Skip to content
All posts

R8 and ProGuard for Kotlin, Room, and Compose: the crash that only happens in release

Why a Room + Compose Android app can work perfectly in debug and crash in production, and the specific R8/ProGuard keep rules that catch it before a user does.

MFKAPPS 4 min read

A build that passes every test on your device and then crashes for a user on the exact same version is one of the more unsettling bugs Android offers, and it usually has one cause: minifyEnabled true. Debug builds skip shrinking and obfuscation entirely, so anything R8 would touch never gets exercised until the release APK — the one already on the Play Store — hits code you never actually ran.

This isn’t an argument against minification. Shrinking a Kotlin + Compose app’s method count and stripping unused code meaningfully helps install size and cold start, and Google increasingly expects it. The fix is smaller: know which parts of your codebase R8 can’t reason about statically, and tell it to leave them alone.

Why R8 breaks things that “obviously” work

R8 decides what to keep by tracing reachability from your app’s entry points — activities, the manifest, anything called directly. Code reached only through reflection, a class name stored as a string, or a library reconstructing a type at runtime is invisible to that trace. R8 renames or removes it, the app compiles fine, and the failure only shows up the moment that reflective path actually runs.

Two places this bites a typical local-first Android app in practice:

WorkManager workers instantiated by name

If you’re scheduling background work — the kind behind the reminder scheduling in Hydrame — your ListenableWorker subclasses aren’t called directly. WorkManager persists the worker’s fully-qualified class name in its own database and reconstructs the instance with Class.forName when the work actually runs, sometimes minutes or hours after the app process that scheduled it is gone. Obfuscate that class name and the crash doesn’t happen at schedule time — it happens later, silently, as a ClassNotFoundException deep in WorkManager’s dispatcher, with no stack trace pointing back at your code:

# proguard-rules.pro
-keep public class * extends androidx.work.ListenableWorker {
    public <init>(android.content.Context, androidx.work.WorkerParameters);
}

Recent work-runtime versions ship a consumer rule close to this already, but “probably already covered by the library” isn’t something to take on faith in a release build — verify it, don’t assume it.

Reflection-based JSON for backup and export

Any local-first app with a real export/import flow — the kind Stocky uses to back up pantry data — is serializing entity data class fields to JSON and back. If that serialization goes through a reflective library (Gson, or Moshi without the codegen path), the JSON keys are your Kotlin property names by default. R8 renames private fields it considers safe to rename, the export file it produces still looks fine, and the failure shows up only on import, when the reflective reader looks for a field name that no longer exists in the shrunk class. No crash, no error — just data that silently doesn’t come back.

-keepclassmembers class com.example.app.data.** {
    <fields>;
}
-keepattributes Signature

Scope this to your actual data package, not your whole codebase — a blanket -keep class ** defeats the point of minifying at all.

Testing the build that actually ships

The only reliable way to catch these is to run the release build, not the debug one:

buildTypes {
    release {
        isMinifyEnabled = true
        isShrinkResources = true
        proguardFiles(
            getDefaultProguardFile("proguard-android-optimize.txt"),
            "proguard-rules.pro"
        )
    }
}

./gradlew assembleRelease, install the APK on a real device, and walk through every core flow by hand before each submission — scheduling a reminder, exporting data, then force-quitting and reopening to confirm the import still reads it back. It’s a five-minute check against a class of bug that’s otherwise invisible until Play Console’s crash reports start filling in, which for an app with no third-party crash reporter attached is the only visibility you’ll get after the fact.

One more habit worth keeping: hold on to the mapping.txt R8 generates per release (Play Console will ask for it, and does automatically if you upload via App Bundle). Without it, a crash that does reach you in production is a stack trace full of single-letter class and method names — readable to R8, not to you.

The takeaway

Minification doesn’t fail loudly. It fails at the one code path you didn’t manually retest, weeks after the build that shipped it passed everything you did check. Treat any class instantiated by name — workers, reflective serializers, anything a library reconstructs from a string — as a keep rule you write on purpose, not one you hope a library’s defaults cover for you.