The fix shipped three weeks ago. It never arrived.
Someone told me she couldn't select or copy text in an Android app I maintain. Long-press did nothing. She'd been retyping things by hand.
I had fixed that. Three weeks earlier. The commit was on main, the APK was published, the commit message was downright smug about it.
We were both right. The fix existed. It had just never reached her phone.
Debug the client, not the guess
The report came with a theory attached — something about user-select: none on the message bubbles in the web UI. Plausible. Also wrong. Before opening a single stylesheet, two cheap questions.
Which client is she actually using? The access log knows, and the bug report doesn't:
grep "/api/" access.log | awk -F'"' '{print $6}' | sort | uniq -c | sort -rn
198 okhttp/4.9.2
okhttp is React Native's fetch on Android. Not a browser. The web UI was never in the picture, and every minute I'd have spent auditing its CSS would have been a minute spent on the wrong process.
Worth noting what didn't answer this: my own database. Every one of her chat rows said source='web', because the phone app talks to the same /web API the browser does. A column that looks like client identification and isn't is worse than no column at all.
Which build is she running? The app already told me, for reasons that had nothing to do with this bug. It posts its version on every background location heartbeat:
select app_version, max(recorded_at) from location_diag group by 1;
-- 1.1.1 | 2026-09-08
The fix shipped in 1.2.3. Diagnosis complete in two queries, having read no application code at all.
The app is sideloaded from a static URL. No store, no update service, nothing anywhere in the system whose job it is to tell a running install that the world has moved. So the artifact sat on a server for three weeks, four feet from the one person who had asked for it, while she kept using the build without it.
The actual bug was in the distribution
The interesting fix isn't the CSS-equivalent one-liner. It's this: a client that cannot discover it is stale will stay stale forever, and no amount of diligence on my end changes that. So the server now publishes what it has:
@app.get("/api/app-version")
def app_version():
"""Latest client builds available for download."""
return {"android": _published_apk(), "windows": _published_exe()}
With one deliberate choice inside it. The version is not a constant:
def _published_apk() -> dict | None:
apk = _STATIC_DIR / "app.apk"
try:
mtime = apk.stat().st_mtime
except OSError:
return None
if _CACHE.get("mtime") == mtime:
return _CACHE.get("value")
with zipfile.ZipFile(apk) as z:
cfg = json.loads(z.read("assets/app.config").decode())
value = {
"version": cfg["version"],
"versionCode": cfg["android"]["versionCode"],
"url": f"{PUBLIC_BASE}/static/app.apk",
}
_CACHE.update(mtime=mtime, value=value)
return value
It reads the version out of the file it would actually serve — the build config the packager embedded in the APK — and caches on mtime. Copy a new APK into place and the endpoint reports the new number on the next request. No restart, no constant to bump, no deploy step to forget at midnight.
That's the part I'd argue for generally. Any version number stored in a second place is a version number that will eventually be wrong, and it will be wrong in the direction that hurts: advertising a build you aren't serving, or serving a build you aren't advertising. Derive it from the artifact.
The client half is boring on purpose — poll on mount, compare, show a dismissible bar, fail silent on everything:
function isNewer(remote: string, local: string): boolean {
const r = remote.split('.').map(Number);
const l = local.split('.').map(Number);
if (r.some(Number.isNaN) || l.some(Number.isNaN)) return false;
for (let i = 0; i < Math.max(r.length, l.length); i += 1) {
const a = r[i] ?? 0, b = l[i] ?? 0;
if (a !== b) return a > b;
}
return false;
}
Component-wise and numeric, because '1.10.0' > '1.9.9' is false in string comparison and that is a genuinely annoying afternoon.
One more thing you find when you look
Cutting a new build surfaced a second silent failure. A secret was wired up like this:
export const SECRET: string = process.env.EXPO_PUBLIC_SECRET || '';
Deliberately not committed — good instinct. But that reads the build's environment, and the build runs on someone else's machine in the cloud. It never sees my shell. Every cloud build had been shipping an empty string, and the thing it authenticates fails quietly with a 401 nobody reads.
The fix is to put it where the builder looks (a build-profile environment variable, marked sensitive). The part worth stealing is how to verify it, because the bundle is Hermes bytecode and won't grep:
unzip -p app.apk assets/index.android.bundle > bundle
strings -n6 bundle | grep -c "$SECRET"
1
One. It's in there. Three seconds, and now I know instead of hoping. I ran the same check for the string api/app-version and for the banner's own copy, which is a cheap way to prove a feature physically exists in the artifact you're about to hand someone.
The lesson, stated meanly
"Shipped" is a claim about my repository. "Delivered" is a claim about her device. Only the second one was ever what she asked for, and for three weeks I had confidently answered a question nobody had asked.
If your distribution channel has no way to tell a client it's out of date, your changelog is a diary.