Quick answer: Android games crash through uncaught exceptions, and a release build's stack is obfuscated by R8 into single letter names. Install a default uncaught exception handler to catch every thread's failures, keep the mapping file for each build to deobfuscate the trace, and watch for ANRs where the main thread blocks too long. Report device, OS, and GPU context so you can tell a universal bug from a vendor specific one.
Android is the most fragmented platform a game can ship on: hundreds of device models, a dozen live OS versions, and GPU drivers of wildly varying quality. A crash that never appears on your test phone is routine, which makes field crash reporting essential rather than optional. On top of that, release builds run R8 obfuscation, so the stack you capture is a puzzle of single letter names until you deobfuscate it. This post covers installing an uncaught exception handler for Java and Kotlin, keeping mapping files to restore readable stacks, detecting ANRs, and capturing the device context that makes Android crashes diagnosable.
The uncaught exception handler
The foundation of Android crash reporting is Thread.setDefaultUncaughtExceptionHandler, which receives the thread and the throwable for any exception that escapes a thread without being caught. You install it as early as possible, typically in your Application subclass onCreate, so it is active before any game code runs. From the handler you capture the throwable's full stack trace, the thread name, and your context, hand it to your reporter, and then chain to the previous default handler so the system still produces its normal crash and the process terminates properly.
Kotlin does not change the mechanism but does change the shape of the stacks. Coroutines, lambdas, and inline functions produce synthetic frames and class names that look unfamiliar, and an exception thrown inside a coroutine surfaces through the coroutine exception handler rather than a raw thread. So a complete setup combines the default uncaught handler for ordinary thread failures with a CoroutineExceptionHandler on your scopes for the structured concurrency paths. Between them you funnel every failure, whether it came from a background thread, the main looper, or a suspended coroutine, into one reporting point.
Deobfuscating R8 and ProGuard stacks
Release builds shrink and obfuscate your code with R8, the modern replacement for ProGuard, renaming classes and methods to short tokens to reduce size and deter reverse engineering. The side effect is that a crash stack from a release build is unreadable: a.b.c instead of meaningful names. The cure is the mapping file R8 produces for every build, which records the original to obfuscated mapping. You must archive that mapping file alongside each release, because it is the only key that turns an obfuscated stack back into source level names and lines.
Deobfuscation is then mechanical. The retrace tool, or a backend that ingests the mapping file, takes the obfuscated stack plus the matching mapping and prints the original. The discipline is the same as native symbols: one mapping per build, archived at the moment you produce it, never overwritten. If you upload builds through the Play Console it can deobfuscate for you when you supply the mapping, but for your own crash pipeline you keep the file and apply it yourself. A crash you cannot deobfuscate is a crash you cannot act on, so this archival step is non negotiable.
ANRs: the crash that is not a crash
Not every Android failure throws. An Application Not Responding, or ANR, happens when the main thread is blocked for too long, around five seconds for input handling, and the system offers the player a close dialog. For a game this is as bad as a crash, since the frame loop has frozen, but it does not produce an exception your handler will catch. ANRs come from doing heavy work on the main thread: synchronous IO, a long lock wait, a blocking network call, or a garbage collection storm from excessive allocation in the render loop.
Detecting ANRs takes a different technique than catching exceptions. A common approach is a watchdog: a background thread that periodically posts a token to the main looper and checks that it ran within a deadline; if the main thread fails to process it, you capture the main thread's stack as an ANR report. The system also records ANR traces, and on newer Android the ApplicationExitInfo API lets you read why your previous process died, including ANRs and native crashes, on next launch. Treat ANRs as first class reports because to a player a frozen game and a crashed one feel the same.
Capturing device and GPU context
Android crashes correlate strongly with hardware, so context is what makes them tractable. Capture the device model and manufacturer, the Android API level, the available memory, and crucially the GPU vendor and driver, since graphics crashes cluster by chip and driver version. A crash that only happens on one GPU family is a different problem from one that happens everywhere, and you cannot tell them apart without that data. Record the build version and your game's own state, such as the current level or scene, as well.
Many Android games also include native code through the NDK, and a native crash there is a signal, not a Java exception, so it bypasses your uncaught handler entirely. Covering it requires a native signal handler in addition to the Java one, and the native stack needs its own symbols to resolve. Knowing which layer crashed, the managed side or the native side, is itself useful context, so tag reports accordingly. The more precisely each report describes the device, the OS, the GPU, and the layer, the faster you can separate the universal bugs from the long tail of vendor specific ones.
Setting it up with Bugnet
Bugnet takes the report your uncaught handler builds and stores it with the device context attached, so an Android crash arrives as a stack trace plus the model, OS level, GPU, and your custom fields rather than a bare exception string. Supply the mapping file for each build and the obfuscated stacks can be deobfuscated so you read source names instead of R8 tokens. The in game report button gives players a way to send a description and a snapshot of game state for the bugs that frustrate without crashing, which on mobile is a large share of feedback.
The Android long tail is where occurrence grouping earns its keep. The same null pointer exception fires across hundreds of device models, and Bugnet folds them into one issue with a count and a breakdown so you see both the total impact and the devices affected. Filtering by GPU vendor, OS level, or device model tells you instantly whether a crash is a code bug or a driver quirk on one chip, and the occurrence count tells you whether it is worth fixing now. That turns the overwhelming variety of Android into a ranked, contextual list you can actually work through.
Shipping crash handling on Android
Make crash capture part of your release checklist. Install the uncaught handler and the coroutine handlers before game startup, configure the watchdog for ANRs, archive the mapping file with every build, and add the native signal handler if you use the NDK. Then test it on real devices across a range of vendors, because behavior differs: trigger an exception, force an ANR with a deliberate main thread stall, and confirm each produces a deobfuscated, contextual report. Emulators are not enough, since the GPU and driver issues that dominate Android only appear on real silicon.
With the pipeline in place, Android's fragmentation stops being a source of dread. Every new device the market throws at you reports its own crashes with its own context, grouped against the issues you already know, so a launch surge becomes data rather than panic. You learn which device families need special handling and which OS versions carry quirks, and you fix the crashes that affect the most players first. That is how a small team ships a game across thousands of Android configurations without owning a fraction of them.
On Android, archive the mapping file with every build and capture the GPU and device. Without those, your release crashes are unreadable and unsortable.