The encryptCache setting is written immediately but the on-disk
conversion is deferred to the next cold start, so a transitional window
exists (setting off, DB still encrypted, SEALED_AUTH present) in which
three components answered from the setting and got it wrong (#479):
* AppLockViewModel fed the raw setting into KeyInvalidationPolicy, so
removing the device lock inside the window returned DISABLE_APP_LOCK:
app-lock silently off, no wipe, SEALED_AUTH orphaned - and the next
cold start hung forever in DatabaseKeyStore.resolvePassphrase
(session.await() nothing could complete), bricking the app behind the
CacheEncryptionGate until a data clear. onForeground now derives the
policy input as `encryptCache || hasAuthSealedPassphrase()` (the same
gate-on-the-seal fix SettingsViewModel.setAppLock already carries), so
the window routes to CLEAR_AND_DISABLE (wipe scheduled, restart) and
DISABLE_APP_LOCK is only reachable seal-free. The policy parameter is
renamed to `encryptedCacheProtected` to make the contract explicit.
* EncryptedCacheGuard.isCacheLocked() derived "locked" from the settings
pair, wrong in both transitional states: workers parked forever inside
provideDatabase when the setting was off but the DB still auth-sealed
(case A), and sync/push/send stalled needlessly while the seal was
still MASTER (case B). It now mirrors resolvePassphrase's blocking
branches - keyed off DatabaseKeyStore.sealState() plus, for the
AUTH-seal-with-setting-off window, a raw header read of the cache file
(still never touching Room).
* DatabaseProvisioner now releases the orphaned auth seal after the
decrypt-on-disable conversion (reseals under the master key,
best-effort), closing the window at its source instead of leaving
SEALED_AUTH to linger indefinitely.
All decision/fallback paths breadcrumb through AppLog (PII-free enums
and booleans only).
Closes#479
Backfill chained bounded slices back-to-back with no gap, and on a large
mailbox `moreWork` never clears, so a single BackfillWorker run paged
flat-out for its whole session and kept the account's IMAP connection
saturated -- the background load that starves interactive message-opens
(#355) and worsens provider throttling (#360).
Add BackfillPacer, a small in-process primitive that bounds one run:
- inter-slice cooldown: a fixed 30s idle between chained slices;
- per-run slice cap: at most 4 slices per run, then defer to the 30-min
periodic cadence so one run cannot monopolise the account.
Composes with the two sibling mechanisms instead of duplicating them:
- #355 (InteractiveImapGate): the cooldown is SKIPPED while an interactive
fetch is active -- the next slice already parks at its per-page yield
point, so a fixed delay on top would only double the idle (no pathological
double-delay);
- #360 (AccountThrottleGate): a slice whose only outstanding work is a
throttled account returns moreWork=false, so the loop stops and no
cooldown is spent spinning on a backed-off provider.
The cooldown is a cancellable delay and the loop rechecks !isStopped before
each slice, so a WorkManager stop / teardown ends a run promptly. All
breadcrumbs are PII-free (durations/counts only).
Tests: BackfillPacerTest (JVM, virtual time) covers cooldown timing, cap,
cancellation, and the #355/#360 composition; BackfillWorkerTest asserts the
worker caps a flat-out run; BackfillPacerInstrumentedTest proves forward
progress across paced runs, the interactive skip, and prompt cancellation on
the real Android runtime.
Closes#356
The on-device perf harness cannot force a genuinely uncached body fetch: proactive
backfill (#12) and post-sync body prefetch (#88/#89) warm the cache before a test can
open a message. Add a debug-only, adb-reachable hook to pause proactive fetch so a real
uncached open can be measured.
Components:
- DebugFetchGate (src/main): thread-safe in-memory holder of paused FetchScopes
(BACKFILL, PREFETCH; `all` alias). Defaults to not-paused; HEADER_SYNC and on-demand
OPEN are never gateable.
- FetchGateReceiver (src/debug only): BroadcastReceiver registered in the debug manifest,
driven by `adb shell am broadcast -a org.libremail.debug.FETCH_GATE -n .../FetchGateReceiver
--es action <pause|resume|query> --es scope <backfill,prefetch|all>`. Returns the state as
ordered-broadcast result data (paused=[...]) for a synchronous read-back.
Enforcement (each read guarded by BuildConfig.DEBUG so R8 strips it from release):
- BackfillWorker.doWork() entry -> skip-and-reschedule when BACKFILL is paused, mirroring
the existing cache-lock deferral (covers periodic + backfillNow()).
- MailSyncer/MailBackfiller.prefetchIfEnabled -> early-return when PREFETCH is paused.
openMessage / fetchBodyMarkingSeen / fetchAttachment are deliberately NOT gated.
Debug-only: receiver + <receiver> live wholly in src/debug; every gate read in main is
behind BuildConfig.DEBUG. Verified on assembleRelease that R8 strips DebugFetchGate /
FetchScope / FetchGateReceiver and the log strings from the release APK, and the merged
release manifest has no FETCH_GATE receiver.
PII-free AppLog breadcrumbs on pause/resume/query and on each gate-triggered defer/skip
(scope names only).
Tests: DebugFetchGateTest, BackfillWorkerTest / MailSyncerTest / MailBackfillerTest
enforcement cases, and FetchGateReceiverInstrumentedTest (ordered-broadcast -> gate ->
read-back; gated worker defers while an un-gated path runs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>