An Android app that records walking routes and claims territory from them. The handoff document listed 25 items; I fixed and shipped the one that was actually first. The document had underrated it.
Three strings sat like this:
| String key | What it does | English | Korean |
|---|---|---|---|
| Notification action | cancel() — discards the recording |
Stop walk | 종료 |
| In-app button | close() — claims the territory |
End walk | 종료 |
| In-app button | cancel() — discards |
Cancel | 취소 |
The button that discards and the button that claims carried character-for-character identical Korean labels.
One question first. Have you ever laid out the labels of two opposite actions, across every language, in a single table?
1. The app itself steered users into that button
At the same moment, another notification was saying: "You're near your starting point. Tap 〈that label〉 to claim your territory."
So the app promised a claim and pushed the user toward a button that destroyed their walk. The recorded route is this app's only output, and it has the largest active base in the fleet.
2. Neither code review nor unit tests can catch this
Each function was correct. The tests passed. cancel() is supposed to discard and close()
is supposed to claim.
The defect was which label sat on which function. That fact lives neither inside the function nor in any single call site. It only becomes visible when you lay out every string that points at the same action in one table.
⚠️ In English they barely separate as Stop and End, and pass. The collision is born in translation. Same family as the letter spacing that ate the word: a defect that does not exist in the source and is created by localization.
The cause differs from the tests that passed while the button did nothing. There the function was the defect. Here the label is the defect.
3. How would you fix it?
- (a) Change the notification action's label
- (b) Remove the notification action
- (c) Change what the notification action does
(a) alone leaves the ability to discard a walk from the notification intact. Irreversible destruction from a surface you tap without opening the app is not a labeling problem. (b) removes any way to stop a walk in progress.
The answer was (c): invent a third action.
4. The notification neither discards nor claims
stopTrackingKeepingWalk()
phase = PAUSED // ← first
flush() // persist the recording
tearDown() // cancel only the collection job, keep the scope alive⚠️ Reverse the order and the save is skipped. The persist function is gated on phase, so in
IDLE/CLAIMING it writes nothing. And if tearDown() cancels its own scope, the flush dies
with it. Same family as putting cleanup after an early return.
The labels split into Pause tracking / 추적 일시중지, and the hint text now says "open the
app". Discarding happens only through 'Cancel' inside the app.
5. Making the test deterministic required injecting a dispatcher
I could not verify "cancel after it's saved and it's deleted", because the row-id assignment
happens behind a withContext(Dispatchers.IO) boundary.
⚠️ Neither yield() nor the mocking library's coVerify(timeout = …) waits for a real IO
thread to resume. The timeout waits for "it was called" and never sees the assignment after it.
So the test passes intermittently — and an intermittently passing test has not passed.
internal var ioDispatcher: CoroutineContext = Dispatchers.IO // same production default
// tests inject UnconfinedTestDispatcher (5 sites replaced)The main scope was already Dispatchers.Main.immediate, so the test rule had it covered.
Verification: 409 tests, 0 failures, release lint clean. Production rollout 100%.
6. How the defect was actually found
Not by reading the code. By building a table of strings.
The ranking flipped while re-reading the handoff document from first principles. The document's prose was good; only its ranking was wrong. Which of the 25 items this was doesn't matter. What matters is that "the priority the document assigned" and "what actually happens to the user" are different axes.
What I still don't know
- I don't know how many users actually lost a walk to this button. The instrumentation never distinguished the two paths. So this post has no damage figure.
- The iOS build of the same app does not have this defect (verified). It has no notification action buttons at all, and the watch surface separates discard and claim by shape — a red X versus a checkmark. Distinguishing by shape rather than by label was the right design.
Three things to check in your own app
- Export the labels of two irreversible actions across every locale into one table. Two rows with the same value is this defect.
- Can a destructive action be triggered from outside the app — notification, widget, watch? Those surfaces have no confirmation screen.
- Does your cleanup function cancel its own scope? Anything that saves after it will not run.
The honest part
The handoff document for this app was well written. All 25 items were described accurately, and I found almost no new facts. The document got exactly one thing wrong: the ranking.
Because of that one thing, a defect that makes users lose their walks — in the app with the largest active base — was sitting in the "later" column. Trusting a document and trusting its priorities are two different acts.
Do one thing today. Pull the duplicated values out of your string resources. sort | uniq -d
is enough. Then just check whether any of those duplicates cover two opposite actions.