The on-device perf harness (scripts/device-testing/, #370; portability fixes#392) cannot force a genuine
uncached body fetch. Proactive fetch — full-history backfill (MailBackfiller / BackfillWorker, #12) and
post-header-sync body prefetch (MailSyncer.prefetchIfEnabled / MailBackfiller.prefetchIfEnabled, #88/#89) —
pulls bodies into Room before a test can open a message, so openMessage finds the body already cached and
does a local read instead of an IMAP fetch. This blocked:
the Gmail body-fetch-throttle repro (message-open / cross-provider scenarios), and
the connection-reuse ON-vs-OFF A/B the reuse rollout still owes (#368 / #357 Part 2).
The harness needs to pause proactive fetch — add an account, let headers sync but not prefetch bodies or
backfill — then trigger a real uncached open and measure it. Today the closest lever is the prefetch-ab
scenario toggling the persisted fetch-policy setting, which (a) doesn't stop backfill's header paging or the
prefetch already in flight, and (b) navigates the settings UI by on-screen text (fragile). We need a
deterministic, debug-only, adb-reachable pause hook.
This is a PROPOSAL / design ticket — no product code is written here.
Fetch architecture: the distinct activities and their choke points
Both prefetch paths funnel through one pure decision:SyncResourcePolicy.shouldPrefetchContent(policy, unmetered, battery). The recent-window prefetch (MailSyncer) and the backfill prefetch (MailBackfiller)
both consult it, so proactive body prefetch has a single logical gate. (Header sync is deliberately never
gated here — see its kdoc: "a paused prefetch merely defers content caching to the next healthy sync, or to
on-demand fetch when a message is opened." Exactly the behaviour the harness wants.)
Backfill header paging is gated at the worker/scheduler layer, not by shouldPrefetchContent. It runs in backfillFolder's while loop under WorkManager's battery-not-low constraint. The natural single choke
point is BackfillWorker.doWork() entry — which already has the exact skip-and-reschedule pattern we want
(if (cacheGuard.isCacheLocked()) { AppLog.i(...); return Result.retry() }).
All fetch runs in the main process (only :coldopen and :restart are separate processes; workers, the
syncer, and IdleService share the default process), so an in-memory gate is visible everywhere it's read.
Options considered
#
Mechanism
Verdict
1
Debug BroadcastReceiver via adb shell am broadcast
RECOMMENDED. The harness already uses am constrained to the target package (adb.py allow-list). Ordered-broadcast result-data gives synchronous read-back; receiver lives in src/debug (physically absent from release). Best fit.
2
Debug DataStore/settings flag
Rejected. Persists across runs (state leak between harness runs); the harness's own safety rules forbid touching datastore/, so it would still need a receiver/UI to flip it — no win over #1, plus unwanted persistence.
3
Debug ContentProvider via adb shell content call
Runner-up. Mirrors the existing ColdOpenCacheProbe (#221) precedent, but content call has poor Bundle read-back ergonomics across Android versions and is awkward for a simple toggle. Keep as fallback.
4
Instrumentation arg / intent extra
Rejected. The harness is an external adb + uiautomator driver, not an instrumented test — no instrumentation to pass args to.
5
Watched file / system prop
Rejected. Reading a file/prop on the fetch hot path is untestable and hacky; harness safety rules forbid touching app dirs.
Recommended approach
A debug-only BroadcastReceiver (src/debug) that toggles an in-memory DebugFetchGate holder, which
main-source fetch entry points consult behind a BuildConfig.DEBUG guard.
Harness hook (adb)
# Pause proactive fetch (the driving use case): backfill + prefetch off, header-sync + open stay live.
adb shell am broadcast \
-a org.libremail.debug.FETCH_GATE \
-n org.libremail.app/org.libremail.debug.FetchGateReceiver \
--es action pause --es scope backfill,prefetch
# -> "Broadcast completed: result=0, data=paused=[backfill,prefetch]"
# Resume everything after the measurement.
adb shell am broadcast -a org.libremail.debug.FETCH_GATE \
-n org.libremail.app/org.libremail.debug.FetchGateReceiver --es action resume --es scope all
# -> "Broadcast completed: result=0, data=paused=[]"
# Query current state without changing it (idempotent read-back).
adb shell am broadcast -a org.libremail.debug.FETCH_GATE \
-n org.libremail.app/org.libremail.debug.FetchGateReceiver --es action query
# -> "Broadcast completed: result=0, data=paused=[backfill,prefetch]"
The receiver is ordered; it calls setResultCode(0) + setResultData("paused=[...]"), which am broadcast
prints, so the harness gets synchronous confirmation that the gate took effect (no logcat race).
Component is named with -n and exported="true" in the debug manifest (adb shell UID must reach it).
Optional hardening: a signature-level android:permission. It's debug-only regardless, so it can never ship.
scope accepts a comma list of backfill, prefetch (add header-sync, idle, attachment if wanted) and
the alias all. Default harness usage pauses backfill,prefetch.
Granularity
Per-activity scopes, minimally BACKFILL and PREFETCH (an enum FetchScope, plus an all alias). The
driving use case pauses both and leaves HEADER_SYNC + on-demand OPEN live — so: add account -> SyncWorker
runs fetchRecent (INBOX headers land) -> prefetch gated (no bodies cached) -> BackfillWorker gated (no history)
-> open a message -> openMessage sees !routing.bodyFetched -> fetchBodyMarkingSeen does a genuine uncached
fetch -> measured. Global "halt all" is just scope=all.
Enforcement points + semantics
BackfillWorker.doWork() entry (primary backfill gate): if (BuildConfig.DEBUG && DebugFetchGate.isPaused(FetchScope.BACKFILL)) { AppLog.i(TAG, "backfill deferred: fetch-gate paused"); return Result.retry() } — skip-and-reschedule, byte-for-byte the existing cache-lock
deferral. Covers periodic + backfillNow() (both route through the worker). WorkManager retries later, so on
resume backfill continues from its persisted per-folder boundary. (Alternative/finer: gate inside MailBackfiller.runBackfill(); the worker entry is simpler and sufficient.)
MailSyncer.prefetchIfEnabled and MailBackfiller.prefetchIfEnabled (prefetch gate): early-return when PREFETCH is paused — no-op-until-resumed, which is already the documented contract (a skipped prefetch is
retried on the next healthy sync / filled in lazily on open). fetchRecent above is untouched, so headers keep
flowing. This also neutralises the folder-open-triggered prefetch (syncFolder -> prefetchIfEnabled), so
merely navigating to the inbox won't warm the body cache.
Deliberately NOT gated:openMessage / fetchBodyMarkingSeen / on-demand fetchAttachment -> uncached
open stays live. Also not gating at ImapClient.withStore op-dispatch: op-label gating would duplicate the
higher-level gates and risk pausing an op we want live — keep the gate where intent is unambiguous.
Writer absent from release.FetchGateReceiver and its <receiver> manifest entry live entirely in app/src/debug/ — the same source-set guarantee the repo already relies on for ColdOpenCacheProbe (#221).
Never compiled into or merged into a release APK.
Reader stripped from release. The DebugFetchGate holder lives in src/main (so main workers/syncer can
reference it), but every read is guarded by if (BuildConfig.DEBUG && ...). BuildConfig.DEBUG is a
compile-time constant false in release, so R8 dead-code-eliminates the branch, leaving DebugFetchGate
unreferenced -> removed from the release APK. In release the gate is not merely inert, it's gone.
Verify release is unaffected:
./gradlew :app:assembleRelease then apkanalyzer dex packages (or dexdump) shows noDebugFetchGate / FetchGateReceiver class, and the merged release manifest has no FETCH_GATE receiver.
Unit test: DebugFetchGate defaults to not-paused for every scope.
Manual: am broadcast ... FETCH_GATE against a release build is a no-op (component absent) and fetch proceeds.
Observability (PII-free AppLog)
On toggle: AppLog.i("DebugFetchGate", "fetch gate: paused=[backfill,prefetch]") — scope names only, never PII.
Harness confirmation (three independent signals): (a) the am broadcastresult-data read-back above;
(b) a query action; (c) the logcat breadcrumbs, which breadcrumbs.py already tails. The absence of ImapPerfprefetch-body / backfill-page ops while paused is itself corroborating evidence.
Testing
JVM unit (branch is live because BuildConfig.DEBUG is true under testDebugUnitTest):
DebugFetchGateTest — pause/resume/query/all per scope; default not-paused; thread-safe holder.
BackfillWorkerTest — with BACKFILL paused, doWork() returns retry() and MailBackfiller is never
invoked; unpaused path unchanged.
MailSyncerTest / MailBackfillerTest — with PREFETCH paused, fetchRecent / fetchOlderThan still run
but prefetchMessage is not called; unpaused path unchanged.
FetchGateReceiver action/scope parsing (backfill,prefetch / all / unknown -> result-data).
Instrumented/E2E (DoD) — debug-only test: send the ordered broadcast, assert result-data and DebugFetchGate state; assert a paused-backfill worker defers. Must run and pass on the cold-boot emulator +
API 37 preflight.
End-to-end harness flow: install debug -> am broadcast ... pause backfill,prefetch (confirm read-back) ->
add Gmail account (headers sync, no prefetch-body/backfill-page ops in logcat) -> open a message
(MailReader openMessage ... fetchedBody=true + one real body-fetchImapPerf op) -> measure uncached open
-> resume all -> repeat for reuse ON vs OFF builds (#368 A/B). Supersedes the fragile settings-nav in prefetch-ab.
Scope / notes
Product code stays Kotlin; the harness-side change (adding am broadcast FETCH_GATE to adb.py's sanctioned am calls and wiring the prefetch-ab / message-open scenarios to it) is a follow-up in scripts/device-testing/.
References: #370 (harness), #392 (harness portability), #368 / #357 Part 2 (the reuse ON/OFF A/B that
needs a cold body), #221 (ColdOpenCacheProbe — the debug-source-set precedent this mirrors), #358
(MailReader / ImapPerf breadcrumbs used for readiness + measurement).
## Motivation
The on-device perf harness (`scripts/device-testing/`, #370; portability fixes #392) **cannot force a genuine
uncached body fetch**. Proactive fetch — full-history backfill (`MailBackfiller` / `BackfillWorker`, #12) and
post-header-sync body prefetch (`MailSyncer.prefetchIfEnabled` / `MailBackfiller.prefetchIfEnabled`, #88/#89) —
pulls bodies into Room *before* a test can open a message, so `openMessage` finds the body already cached and
does a local read instead of an IMAP fetch. This blocked:
- the Gmail body-fetch-throttle repro (`message-open` / `cross-provider` scenarios), and
- the **connection-reuse ON-vs-OFF A/B** the reuse rollout still owes (#368 / #357 Part 2).
The harness needs to **pause proactive fetch** — add an account, let headers sync but **not** prefetch bodies or
backfill — then trigger a real uncached open and measure it. Today the closest lever is the `prefetch-ab`
scenario toggling the persisted fetch-policy setting, which (a) doesn't stop backfill's header paging or the
prefetch already in flight, and (b) navigates the settings UI by on-screen text (fragile). We need a
deterministic, debug-only, adb-reachable pause hook.
This is a **PROPOSAL / design ticket** — no product code is written here.
---
## Fetch architecture: the distinct activities and their choke points
| Activity | Path | Triggered by | Test wants |
|---|---|---|---|
| **Header sync** (recent window) | `MailSyncer.syncFolderHeaders` -> `ImapClient.fetchRecent` (op `imap`) | periodic `SyncWorker` (15m), pull-to-refresh, `syncNow()`, folder open, IDLE push (`syncAccount`) | **LIVE** (headers must arrive) |
| **Body prefetch** (proactive) | `MailSyncer.prefetchIfEnabled` + `MailBackfiller.prefetchIfEnabled` -> `MailRepository.prefetchMessage` -> `ImapClient.fetchBodyPeek` (op `prefetch-body`) + `fetchAttachment` | after every header sync **and** after every backfill page | **PAUSE** |
| **Backfill** (history paging) | `MailBackfiller.backfillFolder` -> `ImapClient.fetchOlderThan` (op `backfill-page`) | periodic `BackfillWorker` (30m), `backfillNow()` | **PAUSE** |
| **On-demand open** | `MailRepositoryImpl.openMessage` -> `ImapClient.fetchBodyMarkingSeen` (op `body-fetch`) | user opens an uncached message | **LIVE** (the thing we measure) |
| **On-demand attachment** | `ensureAttachmentFile` -> `ImapClient.fetchAttachment` (op `attachment`) | user opens attachment / inline image | LIVE |
| **IDLE push** | `ImapClient.idle` / `IdleService` | foreground service | independent (leave alone) |
| Prune / Send | `PruneWorker` / `SendWorker` | periodic / outbox | not a fetch — irrelevant |
Two facts make this cheap to gate:
1. **Both prefetch paths funnel through one pure decision:** `SyncResourcePolicy.shouldPrefetchContent(policy,
unmetered, battery)`. The recent-window prefetch (`MailSyncer`) and the backfill prefetch (`MailBackfiller`)
both consult it, so proactive body prefetch has a single logical gate. (Header sync is deliberately never
gated here — see its kdoc: "a paused prefetch merely defers content caching to the next healthy sync, or to
on-demand fetch when a message is opened." Exactly the behaviour the harness wants.)
2. **Backfill header paging is gated at the worker/scheduler layer, not by `shouldPrefetchContent`.** It runs in
`backfillFolder`'s `while` loop under WorkManager's `battery-not-low` constraint. The natural single choke
point is `BackfillWorker.doWork()` entry — which already has the exact skip-and-reschedule pattern we want
(`if (cacheGuard.isCacheLocked()) { AppLog.i(...); return Result.retry() }`).
All fetch runs in the **main process** (only `:coldopen` and `:restart` are separate processes; workers, the
syncer, and `IdleService` share the default process), so an in-memory gate is visible everywhere it's read.
---
## Options considered
| # | Mechanism | Verdict |
|---|---|---|
| **1** | **Debug `BroadcastReceiver` via `adb shell am broadcast`** | **RECOMMENDED.** The harness already uses `am` constrained to the target package (`adb.py` allow-list). Ordered-broadcast result-data gives synchronous read-back; receiver lives in `src/debug` (physically absent from release). Best fit. |
| 2 | Debug DataStore/settings flag | Rejected. Persists across runs (state leak between harness runs); the harness's own safety rules **forbid touching `datastore/`**, so it would still need a receiver/UI to flip it — no win over #1, plus unwanted persistence. |
| 3 | Debug `ContentProvider` via `adb shell content call` | Runner-up. Mirrors the existing `ColdOpenCacheProbe` (#221) precedent, but `content call` has poor Bundle read-back ergonomics across Android versions and is awkward for a simple toggle. Keep as fallback. |
| 4 | Instrumentation arg / intent extra | Rejected. The harness is an **external** adb + uiautomator driver, not an instrumented test — no instrumentation to pass args to. |
| 5 | Watched file / system prop | Rejected. Reading a file/prop on the fetch hot path is untestable and hacky; harness safety rules forbid touching app dirs. |
---
## Recommended approach
A **debug-only `BroadcastReceiver`** (`src/debug`) that toggles an **in-memory `DebugFetchGate`** holder, which
main-source fetch entry points consult behind a `BuildConfig.DEBUG` guard.
### Harness hook (adb)
```
# Pause proactive fetch (the driving use case): backfill + prefetch off, header-sync + open stay live.
adb shell am broadcast \
-a org.libremail.debug.FETCH_GATE \
-n org.libremail.app/org.libremail.debug.FetchGateReceiver \
--es action pause --es scope backfill,prefetch
# -> "Broadcast completed: result=0, data=paused=[backfill,prefetch]"
# Resume everything after the measurement.
adb shell am broadcast -a org.libremail.debug.FETCH_GATE \
-n org.libremail.app/org.libremail.debug.FetchGateReceiver --es action resume --es scope all
# -> "Broadcast completed: result=0, data=paused=[]"
# Query current state without changing it (idempotent read-back).
adb shell am broadcast -a org.libremail.debug.FETCH_GATE \
-n org.libremail.app/org.libremail.debug.FetchGateReceiver --es action query
# -> "Broadcast completed: result=0, data=paused=[backfill,prefetch]"
```
- The receiver is **ordered**; it calls `setResultCode(0)` + `setResultData("paused=[...]")`, which `am broadcast`
prints, so the harness gets **synchronous confirmation** that the gate took effect (no logcat race).
- Component is named with `-n` and `exported="true"` in the debug manifest (adb `shell` UID must reach it).
Optional hardening: a signature-level `android:permission`. It's debug-only regardless, so it can never ship.
- `scope` accepts a comma list of `backfill`, `prefetch` (add `header-sync`, `idle`, `attachment` if wanted) and
the alias `all`. Default harness usage pauses `backfill,prefetch`.
### Granularity
Per-activity scopes, minimally **`BACKFILL`** and **`PREFETCH`** (an enum `FetchScope`, plus an `all` alias). The
driving use case pauses both and leaves `HEADER_SYNC` + on-demand `OPEN` live — so: add account -> `SyncWorker`
runs `fetchRecent` (INBOX headers land) -> prefetch gated (no bodies cached) -> `BackfillWorker` gated (no history)
-> open a message -> `openMessage` sees `!routing.bodyFetched` -> `fetchBodyMarkingSeen` does a **genuine uncached
fetch** -> measured. Global "halt all" is just `scope=all`.
### Enforcement points + semantics
- **`BackfillWorker.doWork()` entry** (primary backfill gate):
`if (BuildConfig.DEBUG && DebugFetchGate.isPaused(FetchScope.BACKFILL)) { AppLog.i(TAG, "backfill deferred:
fetch-gate paused"); return Result.retry() }` — **skip-and-reschedule**, byte-for-byte the existing cache-lock
deferral. Covers periodic + `backfillNow()` (both route through the worker). WorkManager retries later, so on
resume backfill continues from its persisted per-folder boundary. (Alternative/finer: gate inside
`MailBackfiller.runBackfill()`; the worker entry is simpler and sufficient.)
- **`MailSyncer.prefetchIfEnabled` and `MailBackfiller.prefetchIfEnabled`** (prefetch gate): early-return when
`PREFETCH` is paused — **no-op-until-resumed**, which is already the documented contract (a skipped prefetch is
retried on the next healthy sync / filled in lazily on open). `fetchRecent` above is untouched, so headers keep
flowing. This also neutralises the folder-open-triggered prefetch (`syncFolder` -> `prefetchIfEnabled`), so
merely navigating to the inbox won't warm the body cache.
- **Deliberately NOT gated:** `openMessage` / `fetchBodyMarkingSeen` / on-demand `fetchAttachment` -> uncached
open stays live. Also not gating at `ImapClient.withStore` op-dispatch: op-label gating would duplicate the
higher-level gates and risk pausing an op we want live — keep the gate where intent is unambiguous.
### Debug-only gating (hard requirement) + release verification
Belt **and** suspenders:
1. **Writer absent from release.** `FetchGateReceiver` and its `<receiver>` manifest entry live entirely in
`app/src/debug/` — the same source-set guarantee the repo already relies on for `ColdOpenCacheProbe` (#221).
Never compiled into or merged into a release APK.
2. **Reader stripped from release.** The `DebugFetchGate` holder lives in `src/main` (so main workers/syncer can
reference it), but every read is guarded by `if (BuildConfig.DEBUG && ...)`. `BuildConfig.DEBUG` is a
compile-time constant `false` in release, so R8 dead-code-eliminates the branch, leaving `DebugFetchGate`
unreferenced -> removed from the release APK. In release the gate is not merely inert, it's **gone**.
**Verify release is unaffected:**
- `./gradlew :app:assembleRelease` then `apkanalyzer dex packages` (or `dexdump`) shows **no** `DebugFetchGate` /
`FetchGateReceiver` class, and the merged release manifest has no `FETCH_GATE` receiver.
- Unit test: `DebugFetchGate` defaults to not-paused for every scope.
- Manual: `am broadcast ... FETCH_GATE` against a release build is a no-op (component absent) and fetch proceeds.
### Observability (PII-free `AppLog`)
- On toggle: `AppLog.i("DebugFetchGate", "fetch gate: paused=[backfill,prefetch]")` — scope names only, never PII.
- On enforcement: `"backfill deferred: fetch-gate paused"` (mirrors "backfill deferred: cache locked");
`MailSyncer`/`MailBackfiller` log `"prefetch skipped: fetch-gate paused"`.
- Harness confirmation (three independent signals): (a) the `am broadcast` **result-data** read-back above;
(b) a `query` action; (c) the logcat breadcrumbs, which `breadcrumbs.py` already tails. The absence of
`ImapPerf` `prefetch-body` / `backfill-page` ops while paused is itself corroborating evidence.
### Testing
- **JVM unit** (branch is live because `BuildConfig.DEBUG` is true under `testDebugUnitTest`):
- `DebugFetchGateTest` — pause/resume/query/`all` per scope; default not-paused; thread-safe holder.
- `BackfillWorkerTest` — with `BACKFILL` paused, `doWork()` returns `retry()` and `MailBackfiller` is never
invoked; unpaused path unchanged.
- `MailSyncerTest` / `MailBackfillerTest` — with `PREFETCH` paused, `fetchRecent` / `fetchOlderThan` still run
but `prefetchMessage` is not called; unpaused path unchanged.
- `FetchGateReceiver` action/scope parsing (`backfill,prefetch` / `all` / unknown -> result-data).
- **Instrumented/E2E (DoD)** — debug-only test: send the ordered broadcast, assert result-data and
`DebugFetchGate` state; assert a paused-backfill worker defers. Must run and pass on the cold-boot emulator +
API 37 preflight.
- **End-to-end harness flow:** install debug -> `am broadcast ... pause backfill,prefetch` (confirm read-back) ->
add Gmail account (headers sync, no `prefetch-body`/`backfill-page` ops in logcat) -> open a message
(`MailReader openMessage ... fetchedBody=true` + one real `body-fetch` `ImapPerf` op) -> measure uncached open
-> `resume all` -> repeat for reuse ON vs OFF builds (#368 A/B). Supersedes the fragile settings-nav in
`prefetch-ab`.
---
## Scope / notes
- Product code stays Kotlin; the harness-side change (adding `am broadcast FETCH_GATE` to `adb.py`'s sanctioned
`am` calls and wiring the `prefetch-ab` / `message-open` scenarios to it) is a follow-up in `scripts/device-testing/`.
- References: **#370** (harness), **#392** (harness portability), **#368 / #357 Part 2** (the reuse ON/OFF A/B that
needs a cold body), **#221** (`ColdOpenCacheProbe` — the debug-source-set precedent this mirrors), **#358**
(`MailReader` / `ImapPerf` breadcrumbs used for readiness + measurement).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Motivation
The on-device perf harness (
scripts/device-testing/, #370; portability fixes #392) cannot force a genuineuncached body fetch. Proactive fetch — full-history backfill (
MailBackfiller/BackfillWorker, #12) andpost-header-sync body prefetch (
MailSyncer.prefetchIfEnabled/MailBackfiller.prefetchIfEnabled, #88/#89) —pulls bodies into Room before a test can open a message, so
openMessagefinds the body already cached anddoes a local read instead of an IMAP fetch. This blocked:
message-open/cross-providerscenarios), andThe harness needs to pause proactive fetch — add an account, let headers sync but not prefetch bodies or
backfill — then trigger a real uncached open and measure it. Today the closest lever is the
prefetch-abscenario toggling the persisted fetch-policy setting, which (a) doesn't stop backfill's header paging or the
prefetch already in flight, and (b) navigates the settings UI by on-screen text (fragile). We need a
deterministic, debug-only, adb-reachable pause hook.
This is a PROPOSAL / design ticket — no product code is written here.
Fetch architecture: the distinct activities and their choke points
MailSyncer.syncFolderHeaders->ImapClient.fetchRecent(opimap)SyncWorker(15m), pull-to-refresh,syncNow(), folder open, IDLE push (syncAccount)MailSyncer.prefetchIfEnabled+MailBackfiller.prefetchIfEnabled->MailRepository.prefetchMessage->ImapClient.fetchBodyPeek(opprefetch-body) +fetchAttachmentMailBackfiller.backfillFolder->ImapClient.fetchOlderThan(opbackfill-page)BackfillWorker(30m),backfillNow()MailRepositoryImpl.openMessage->ImapClient.fetchBodyMarkingSeen(opbody-fetch)ensureAttachmentFile->ImapClient.fetchAttachment(opattachment)ImapClient.idle/IdleServicePruneWorker/SendWorkerTwo facts make this cheap to gate:
SyncResourcePolicy.shouldPrefetchContent(policy, unmetered, battery). The recent-window prefetch (MailSyncer) and the backfill prefetch (MailBackfiller)both consult it, so proactive body prefetch has a single logical gate. (Header sync is deliberately never
gated here — see its kdoc: "a paused prefetch merely defers content caching to the next healthy sync, or to
on-demand fetch when a message is opened." Exactly the behaviour the harness wants.)
shouldPrefetchContent. It runs inbackfillFolder'swhileloop under WorkManager'sbattery-not-lowconstraint. The natural single chokepoint is
BackfillWorker.doWork()entry — which already has the exact skip-and-reschedule pattern we want(
if (cacheGuard.isCacheLocked()) { AppLog.i(...); return Result.retry() }).All fetch runs in the main process (only
:coldopenand:restartare separate processes; workers, thesyncer, and
IdleServiceshare the default process), so an in-memory gate is visible everywhere it's read.Options considered
BroadcastReceiverviaadb shell am broadcastamconstrained to the target package (adb.pyallow-list). Ordered-broadcast result-data gives synchronous read-back; receiver lives insrc/debug(physically absent from release). Best fit.datastore/, so it would still need a receiver/UI to flip it — no win over #1, plus unwanted persistence.ContentProviderviaadb shell content callColdOpenCacheProbe(#221) precedent, butcontent callhas poor Bundle read-back ergonomics across Android versions and is awkward for a simple toggle. Keep as fallback.Recommended approach
A debug-only
BroadcastReceiver(src/debug) that toggles an in-memoryDebugFetchGateholder, whichmain-source fetch entry points consult behind a
BuildConfig.DEBUGguard.Harness hook (adb)
setResultCode(0)+setResultData("paused=[...]"), whicham broadcastprints, so the harness gets synchronous confirmation that the gate took effect (no logcat race).
-nandexported="true"in the debug manifest (adbshellUID must reach it).Optional hardening: a signature-level
android:permission. It's debug-only regardless, so it can never ship.scopeaccepts a comma list ofbackfill,prefetch(addheader-sync,idle,attachmentif wanted) andthe alias
all. Default harness usage pausesbackfill,prefetch.Granularity
Per-activity scopes, minimally
BACKFILLandPREFETCH(an enumFetchScope, plus anallalias). Thedriving use case pauses both and leaves
HEADER_SYNC+ on-demandOPENlive — so: add account ->SyncWorkerruns
fetchRecent(INBOX headers land) -> prefetch gated (no bodies cached) ->BackfillWorkergated (no history)-> open a message ->
openMessagesees!routing.bodyFetched->fetchBodyMarkingSeendoes a genuine uncachedfetch -> measured. Global "halt all" is just
scope=all.Enforcement points + semantics
BackfillWorker.doWork()entry (primary backfill gate):if (BuildConfig.DEBUG && DebugFetchGate.isPaused(FetchScope.BACKFILL)) { AppLog.i(TAG, "backfill deferred: fetch-gate paused"); return Result.retry() }— skip-and-reschedule, byte-for-byte the existing cache-lockdeferral. Covers periodic +
backfillNow()(both route through the worker). WorkManager retries later, so onresume backfill continues from its persisted per-folder boundary. (Alternative/finer: gate inside
MailBackfiller.runBackfill(); the worker entry is simpler and sufficient.)MailSyncer.prefetchIfEnabledandMailBackfiller.prefetchIfEnabled(prefetch gate): early-return whenPREFETCHis paused — no-op-until-resumed, which is already the documented contract (a skipped prefetch isretried on the next healthy sync / filled in lazily on open).
fetchRecentabove is untouched, so headers keepflowing. This also neutralises the folder-open-triggered prefetch (
syncFolder->prefetchIfEnabled), somerely navigating to the inbox won't warm the body cache.
openMessage/fetchBodyMarkingSeen/ on-demandfetchAttachment-> uncachedopen stays live. Also not gating at
ImapClient.withStoreop-dispatch: op-label gating would duplicate thehigher-level gates and risk pausing an op we want live — keep the gate where intent is unambiguous.
Debug-only gating (hard requirement) + release verification
Belt and suspenders:
FetchGateReceiverand its<receiver>manifest entry live entirely inapp/src/debug/— the same source-set guarantee the repo already relies on forColdOpenCacheProbe(#221).Never compiled into or merged into a release APK.
DebugFetchGateholder lives insrc/main(so main workers/syncer canreference it), but every read is guarded by
if (BuildConfig.DEBUG && ...).BuildConfig.DEBUGis acompile-time constant
falsein release, so R8 dead-code-eliminates the branch, leavingDebugFetchGateunreferenced -> removed from the release APK. In release the gate is not merely inert, it's gone.
Verify release is unaffected:
./gradlew :app:assembleReleasethenapkanalyzer dex packages(ordexdump) shows noDebugFetchGate/FetchGateReceiverclass, and the merged release manifest has noFETCH_GATEreceiver.DebugFetchGatedefaults to not-paused for every scope.am broadcast ... FETCH_GATEagainst a release build is a no-op (component absent) and fetch proceeds.Observability (PII-free
AppLog)AppLog.i("DebugFetchGate", "fetch gate: paused=[backfill,prefetch]")— scope names only, never PII."backfill deferred: fetch-gate paused"(mirrors "backfill deferred: cache locked");MailSyncer/MailBackfillerlog"prefetch skipped: fetch-gate paused".am broadcastresult-data read-back above;(b) a
queryaction; (c) the logcat breadcrumbs, whichbreadcrumbs.pyalready tails. The absence ofImapPerfprefetch-body/backfill-pageops while paused is itself corroborating evidence.Testing
BuildConfig.DEBUGis true undertestDebugUnitTest):DebugFetchGateTest— pause/resume/query/allper scope; default not-paused; thread-safe holder.BackfillWorkerTest— withBACKFILLpaused,doWork()returnsretry()andMailBackfilleris neverinvoked; unpaused path unchanged.
MailSyncerTest/MailBackfillerTest— withPREFETCHpaused,fetchRecent/fetchOlderThanstill runbut
prefetchMessageis not called; unpaused path unchanged.FetchGateReceiveraction/scope parsing (backfill,prefetch/all/ unknown -> result-data).DebugFetchGatestate; assert a paused-backfill worker defers. Must run and pass on the cold-boot emulator +API 37 preflight.
am broadcast ... pause backfill,prefetch(confirm read-back) ->add Gmail account (headers sync, no
prefetch-body/backfill-pageops in logcat) -> open a message(
MailReader openMessage ... fetchedBody=true+ one realbody-fetchImapPerfop) -> measure uncached open->
resume all-> repeat for reuse ON vs OFF builds (#368 A/B). Supersedes the fragile settings-nav inprefetch-ab.Scope / notes
am broadcast FETCH_GATEtoadb.py's sanctionedamcalls and wiring theprefetch-ab/message-openscenarios to it) is a follow-up inscripts/device-testing/.needs a cold body), #221 (
ColdOpenCacheProbe— the debug-source-set precedent this mirrors), #358(
MailReader/ImapPerfbreadcrumbs used for readiness + measurement).