ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-06-22Advanced

It Only Crashes in Release — Tracking Down When R8 Full Mode Broke My Gson Mapping

It works in debug but crashes only in release. This walks through the R8 full-mode pattern that breaks Gson reflection, how to reproduce it locally, deobfuscate the stack trace, and write the smallest keep rules that fix it.

android23r8proguardgsonrelease-build

Premium Article

One morning Crashlytics was full of a NullPointerException I'd never seen. The top of the stack trace was obfuscated symbols like a.b.c.d — class names I never wrote. Running a debug build on my own device reproduced nothing. A few hours after release, only users who opened one particular screen were crashing. As an indie developer who has run ad-supported wallpaper apps for years, this "only crashes in release" category of bug has tripped me up more than once.

The cause is almost always in the same place. R8's full-mode obfuscation rewrites the field names of the data classes that Gson reads and writes via reflection, so the mapping to the JSON keys falls apart. The debug build has obfuscation disabled, so it runs fine; only release breaks. That asymmetry is exactly what makes it so hard to reproduce, and why you can burn hours before you find it.

This article is a record of how I tracked the problem down the last time I hit it, and how I ultimately fixed it. "Just keep everything and it goes away" is technically true, but that's abandoning the design decision — you throw away both the obfuscation and the size reduction at the same time. We'll go all the way to protecting only the broken class, as narrowly as possible.

Why it never happens in debug and only crashes in release

In an Android release build, when minifyEnabled true, R8 performs shrinking (removing unused code) and obfuscation (shortening class, method, and field names). Since AGP 8.0, R8 is the standard code shrinker, and "full mode" is enabled by default. Full mode optimizes more aggressively than the legacy ProGuard-compatibility mode: anything not explicitly protected by a keep rule is removed or renamed more freely.

The trouble is that Gson matches fields by name. When mapping {"display_name": "Sunset"} onto data class Category(val displayName: String), Gson reads the field name via reflection at runtime, and absent an annotation it uses the field name itself as the JSON key. But if R8 has renamed that field to a, what Gson sees is a, not displayName. The JSON key never matches, and the value stays unfilled as null.

In the debug build, minifyEnabled false, so the field names stay intact and nothing breaks. That asymmetry is the real source of the difficulty. As long as the person who wrote the code runs it on their own machine, it almost never reproduces.

Reproduce the release behavior locally

Killing the cause by guesswork is slow. The fast path is to first make the release behavior reproduce reliably on your own machine.

# Build the release variant and put it on a device/emulator
./gradlew assembleRelease
 
# Install the signed release APK (adb install works even if it isn't debuggable)
adb install -r app/build/outputs/apk/release/app-release.apk

Now the same obfuscated code that ships to production runs on your machine too. "Only crashes in release" makes it feel like you can only investigate in production, but a single assembleRelease puts you in a state where you can reproduce it on your own device as many times as you like. There was a period where I skipped this and just stared at production logs, and that was the single biggest waste of time.

Open the screen that crashes, trigger it, and capture the stack trace with adb logcat. The trace at this point is still obfuscated symbols, so the next step is to translate it back to the original names.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
You can reproduce a release-only crash on your own machine instead of staring at production logs
You'll understand why R8 full mode breaks Gson reflection, and how to restore an obfuscated stack trace with mapping.txt and retrace
You can stop hiding behind 'keep everything' and protect only the broken class with minimal keep rules in your own app
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

App Dev2026-06-22
Porting a Wallpaper Viewer's Slideshow and Page-Scrubber from iOS to Android — Where the Two-Way Sync with SnapHelper Tripped Me Up
A hands-on record of porting a full-screen wallpaper viewer's slideshow and bottom scrubber from iOS to Android. How I pinned down the current page with RecyclerView and SnapHelper, synced it two-way with the scrubber, and resolved the conflict between auto-advance and user input with a small state machine — in working Kotlin.
App Dev2026-06-21
The Back Button Showed an Interstitial Sometimes, Not Others — Rewriting Nested ifs Into a List of Independent Guards
Interstitial display on back press was unstable because nested if statements hid the priority between conditions. Here is how I split it into reason-returning guards and generated tests from a decision table.
App Dev2026-06-17
Stop Dialogs From Stacking: One Gate for Paywalls, Review Prompts, and Rewarded Ads
A field record of curing the bug where a paywall, a review prompt, and a rewarded ad all surface at once, fixed with a single priority-based modal gate. I let an Antigravity agent sweep up the scattered show() calls, but kept the display policy in my own hands.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →