Mobile Development Bug Report

React Native Android release build crashed on launch while debug worked: R8 stripped a class

Our React Native app ran perfectly in debug, then the release APK closed instantly on every device. adb logcat showed a ClassNotFoundException for a class used through reflection by a native SDK, which R8 had removed during minification. How we read the crash, wrote keep rules and verified with mapping.txt.

Marcus Lindqvist Marcus Lindqvist @marcuslindqvist · January 29, 2025 · 7 min read · 35 Views
Banner: React Native Android release build crashed on launch while debug worked because R8 stripped a class

Two days before a planned release, QA installed the first release build of our React Native app on a test device. The splash screen appeared for a fraction of a second and the app closed. No error dialog, no red screen. The debug build, used by everyone during development, worked perfectly on the same device.

Getting the crash

A release build has no developer menu or red box, so the first step is the device log. With the device connected:

adb logcat -c                      # clear old logs
adb shell am start -n com.example.shop/.MainActivity
adb logcat -d | grep -E "AndroidRuntime|FATAL|ReactNative" -A 25
E AndroidRuntime: FATAL EXCEPTION: main
E AndroidRuntime: Process: com.example.shop, PID: 18210
E AndroidRuntime: java.lang.RuntimeException: Unable to start activity ComponentInfo{com.example.shop/com.example.shop.MainActivity}:
E AndroidRuntime:   java.lang.ClassNotFoundException: Didn't find class "com.paymentsdk.internal.a"
E AndroidRuntime:     at dalvik.system.BaseDexClassLoader.findClass(BaseDexClassLoader.java:259)
E AndroidRuntime:     at java.lang.ClassLoader.loadClass(ClassLoader.java:637)
E AndroidRuntime:     at com.paymentsdk.PaymentSdk.init(Unknown Source:42)
E AndroidRuntime:     at com.example.shop.MainApplication.onCreate(MainApplication.kt:31)

A Java class could not be found at startup. The obfuscated name a was the clue: something had renamed or removed code.

Was minification on?

// android/app/build.gradle
def enableProguardInReleaseBuilds = true

android {
    buildTypes {
        release {
            minifyEnabled enableProguardInReleaseBuilds
            proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
            signingConfig signingConfigs.release
        }
    }
}

Someone had turned on enableProguardInReleaseBuilds a week earlier to reduce APK size. Release builds now ran R8, the Android code shrinker and obfuscator. Debug builds never did.

Reading the R8 output

R8 writes reports into android/app/build/outputs/mapping/release/. usage.txt lists removed code:

grep -n "com.paymentsdk" android/app/build/outputs/mapping/release/usage.txt | head
1873: com.paymentsdk.internal.ConfigLoader
1874:     public com.paymentsdk.internal.ConfigLoader()

The payment SDK loaded ConfigLoader by name, using reflection, from a string in its own resources. R8 saw no direct reference to the class, decided it was unused, and removed it. The SDK then tried to load it and failed. The obfuscated name in the stack was the SDK's own internal class that did the loading.

What we ruled out

  • Hermes bytecode issues. A broken JS bundle produces errors from ReactNativeJS or a crash later, not a ClassNotFoundException in Application.onCreate.
  • Signing. A signing problem prevents installation; the app installed and started.
  • Different native dependencies in release. The dependency tree was identical; ./gradlew app:dependencies matched for both variants.

Root cause

Enabling R8 minification for release builds let it remove a class that a third-party native SDK loaded through reflection. The SDK did not ship consumer ProGuard rules to keep that class, and our project had none, so the release build crashed at startup while debug builds, which do not minify, worked.

What R8 does

R8 performs shrinking (removing unused classes and members), optimisation, and obfuscation (renaming to short names). It decides what is "used" by following references from entry points: the Android manifest, classes referenced in code, and keep rules. Anything reached only by reflection, Class.forName, JNI from native code, or serialisation by field name is invisible to it unless a keep rule says otherwise.

React Native apps have several categories of such code: native modules registered by name, classes used by JNI from C++, and third-party SDKs that load implementations dynamically. Well-maintained libraries ship consumer-rules.pro files inside their AAR so R8 keeps what they need. Not all do.

Why debug did not show it

Debug builds skip R8 for faster builds and readable stack traces. Any bug introduced by minification therefore appears for the first time in a release build, often late in the release process.

Who is affected

React Native apps that enable minifyEnabled for release, especially after adding native SDKs for payments, analytics, maps or push notifications that use reflection, and apps upgrading the Android Gradle Plugin, which can make R8 more aggressive in full mode. Apps that never test a release build before submission find out from crash reports.

Environment

Component Version / value
React Native 0.76, Hermes enabled
Android Gradle Plugin 8.x, R8 full mode default
Minification enableProguardInReleaseBuilds = true (new)
Native SDK Payment SDK AAR without consumer rules
Devices All Android versions tested, same crash

Keep rules for the SDK

The SDK vendor's documentation, once we found it, listed the rules to add. In proguard-rules.pro:

# Payment SDK: classes loaded by name from configuration
-keep class com.paymentsdk.internal.** { *; }
-keepnames class com.paymentsdk.** { *; }

# Keep attributes needed by reflection and stack traces
-keepattributes Signature, *Annotation*, InnerClasses, EnclosingMethod
-keepattributes SourceFile, LineNumberTable

Keeping the whole internal package is broader than necessary, but it is what the vendor supports; narrower rules risk breaking on the next SDK update.

Check our own native modules

We searched our own Kotlin code for reflection and found one native module that looked up a class by name for an optional feature. We added an @Keep annotation to it, which is clearer than a rule in a separate file:

import androidx.annotation.Keep

@Keep
class BiometricBridgeImpl : BiometricBridge { /* ... */ }

Verify with mapping.txt and a release smoke test

After rebuilding, mapping.txt showed the SDK class kept with its original name:

grep "com.paymentsdk.internal.ConfigLoader" android/app/build/outputs/mapping/release/mapping.txt
# com.paymentsdk.internal.ConfigLoader -> com.paymentsdk.internal.ConfigLoader:

We upload mapping.txt to our crash reporting service with every release, so obfuscated stack traces from production are readable.

Alternatives and trade-offs

  • Disable minification. The APK was 9 MB larger and slightly slower to start; acceptable as a stopgap, not as a fix.
  • R8 compatibility mode (android.enableR8.fullMode=false) is less aggressive and can avoid some issues, but hides missing rules instead of fixing them.

How to test the fix

The CI pipeline now builds the release variant and runs a smoke test on an emulator that launches the app and waits for the home screen:

cd android && ./gradlew assembleRelease
adb install -r app/build/outputs/apk/release/app-release.apk
adb shell am start -W -n com.example.shop/.MainActivity
adb logcat -d | grep -c "FATAL EXCEPTION"     # expect 0

A Maestro flow then checks that the home screen and the checkout screen (which initialises the payment SDK) render.

Confirming in production

The release shipped with minification enabled. The APK size dropped by about 9 MB compared with the unminified build, and crash-free sessions stayed at 99.8%, the same as the previous release. Readable stack traces arrived in crash reports thanks to the uploaded mapping file.

Regression guard

  • Every pull request that changes native dependencies or ProGuard rules builds and smoke-tests the release variant.
  • Upgrading any native SDK requires checking its release notes for keep rules.
  • mapping.txt upload is a required step in the release pipeline.

Security implications

Obfuscation is not a security control, but it does make casual reverse engineering slightly harder, and shrinking removes unused code that could contain vulnerabilities. Keeping a whole SDK package unobfuscated exposes its internal names; that is the vendor's supported configuration, so we accepted it. Never put secrets in the app expecting obfuscation to hide them.

Prevention checklist

  • Test release builds early, not only debug builds.
  • Read adb logcat for release crashes; there is no red box.
  • Add keep rules or @Keep for code reached by reflection or JNI.
  • Check that native SDKs ship consumer rules, and follow their documentation.
  • Upload mapping.txt with every release.

References

Add a label

#android #react-native #r8 #proguard #hermes
Written by
Marcus Lindqvist @marcuslindqvist

I have spent most of the last decade on mobile teams, first native iOS, then React Native and Flutter for companies that needed one team to ship to both stores. The work I enjoy most is the unglamorous part between "it runs on my phone" and "it is live for a million users": code signing, release builds, store review, crash triage and background behaviour that differs on every Android vendor. Most of the bugs I write about here cost a release day. Each post shows the exact error, what we ruled out, the fix and how we proved it in the next build, so you can get your own release out the door sooner.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.