← Zippy Town

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:

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 advertises GL_KHR_texture_compression_astc_ldr in its extension string, while Filament's Texture.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

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.

Get it on Google Play