The Silent Regression — Alerts that were detected, queued — and never sent. A hunt by one developer and his AI
Field Log | October 2026 | By Thierry Bremard
XOPOZ
Xopoz is built by one engineer, Thierry Bremard, the author of this story. There is no QA department, no on-call rotation, no release manager. What there is instead: an automated test suite of several hundred tests, a support pipeline that lets any user send a ticket with the app's log files attached, and a pair-programming partner that never sleeps — Claude Code, an AI assistant working directly in the developer's terminal.
In this log the developer is called Master and Claude Code is called Slave. These are the names we actually use at the keyboard, and they describe the roles honestly. Master is the strategic architect: he knows the product, its history, its users and every change made to it, and he decides where the hunt goes. Slave is the engine: it reads, searches, runs commands and proposes, at a speed no human can match — and, left alone, it runs in every direction at once. This story is about how Master kept refocusing that frenetic engine until it hit the target. The dialogues are quoted from the real session, shortened only where needed.
The Words of This Story
This is a technical story, but it should be readable without being an Android developer. Here are the terms that come back throughout.
The Silence
Every story needs its cast. This one has two phones in the same Xopoz team — and the two people who carry them.
Tiri — the requester. Tiri is Master himself, off duty. He has set a geofence on Bibi's workplace, so his phone tells him when she leaves work… and therefore exactly how much time he has left before she gets home. A very useful feature for someone who promised to clean the house.
Bibi — the monitored device. Bibi leaves work most weekdays in the afternoon. When she crosses the edge of the zone, her phone is supposed to send Tiri one small encrypted message: "Bibi has left work."
On 1 October, the afternoon went like this:
15:24 · Bibi walks out of work. Her phone notices she is moving.
15:29 · Her phone confirms she has really left the zone, prepares the alert "Bibi has left work" and puts it in its outgoing queue. So far, everything works.
15:30 · At home, Tiri is on the couch. A movie is playing. The vacuum cleaner stands untouched in the corner, the dishes wait in the sink. His phone lies next to him, silent. No alert means Bibi is still at work. Plenty of time.
15:45 · Still nothing. "She must be working late today." Tiri starts the next episode.
~16:05 · A key turns in the door.
I'm home! … Wait. Weren't you supposed to clean the house today?— Bibi
You… you're already back? My phone said nothing! According to Xopoz, you never left work!— Tiri
I left at half past three, like every day. Maybe your famous app is broken.— Bibi
Tiri was caught. Thinking back, the day before had been silent too. And the strange part was that everything else looked normal: Bibi's position had kept moving across the map the whole way home — if only Tiri had looked at the map instead of the movie. The app opened without errors, nothing had crashed. That is what makes this kind of failure dangerous. When a feature breaks loudly, someone notices within minutes. When it breaks silently, the system looks alive while an important part of it is dead, and users simply believe that nothing happened. Here, the price was a messy house. For a team relying on SOS alerts, it could be much higher.
That evening, Tiri put his developer hat back on and became Master again. He sent two support tickets: one from Bibi's phone (the monitored device, which should have sent the alert) and one from his own phone (the requester, which should have received it). Sending both was deliberate: a message has two ends, and the problem could be on either side. Each ticket carried five days of logs. Then he handed the evidence to Slave.
There is an issue on geofencing. I sent two support tickets. Load both of them and work with both of them.— Master
The Frenetic Engine
Slave does what it does best: it reads everything, at once, at a speed no human can follow. Within minutes it had unpacked ten log files, more than 30 MB of text, counted every background job of the last five days and cross-checked them against the server's own log. Its first report was accurate — and buried in history:
Both loaded. Bibi's phone detected four exits — 29 September twice, 30 September, 1 October — and queued four alerts. None was sent. The sender ran zero times in five days, while the poll ran about 58 times a day. On the server, the last message from that phone is dated 26 September. Tiri's phone shows the same pattern. I need to see the scheduler on a real phone.— Slave
Every word of it was true. Here is what the logs showed, for the record:
| Phone and build | Build type | Sender runs | Messages reaching the server |
|---|---|---|---|
| Bibi's phone, store version until 26 Sep | release | normal | ✅ |
| Tiri's phone, developer's local build | debug | 7 | ✅ |
| Tiri's phone, newest version | release | 0 | ❌ |
| Bibi's phone, newest version | release | 0 | ❌ |
But look at where the report was pointing: five days of statistics, a table of builds, a date in the past. Slave was drifting into history, and it kept going back to that date:
The last message from that phone reached the server on 26 September…— Slave
Master's call #1 — "The bug is today"
You know the date of today? Why do you pop up 26 September???— Master
It is the last day a message from that phone reached the server…— Slave
Dummy, the bug is today.— Master
A user complained about today's missing alert. An investigation that wanders through history can end up explaining the past perfectly while fixing nothing. Master cut the drift with one sentence: one failure, today, observable, testable right now. Everything else was noise.
Sorry Master. Focusing on today. Bibi's phone detected the exit at 15:29:51, saved the alert in its outgoing queue and asked the sender to run. The sender never started. The geofence logic is fine. The alert never left the device.— Slave
# Bibi's phone (monitored device), release build 2026.09.28.01, 1 October 15:29:51.900 [GEOFENCE] alarm LEAVE on geofenceId=… ← exit detected 15:29:51.912 Saved Tp message: …-4 ← alert saved in the outgoing queue ("Tp" = transmission pending) 15:29:51.917 Scheduled FlushWorker (KEEP policy) ← sender asked to run … no "FlushPendingMessagesWorker.doWork() entry" — ever ← the sender never started
Now the hunt had a single, sharp target. The sender — the FlushWorker — writes a line as the very first thing it does when it starts (doWork() entry). That line never appeared. The sender was being asked to run, but never actually ran. Bad news for Tiri: Bibi was right, the app had failed him. Good news for Master: the detection side worked, and the problem lived somewhere between "alert queued" and "alert sent".
The table above also explained why the automated tests had been green: they run on the developer's machine against unoptimized code, while the bug only lived in the shrunk release that users install. A clue — but release builds differ from debug builds in a dozen ways, and none of them pointed anywhere yet.
Theories at Full Speed
Log files only show what the app chose to write down. To see what Android itself was doing, Master borrowed Bibi's phone and plugged it into the workstation, and Slave used adb to ask the phone's operating system directly.
This is where the engine shines. Every adb command in this story was typed and executed by Slave itself, at lightning speed — each one returns in a second or two, and Slave reads the answer, decides on the next command and fires it before a human would have finished typing the first. More importantly, Slave knows Android's internals for troubleshooting by heart: which system service holds the background jobs (dumpsys jobscheduler), how to read a process's age, which hidden broadcast makes WorkManager dump its state, how to force-stop and relaunch an app without touching its data. A human developer looks these up; Slave simply uses them. Master only had to plug in the phone.
Android has its own scheduler, called JobScheduler, which decides when background jobs may run. WorkManager hands its jobs to it. So the first question was simple: which jobs does Android know about for Xopoz?
# Which background jobs does Android hold for the app? adb shell dumpsys jobscheduler | grep com.tbr.xopoz → one job only: the periodic poll. No sender job at all. # How long has the app process been alive? adb shell "ps -A -o PID,STIME,ELAPSED,NAME | grep xopoz" → 3 days, since the minute of the last app update # Ask WorkManager to print its internal state adb shell am broadcast -a androidx.work.diagnostics.REQUEST_DIAGNOSTICS -p com.tbr.xopoz → nothing printed: Xopoz release builds remove Android's system log calls, so WorkManager's own error messages are invisible on a user's phone
Three commands, three answers, in under ten seconds — and Slave already knew what each answer meant inside Android: a missing job in JobScheduler means the work was never handed to the operating system, a three-day-old process means nothing has restarted WorkManager since the update.
Slave immediately built a theory around it. The sender is scheduled with a KEEP policy — "if a sender is already pending, do not create a second one", normally a protection against sending the same message twice. If one sender had got stuck inside a process that never restarts, KEEP would silently swallow every new request for days. A "zombie" sender. Plausible, elegant, and testable: forcing the app to restart makes WorkManager clean up stuck work.
Decisive test: force-stop the app. If the four queued alerts go out on restart, the zombie theory is proven. Side effect: four old alerts land on your phone — four times "Bibi has left work", days too late.— Slave
Yes, force-stop it.— Master
Restarted at 20:45:34. Sender scheduled again. Android shows the job START, then STOP "canceled" 54 ms later. Still no doWork(), still nothing on the server. The zombie theory is dead.— Slave
# force-stop, relaunch, then read Android's job history adb shell am force-stop com.tbr.xopoz ← kill the app completely adb shell monkey -p com.tbr.xopoz -c android.intent.category.LAUNCHER 1 ← start it again, as if tapped adb shell dumpsys jobscheduler | grep -E "START|STOP" | grep xopoz ← read Android's job history -30s087ms START: #u0a435/389 …SystemJobService -30s033ms STOP: #u0a435/389 …SystemJobService canceled
Two of these commands deserve a word. am force-stop asks Android's activity manager to kill the app completely, background service included, exactly as the "Force stop" button in the phone's settings does. monkey is a tool built into Android for stress-testing apps: it normally fires thousands of random taps and swipes at an app. Asked for a single event in the app's launcher category (-c android.intent.category.LAUNCHER 1), it does just one thing — it opens the app exactly as if a user had tapped its icon on the home screen. It is the simplest reliable way to start an app from the command line without knowing the name of its first screen.
Again, the whole sequence — stop the app, relaunch it, wait, read Android's job history — was run by Slave in a few seconds. The line that mattered was buried among hundreds of other jobs in the system's dump; Slave knew where JobScheduler records the start and stop of each job, and the reason Android gives for every stop.
A third support ticket, sent from Bibi's phone right after the restart, confirmed it from the inside: the sender was scheduled again, and again never started. The 54 milliseconds were the real clue — far too short for any network call, just long enough for WorkManager to start preparing the job, fail, and give up. The sender was not stuck. It could not even be built.
How Slave read the scheduler
None of these command outputs says "the sender failed". The diagnosis came from reading them together, the way an Android specialist reads an X-ray: each answer rules something out, until only one place is left where the failure can live.
| What Slave saw | What it means inside Android |
|---|---|
The app's log says Scheduled FlushWorker, but dumpsys jobscheduler lists only one pending job for Xopoz — the periodic poll, waiting for its next 15-minute slot. |
The sender request reached WorkManager, but no sender job was waiting in Android's scheduler. Either it was never handed over, or it had already run and finished. |
| The app process has been alive for 3 days. | Nothing has restarted WorkManager since the update. That made the "zombie" theory possible — and worth testing. |
After the force-stop and relaunch, the app logs Scheduled FlushWorker again, and Android's job history shows a new job: START, then STOP … canceled 54 ms later. |
The job was valid and its conditions were met — Android did start it. Then it was cancelled by the app side itself, which WorkManager does when it considers the work finished. Finished in 54 ms: far too fast to send anything. |
| No new sender job appears in the pending list afterwards. | WorkManager did not plan a retry. The work did not end in "try again later" — it ended in failure. |
The app's own log, from the third ticket, still has no doWork() entry after the restart. The server receives no message. |
The app's sender code never got control. Whatever failed, failed before the sender object even existed. |
Put together, the readings left exactly one suspect zone: the few milliseconds inside WorkManager between "Android starts the job" and "the sender object is created". Nothing in Xopoz's own code runs there — only WorkManager's preparation steps. That is precisely the window Chapter 4 opens.
The Clue That Needed Master
The question was now narrow. Two jobs, same library, same network requirement, same app process. The poll runs every 15 minutes without fail; the sender never runs. The difference is the type of job: the poll is periodic, the sender one-time. And for one-time jobs only, WorkManager performs an extra preparation step: it creates a small helper object called an InputMerger. (One-time jobs can be chained, and the merger combines the results of the previous jobs into the input of the next one. Xopoz does not use chains, but WorkManager creates the merger anyway, for every one-time job.)
One-time work is the only path where WorkManager creates an object from a class name stored as text — by reflection. Checking the compiled code… In the release, androidx.work.OverwritingInputMerger has no constructor at all. R8 removed it.— Slave
A missing constructor. A precise, real finding — and still only half an answer. Why would the shrinker suddenly remove a constructor that had been there for years? Slave had the symptom; it did not have the cause. This is where Master stepped in.
Master's call #2 — "Beware of reflection"
Beware of reflection. We upgraded different component versions since one month, like AGP and other versions. Continue.— Master
This came from experience, not from the logs. Master knew what no log file could tell: a few days earlier he had upgraded the build toolchain. He had not mentioned it at the start because nothing connected a build tool to a geofence alert. But the moment the word reflection appeared, the architect saw the connection at once. A toolchain upgrade brings a new version of R8, the shrinker that decides what survives in a release build. Code reached only through a name stored as text is exactly what a shrinker cannot see, and may silently throw away. That would also explain why only release builds were broken: debug builds are not shrunk.
With that direction, Slave stopped looking at symptoms and went straight to the cause: compare the release built before the toolchain upgrade with the release built after it.
# Extract the compiled code (dex) from both releases and compare unzip -q xopoz-2026.09.25.01-release.aab 'base/dex/*' dexdump -d base/dex/classes.dex | grep -A12 "Landroidx/work/OverwritingInputMerger;" # release built with AGP 8.13.2 (before the upgrade) Direct methods - name : '<init>' type : '()V' ← the constructor is there # release built with AGP 9.1.1 (after the upgrade) Direct methods - (empty) ← the constructor is gone # R8 also writes down everything it removed — and confirms it grep -A1 "^androidx.work.OverwritingInputMerger:" app/build/outputs/mapping/release/usage.txt public void <init>()
The cause was nailed. For every one-time job, WorkManager reads the name OverwritingInputMerger from its own database and asks Android to create an object of that class. Under AGP 8 the constructor was there; under AGP 9 the new R8 had removed it. Creation fails, WorkManager marks the job as failed and moves on. It does write an error message — but Xopoz release builds remove Android's system log calls, so nobody could see it. No crash, because the failure is caught; no retry, because a job that cannot be created is not retried; no trace in the app's own log, because the app's code never gets control.
Following Master's warning, Slave then checked every other class in the app that R8 had kept but stripped of its constructor, to know whether more traps were waiting. Four came out. Two were WorkManager's input mergers — the live problem. The other two — a network-engine helper from the ktor library and RecyclerView's LinearLayoutManager — turned out to be harmless in Xopoz: the app chooses its network engine explicitly in code and never declares a layout manager in XML. One live bomb, two sleeping ones, now documented.
Is It Us, or Is It the World?
With the cause proven, the support analysis had done its job. In Xopoz, a support ticket never touches the code: it only produces evidence and, when a real defect is found, an agile ticket — the plan of the code change, written down for Master to review before anything is done. Slave wrote that agile ticket with the shortest fix it could see:
Agile ticket created, waiting for your review. The fix is one line in our ownproguard-rules.pro:-keep class * extends androidx.work.InputMerger { public <init>(); }. Rebuild, check the dex, done.— Slave
This is exactly why the review step exists. The patch would have worked. It was also exactly the reflex of a fast engine: patch the symptom where you stand. A line written into our own app, minutes after discovering the bug, to compensate for a gap inside somebody else's library — the kind of patch that ages badly, that nobody remembers the reason for, and that can hide the moment the real problem is fixed upstream or comes back in another form. Master did not approve it.
Master's call #3 — "Search the web first"
Before doing this task, search the web for any insight on this issue. Is there an official guideline on Android? This looks like a major breaking change from the vendor.— Master
Master does not let a patch go in before knowing whether the vendor already has a fix. If this was a change made by Google, then Google had documented it — and maybe already corrected its own library.
It was, and they had. The official AGP 9.0 release notes describe the change: a setting called android.r8.strictFullModeForKeepRules changed its default from false to true. In plain words: until AGP 8, a keep rule that said "keep this class" silently also kept its basic constructor. From AGP 9 on, "keep this class" keeps the class — and nothing more. Google's own wording: "-keep class A no longer implies -keep class A { <init>(); }". Several other projects on GitHub had already hit the same wall with WorkManager after moving to AGP 9.
Then the decisive check: what does WorkManager itself ship, in the version Xopoz was using and in the latest one? A library on Android is distributed as an archive (an AAR) that contains its keep rules, so they can be read directly:
# Read the keep rules bundled inside each WorkManager package unzip -p work-runtime-2.9.0.aar proguard.txt | grep InputMerger -keep class * extends androidx.work.InputMerger ← class only unzip -p work-runtime-2.12.0.aar proguard.txt | grep InputMerger -keepnames class * extends androidx.work.InputMerger -keepclassmembers class * extends androidx.work.InputMerger { void <init>(); } ← constructor too
Google had already fixed it on their side, in WorkManager 2.10.0, by adding the constructor to the library's own rule. Xopoz was still on WorkManager 2.9.0, whose rule had worked for years under the old meaning of "keep this class" — and broke only when the toolchain changed that meaning underneath it.
This changes my recommendation. Our local keep rule would only duplicate what the library already ships. The cleaner fix is to upgrade WorkManager to 2.12.0 and take Google's own rule. The third road, switching R8 back to the old behaviour with strictFullModeForKeepRules=false, is a transition setting that is expected to be removed in a future AGP version — not advised.— Slave
Master's call had turned a patch into a proper fix. A local patch stays in our code forever and duplicates the vendor's work. A global switch back to the old R8 behaviour only postpones the problem. The library upgrade takes the fix from the people who own the code that needs it, and stays correct as both the library and the toolchain evolve.
The Fix: Take Google's Rule, Not a Patch
Upgrading a library is never free: a new version may require a newer Android version on the user's phone, or pull in new versions of other libraries. Before giving the order, Master checked the cost for his users:
If I upgrade WorkManager to 2.12.0, is there a chain effect on other dependencies, like the min SDK?— Master
No blocker. 2.12.0 needs minSdk 24, compileSdk 35 and AGP 8.6 — we have 26, 36 and 9.1.1. Transitive bumps are minor only: core 1.12.0, lifecycle 2.6.2, coroutines 1.9.0, tracing 1.2.0. Room and Kotlin stay where they are.— Slave
In plain words: minSdk is the oldest Android version the app accepts. WorkManager 2.12.0 needs Android 7 or newer, and Xopoz already requires Android 8, so no user loses the app. compileSdk and AGP are tools on the developer's side, already recent enough. The few other libraries that move with WorkManager change only minor versions, and the core of the app — the database (Room) and the language (Kotlin) — stays exactly the same.
# List every library that really ships in the release, before and after ./gradlew :app:dependencies --configuration releaseRuntimeClasspath # Rebuild the release and repeat the dex check ./gradlew assembleRelease dexdump -d classes.dex | grep -A12 "Landroidx/work/OverwritingInputMerger;" name : '<init>' type : '()V' ← the constructor is back
Armour for Next Time
Fixing the bug was half the job. The other half was more uncomfortable: the failure had been invisible for five days, and it was found only because a human noticed that an alert he expected did not arrive. On a user's phone there is no developer console. The only witness is the log file attached to a support ticket.
In the existing WorkManager, could we have added some logs to have an obvious hint for better analysis?— Master
Yes. WorkManager did report the failure, but the release build strips Android's system log. Let's stop stripping the error and warning levels, so library errors show up in logcat…— Slave
Master's call #4 — "Only the support log counts"
No logcat in release. I speak only about the log files sent on support tickets.— Master
Slave was optimizing for the developer's desk. Master blocked it, and the reasons are worth spelling out.
Here is the picture Master had in mind. On a user's phone, the app can write to two very different places:
BIBI'S PHONE (release build, in the field) ┌──────────────────────────────────────┐ │ Xopoz │ │ │ │ app log file (Timber) ────────────┼──► support ticket ──► Master reads it ✅ │ private to the app, │ (sent only when the user │ kept for days │ chooses to send a ticket) │ │ │ Android system log (logcat) ────────┼──► ANYONE who plugs a cable into ✗ │ shared by the whole phone, │ the phone and runs adb logcat: │ verbose output of the app │ a thief, a repair shop, a curious │ and all its libraries │ colleague, a forensic tool └──────────────────────────────────────┘ Slave's proposal: re-open logcat in release → hands the app's internals to anyone Master's decision: write into the app log file → reaches Master, and only Master
The rationale behind keeping logcat closed in release builds:
- Anyone could read it. This is the decisive reason. The system log is not private to the app: it is shared by the whole phone. Re-enabling it in release means that anyone who gets the phone in hand and plugs it into a computer can dump the verbose logs of Xopoz and of every library it uses — internal class names, network calls, server addresses, timings, error details, sometimes request data. For a privacy-first app that encrypts every position before it leaves the phone, that would open a side door next to a locked front door. Removing these log calls from release builds is a deliberate security choice, not an oversight.
- It would not even reach the developer. Logcat is read with a cable and developer tools. Xopoz users are families, field crews and NGOs, never plugged into the developer's computer. The only evidence that travels from their phones to the developer is the app's own log file, attached to a support ticket — and only when the user decides to send one.
- It does not last. Logcat is a small buffer shared by every app and system service on the phone. On a busy phone it is overwritten within minutes or hours, and it is wiped at every reboot. This bug stayed silent for five days; logcat would have kept none of it.
- The fix belongs where the evidence arrives. Instead of opening a channel that exposes everything to everyone, the diagnostics were added to the app's own private log file: the stuck-message watchdog and the library versions. Every future support ticket carries them automatically, and nobody else can read them.
Slave's proposal would have handed the app's internals to anyone holding the phone, to improve a channel that never reaches the developer anyway. Master blocked it in one sentence, and brought the question back to where the evidence really comes from.
My question is simple: could we add more logs in the code to improve WorkManager troubleshooting?— Master
Two options, both written into the app's own log file: check WorkManager's state each time a message is queued, or check our own queue every 15 minutes.— Slave
Master's call #5 — "Is the 15-minute check the safest?"
Are you sure the first one is possible? Between the two, maybe the 15-minute check is the safest?— Master
You are right Master. At the moment a message is queued, the previous attempt usually still looks "waiting", because it fails a few milliseconds later. And WorkManager deletes old finished jobs from its records, so yesterday's failure may already be gone. The 15-minute check reads only our own table, so it catches a stuck sender whatever the cause.— Slave
The architect's principle: a safety check must never depend on the very component it is supposed to watch. Asking WorkManager whether WorkManager is healthy is fragile. Counting our own unsent messages is not.
1. The pending-message watchdog
Every 15 minutes, the poll — the job that kept running throughout the whole incident — now also counts the outgoing messages that are still unsent. If the oldest one has been waiting for more than 15 minutes, the app writes a warning into its log, in plain language:
# what Bibi's phone would have written, every 15 minutes, from the first evening on
[WRN] [FLUSH-WATCHDOG] 4 unsent message(s), oldest 2d 04h (GEOFENCE_NOTIFY_LEAVE)
It says nothing when everything is fine, so it does not flood the logs. It does not depend on WorkManager's internals, so it would also catch a server refusal, an expired login or any future failure that leaves messages stuck. And it is protected so that an error inside the check can never break the poll itself. With this one line, the very first support ticket would have shown the problem immediately.
2. Library versions in every log
This incident came from the interaction between a build tool and a library — and it was found only by comparing two builds. The app's log already started with the versions of the app, the language and the database; it now also records the versions of the libraries most likely to be involved in a field problem. These values are read from the same central version list (the version catalog) that the build uses, so the log can never claim a version different from the one that actually shipped:
=== BUILD VERSIONS === App : 2026.09.28.01 (27) Kotlin : 2.2.21 Room : 2.8.5 WorkManager: 2.12.0 ← the background scheduler of this story Ktor : 2.3.4 ← all communication with the server Kotlinx Ser: 1.6.0 ← encoding of every message and position Osmdroid : 6.1.18 ← the map and its tiles AGP : 9.1.1 Gradle : 9.3.1 === END BUILD VERSIONS ===
Proof, Not Promises
A fix is only a claim until it is checked. Here is what was verified the same evening, and — just as important — what each check does and does not prove:
| Check | Result | What it proves |
|---|---|---|
| Dex of the new release build | OverwritingInputMerger.<init>()V present again |
The exact cause of the bug is gone from the code users will install |
| Automated test suite, with a live backend | 393 / 393 green | The upgrade broke nothing else in the app's logic |
| Tiri's phone, new debug build installed over its existing data | Sender ran 0.4 s after being scheduled | WorkManager 2.12.0 starts correctly on a real phone with existing data (debug builds never had the bug, so this is not yet the proof of the fix itself) |
| Watchdog on a phone with an empty queue | Executed, silent, no error | The new check runs safely and stays quiet when all is well |
| Startup log | All library versions written | Future support tickets will state them |
The same speed served the verification. Slave installed the new build over the existing app without losing a byte of its data, started it, and read the app's private log file straight from the phone — something only possible because Slave knows how Android lets a debug build expose its own files to the developer (run-as):
# install the new build over the existing app and its data, start it, read its log adb install -r -t xopoz-debug.apk ← replace the app, keep its data adb shell monkey -p com.tbr.xopoz -c android.intent.category.LAUNCHER 1 ← start it, as if tapped adb exec-out run-as com.tbr.xopoz cat files/<log-dir>/xopoz_2026-10-01.log | grep -E "FlushWorker|FLUSH-WATCHDOG" ← copy the app's private log to the PC, keep the key lines 21:26:58.946 Scheduled FlushWorker (KEEP policy) 21:26:59.302 [STARTUP] WorkManager → FlushPendingMessagesWorker.doWork() entry 21:26:59.319 FlushWorker: Completed message flush - Sent: 0, Failed: 0
What each command does: adb install -r replaces the installed app with the new build while keeping all its data — accounts, keys, queued messages — exactly like an update from the store (-t allows a developer test build). monkey, as in Chapter 3, simply opens the app as if its icon had been tapped. adb exec-out runs a command on the phone and streams its raw output straight back to the computer, byte for byte. Combined with run-as com.tbr.xopoz, which runs that command with the app's own identity, it reads a file from the app's private storage — a place no other app, and not even the phone's file browser, can open. The result lands on the workstation, where grep keeps only the lines that matter.
adb shell cmd jobscheduler run -f <package> <jobId>. Doing this while the app is being updated can make Android drop that job: during this test, the poll disappeared from Android's scheduler until the next app restart, when WorkManager registered it again. Also, since version 2.10, WorkManager files its jobs under its own name (androidx.work.systemjobscheduler), so searching Android's job list for the app's package name alone will not find them. The final proof belongs to the field: the corrected release build on Bibi's phone, where four alerts have been waiting since 29 September. When it starts, the watchdog should name them, and the sender should finally deliver them.
What We Keep From This
Master and the engine
Look back at the five moments where the hunt changed direction. Each time, Slave was running at full speed — and each time, it was Master who decided where to run.
| Where Slave was heading | Master's call | Where the hunt went instead |
|---|---|---|
| Five days of statistics and a date in the past | "The bug is today" | One failure, today, observable and testable: the 15:29 alert, queued and never sent |
| A missing constructor, without a cause | "Beware of reflection… AGP" | The release before and after the toolchain upgrade — and the root cause |
| A one-line local patch | "Search the web first" | Google's documented change and Google's own fix: an upgrade, not a patch |
| Re-opening logcat in release builds | "Only the support log counts" | No verbose logs exposed to anyone with a cable; diagnostics written into the private log that support tickets carry |
| Asking WorkManager about WorkManager | "Is the 15-minute check the safest?" | A watchdog independent of the component that failed |
An AI assistant like Claude Code is an extraordinary engine: it reads tens of megabytes of logs in minutes, runs every command, never tires, and is right about almost every individual fact. But speed is not direction. Left alone, it explores everything that is true — including everything that does not matter. Master brings what no log contains: the memory of the product, the knowledge of what changed last week, the judgement of what is worth fixing and how. In this hunt, the engine covered the ground; Master chose the ground.
Without Master: five ways this could have failed
Every one of Slave's proposals in this story was technically sound. That is precisely the danger: a fast engine that is right about the facts can still be wrong about the direction, and nothing in the logs will tell it so. Had Master simply approved each proposal instead of challenging it, here is what would have happened:
- The investigation would have explained the past instead of fixing the present. Slave was busy with five days of statistics and the last good delivery on 26 September. Without "the bug is today", the hunt could have spent its energy reconstructing what changed around that date, while the concrete failure in front of everyone — one alert, queued at 15:29:51 and never sent — waited for attention.
- The symptom would have been patched without knowing the cause. Slave had found the missing constructor, but not why it was missing. Without "beware of reflection… AGP", nobody would have compared the releases before and after the toolchain upgrade. The fix would have been a blind guess, and the audit of the other classes stripped by the new R8 — the two sleeping traps now documented — would never have been done.
- A local patch would have shipped instead of the vendor's fix. Slave's agile ticket already contained the one-line keep rule. Without "search the web first", Xopoz would have stayed on WorkManager 2.9.0 with a home-made rule duplicating Google's own correction — an unexplained line in the code, waiting to rot or to hide the next problem.
- A security hole would have been opened — for diagnostics that help nobody. Without "only the support log counts", the first improvement would have re-enabled logcat in release builds: the verbose logs of the app and its libraries readable by anyone who plugs a cable into a user's phone, while still never appearing in the logs that users actually send. The next silent failure would have been just as invisible — and the app less private.
- The safety net would have been blind to this very failure. Without "is the 15-minute check the safest?", the watchdog would have asked WorkManager about WorkManager's health — at a moment when the failing job still looked "waiting", and with old failures already deleted from its records. It would have stayed silent through exactly the incident it was built to catch.
Five decisive moments, five failures avoided — and none of them could have been avoided by more speed, more data or more commands. They needed what only Master has: knowledge of the product's history, of its users, of where the evidence comes from, and the instinct to ask "is this the right question?" before asking "is this the right answer?". An AI assistant multiplies the developer's reach. It does not replace the developer's judgement. The more powerful the engine, the more it needs a Master at the helm.
Lessons for every Android team
- A green build is not a working build. The compiler was happy, the automated tests were happy, and nothing crashed. The tests ran against unoptimized code; the bug lived only in the optimized release. Some failures can only be seen in the exact build that users install.
- Reflection is where shrinkers bite — and this bite came from inside the vendor's own stack. Xopoz deliberately avoids reflection in its own code, precisely because shrinkers cannot see it. Not one line of our code asked for that constructor. It was WorkManager, Google's own background scheduler, creating its own
InputMergerfrom a class name deep inside its internals, protected only by the keep rule that the library itself ships. Then Google's own build toolchain changed what that rule means. Vendor library, vendor rule, vendor toolchain, vendor default: this was 100% the vendor's responsibility. And the bite was as hard as it gets — no build error, no warning, no crash, no log line in release, only messages that never left the phone. An app developer cannot audit the private reflection of every dependency; the minimum defence is to compare what R8 removed (itsusage.txtreport) between the old and the new release after any toolchain upgrade — especially constructors of classes that are otherwise kept. - Counter-check before patching. The first fix on the table was a local keep rule. A quick look at the vendor's release notes and at the library's own rules showed that it was already fixed upstream. The upgrade replaced our patch with Google's rule — less code of ours, and a fix that stays correct as the tools evolve.
- Log what support can see. On a user's phone, only the app's log file reaches the developer. Make it state the versions that matter, and make it shout when messages pile up instead of waiting for a human to notice a missing alert.
P.S. The house got cleaned that evening. And from now on, when Bibi's alerts get stuck, the log will say so within 15 minutes — so next time, Tiri will not be able to blame the app.