Google rejected my app for crashing and sent me one screenshot
18 September 2026
The entire evidence package was a photograph of the dialog Android shows when an app dies. No stack trace. No device model. No Android version. Nothing in Play vitals, nothing in my analytics, and it worked perfectly on every phone I owned.
Zippy Town is an Android live wallpaper — a small 3D cartoon city that keeps running on your home screen, rendered natively with Filament. I submitted it, and it came back rejected for crashing.
This is how I found the bug with no crash report, and the thing I got wrong on the way.
Four reasons I had no data
The first useful realisation was that the absence of evidence wasn't bad luck. Four separate decisions, each defensible on its own, had combined into a total blackout:
- Analytics are opt-in. PostHog stays opted out until the user consents. A reviewer never consents, so a reviewer never reports anything.
-
The wallpaper process carries no SDK at all. The live wallpaper runs in a
separate
:wallpaperprocess, and I deliberately kept analytics and ads out of it to hold its memory down. That also meant it had no crash reporting whatsoever. -
It was a native crash. The process died on
SIGABRTinside a C++ library. No Java exception was ever thrown, so noUncaughtExceptionHandleron earth would have seen it. - Vitals needs a shipped app. Android vitals collects from real installs. The app had none, because it hadn't shipped, because it had been rejected.
There's a lesson in that list on its own. Every one of those was a reasonable call. Nobody decided to make the app undebuggable; it emerged from four sensible decisions meeting each other.
Building a black box first
I stopped trying to guess the bug and wrote the diagnostic I wished I'd had: a startup dump of what the device can actually do. Manufacturer, model, board, ABIs, SDK level, memory class, whether the system considers it low-RAM, Filament's feature level, and — the one that mattered — which texture formats the GPU can genuinely sample.
It logs at Log.i, not behind a debug flag, because the entire point is to be
present in a release build on somebody else's phone. It costs a handful of string lookups,
once, per process.
My four test devices were all Samsungs. Between them they covered one GPU vendor, one ABI family, and none of the ten years of Android versions the manifest claimed to support. So I spun up an emulator instead — API 30, software rendering — and pointed the new logging at it.
It died immediately
Filament: [Android Emulator OpenGL ES Translator (Google SwiftShader)]
[OpenGL ES 3.0 ...] Feature level: 1
ZippyCaps: ASTC UNSUPPORTED on this GPU
F libc : Fatal signal 6 (SIGABRT) in tid 6891 (zippy-native-hu)
F DEBUG : Abort message: 'terminating due to uncaught exception
of type utils::PreconditionPanic'
F DEBUG : #07 utils::TPanic<utils::PreconditionPanic>::panic(...)
libfilament-jni.so
Zygote : Process 6820 exited due to signal 6 (Aborted)
There it is. Every texture in the city is
ASTC, a GPU-compressed format. On a GPU that can't sample ASTC, Filament doesn't politely
return null from its texture builder — it panics, and a panic in C++ means
abort(), which means the process is gone. To the user: "Zippy Town keeps
stopping." To me: absolutely nothing.
ASTC is only guaranteed from OpenGL ES 3.2. At 3.0 and 3.1 it's an optional extension, and my manifest required 3.0. The app was installable on hardware that could not sample a single one of its textures.
The part that surprised me: the device lied
The obvious fix is a Play Store filter. There's a manifest tag for exactly this —
<supports-gl-texture> — which tells Google not to offer the app to
devices lacking a given texture format. Ship that and the problem goes away.
Except it doesn't, and this is the bit I'd want someone to tell me:
The emulator advertisesGL_KHR_texture_compression_astc_ldrin its extension string, while Filament'sTexture.isTextureFormatSupported()returns false for every single ASTC format.
Play filters on what the device claims. The renderer cares about what the device does. Those are two different questions, and I had assumed they were the same one. A store filter reduces exposure; only a runtime check prevents the crash. I shipped both, and the comment in the manifest now says why neither replaces the other.
Proving it rather than believing it
The fix is three lines — ask the GPU before building the texture, and skip the texture if the answer is no:
if (!Texture.isTextureFormatSupported(engine, internalFormat)) {
Log.e(TAG, "$path: GPU cannot sample $internalFormat - skipped")
return null
}
A fix that makes a symptom disappear is not the same as a fix for the cause, so I took the guard back out and watched it die again, then put it back and watched it live: 1,850 textures skipped, an untextured grey city, and a fully interactive app that did not crash. Wrong-looking, but running and reportable — which a dead process is not.
There's no uncompressed fallback, deliberately. Decompressing the textures would multiply GPU memory on precisely the low-end devices least able to afford it. A device without ASTC is not a device with room for uncompressed RGBA.
The fix that would have hidden the bug
Then I nearly shot myself in the foot, and this is my favourite part of the whole episode.
I was planning to hunt for more affected devices using an automated device farm, which decides pass or fail on whether the process died. But I had just made the app stop dying on exactly the devices I was hunting for. Every ASTC-less device in the matrix would have come back a clean green pass, rendering an invisible grey city, and I'd have concluded the problem was fixed everywhere.
So the two builds now disagree on purpose. The release build degrades quietly. The test build throws, loudly, naming the reason — so the farm flags the device with a stack trace instead of a false pass. Hunt with one, ship the other.
Verifying that found a second bug: the renderer runs on its own thread whose loop
ends in catch (Throwable), which swallowed the deliberate crash, killed the
render thread, and left the process alive. The farm would still have scored it a pass. It
has to be posted to the main thread to count as a crash to anything that's watching.
The theory that was wrong
I was also fairly convinced memory was involved. Setting a wallpaper runs two copies of the renderer at once — the preview in the app, and the wallpaper service — and that doubles graphics memory at the worst possible moment.
So I tested it on the oldest phone I own, a Galaxy S6 from 2015, with both engines live:
main process TOTAL PSS 180 MB (EGL 41.6 MB)
:wallpaper TOTAL PSS 118 MB (EGL 43.2 MB)
system MemFree 48 MB, 1.2 GB of swap consumed,
7 other apps killed to make room
And it did not crash. It rendered the city correctly the entire time, on a device below the memory floor I'd been considering. The doubling is real, the pressure is real, seven other apps died — but the theory I was most confident about was simply wrong, and an afternoon with a real device said so in a way that no amount of reasoning had.
What the S6 did tell me was different and more useful: it managed about 10fps. Not a crash, but not something I'd want anyone to rate out of five stars either.
What shipped
- The runtime guard, which is the only thing that actually prevents the crash.
- A store filter requiring GLES 3.2, where ASTC is mandatory rather than optional — so the bad combination can't be installed in the first place.
-
A permanent black box. Android has recorded how every process died since
API 30, via
ApplicationExitInfo, including native aborts and including processes that carry no SDK at all. The app reads that on its next launch. It adds nothing to the wallpaper process, needs no library, and it's the crash reporting that should have existed before any of this. - Framerate telemetry, keyed by chipset, so that the next decision about which devices to support is made from measurements rather than from my guesses about chip naming conventions.
What I'd take away from it
Test on hardware you didn't choose. Four phones from one manufacturer is one phone, tested four times.
Write the diagnostic before the fix. The capability dump took an hour and
turned a guessing game into a log line that says ASTC UNSUPPORTED on this GPU.
Every hour after that was cheaper for it.
What a device claims and what it can do are different questions. Ask the one you actually care about.
Check whether your fix breaks your ability to find the bug. Graceful degradation and automated detection want opposite things, and it is very easy to ship both and detect nothing.
The app is live now. It took an embarrassingly long time to find a three-line fix, and I'd still rather have spent it building the black box than reading tea leaves in a screenshot of a dialog box.
Zippy Town
A tiny cartoon city that lives on your home screen. You can fly around it in your browser at zippy.town — no install needed — or get it on Android.