← Back to Marketing

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

In one sentence: For several days, geofence alerts were correctly detected on the phone, put in the outgoing queue — and never left it. There was no crash, no error message, the build was green, the automated tests were green, and positions kept appearing on the map, so the app looked perfectly healthy. This is the story of the hunt, told the way it happened: a solo developer (Master) and Claude Code (Slave), starting from two support tickets and knowing nothing more than "the alert did not come".

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.

Before We Start

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.

Xopoz
An Android app for teams that need to know where each other's devices are — families, field crews, NGOs, search-and-rescue groups. Every GPS position is encrypted on the phone with a team key before it leaves, so the server stores data it cannot read. Each device is an independent member of its team, with geofencing, SOS alerts and offline maps built in.
Geofencing
A virtual fence drawn on the map: a centre point and a radius. One team member (the requester) asks another device (the monitored device) to watch a zone — in this story, Tiri asks Bibi's phone to watch her workplace. The monitored device checks its own GPS position and, when it really enters or leaves the zone, sends an encrypted alert back to the requester. In Xopoz this alert is a message like any other: first saved in an outgoing queue on the phone, then delivered to the server by a background sender.
Support ticket
A bug report sent by a user from inside the app. It automatically attaches the app's own log files for the last days — a diary the app writes on the phone, line by line. For a release build, this diary is the only window a developer has into what happened on a user's phone. A support ticket is evidence, never an order: it is analysed and answered, but it never changes a line of code by itself.
Agile ticket
A work item for the development itself: a short file describing the problem, its root cause and a step-by-step plan of the code changes. When the analysis of a support ticket finds a real defect, it produces an agile ticket. Nothing is changed in the code until Master has reviewed that plan — he can approve it, amend it or reject it. Only then is the ticket executed, built, tested and committed.
Debug build and release build
Two flavours of the same app. A debug build is what the developer runs while coding: unoptimized, easy to inspect. A release build is what users install from the store: optimized, shrunk and harder to inspect. Most of the time both behave the same. This story is about a time they did not.
AGP (Android Gradle Plugin)
The official build toolchain from Google that turns Android source code into an installable app. Among other things, it decides which version of the shrinker (R8, below) is used and with which default rules. Google Play regularly asks publishers to move to recent AGP versions, so that apps benefit from the latest optimizations and security fixes.
R8
The shrinker and optimizer that AGP runs on release builds only. It removes classes, methods and constructors it believes are unused, and renames the rest, to make the app smaller and faster. It decides what is "used" by following direct calls in the code.
Reflection
Creating an object or calling a method from a name stored as text — for example, a class name read from a database — instead of a direct call written in the code. Because there is no direct call, a shrinker cannot see these uses: for R8, code reached only by reflection looks unused.
Keep rules (ProGuard rules)
Written instructions telling R8 "do not remove or rename this". They are the only protection for code reached by reflection. Libraries ship their own keep rules inside their package (the consumer rules), so that apps using them stay intact after shrinking without having to know the library's internals.
Constructor
The piece of code that creates a new object of a class. A class without its constructor still exists, but nothing can be created from it.
WorkManager
Google's Android library for background work that must happen even if the app is closed or the phone restarts. Work is either periodic (repeated, for example every 15 minutes) or one-time (run once, as soon as possible). In Xopoz, a periodic job — the poll — fetches incoming messages every 15 minutes, and a one-time job — the FlushWorker — sends the outgoing queue each time something is added to it.
Dex
The compiled code inside an Android app, after R8 has done its work. Reading the dex of a release build shows exactly what survived — the ground truth when the source code and the running app disagree.
adb
Android Debug Bridge, the command-line tool that lets a developer talk to a phone plugged into the computer: install an app, read its files, or ask Android what it is doing.
Claude Code
Anthropic's AI coding assistant, working directly in the terminal of the development machine: it reads logs and source code, runs build and phone commands, and proposes changes that the developer approves or rejects.
Chapter 1

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
Chapter 2

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.

Chapter 3

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.

Chapter 4

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.

Flashback — the call from Google. At the end of September, the Google Play Console had shown two friendly warnings for Xopoz: "Upgrade your Android Gradle plugin to version 9.0 or higher" and "Optimized resource shrinking isn't enabled". Google sends such recommendations so that apps benefit from the latest optimizations, and a responsible publisher answers that call. So Xopoz moved to AGP 9.1.1 on Gradle 9.3.1, with R8 in full mode and optimized resource shrinking, fixed the one image that needed it, ran the build (green) and the test suite (green), and shipped. Nothing about it looked related to geofencing.

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.

Chapter 5

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 own proguard-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.

Chapter 6

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
Chapter 7

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:

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 ===
Chapter 8

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.

A trap worth knowing. Developers sometimes force a background job to run immediately with 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.

Epilogue

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

For Xopoz users: no location data was exposed or lost at any point, and positions kept being shared, end-to-end encrypted, throughout. What stalled, on devices running version 2026.09.28.01, were outgoing messages: geofence alerts, SOS alerts and chat messages. They were not lost — they stayed safely in the queue on the phone, and the corrected version sends them on its first start. If you rely on SOS or geofence alerts, please install the corrected version as soon as it is available.

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.