Commit Graph
3 Commits
Author SHA1 Message Date
JMR-dev 46ca019aa8 fix(security): derive key-invalidation and cache-guard decisions from seal state, not the encryptCache setting
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
2026-07-10 14:45:45 -05:00
JMR-dev 5c564ebeda perf(sync): pace backfill with an inter-slice cooldown and per-run cap
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
2026-07-08 16:57:15 -05:00
JMR-devandClaude Opus 4.8 8f8608a430 feat(debug): dev-only pause/halt mail-fetch hook for test harnesses (#393)
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>
2026-07-06 20:13:22 -05:00