Addresses 14 of the 15 confirmed findings from the max-effort review of the
screen-lock app gate. The remaining one (accounts/credentials share the
auth-bound cache DB) needs a device-tested Room migration and is filed
separately; its blast radius is reduced here by eliminating the spurious wipes.
- Cold-start deadlock: LibreMailApplication injects AccountRepository lazily so
the Room DB is never built on the main thread before unlock.
- Passphrase source of truth: DatabaseKeyStore.resolvePassphrase() keys off
which seal exists, not the app-lock setting; passphrase() refuses to mint a
master key while an auth seal exists.
- Toggle-order strand: disabling app-lock reseals under the master key whenever
an auth seal exists (not gated on the encryptCache setting).
- Crash-safe clear protocol: wipe + reset seals, then clear the flag last; set
clear-pending before flipping app-lock off.
- isInvalidated(): treats a lapsed auth window (UserNotAuthenticated) as valid,
and onForeground short-circuits when app-lock is off.
- unwrapSealedPassphrase: classifies all decrypt failures — no crash after a
successful auth.
- Headless entry points: SyncWorker/SendWorker/IdleService fail fast via
EncryptedCacheGuard instead of blocking DB construction while locked.
- sealWithMaster: deletes the orphaned auth key (no spurious later wipe).
- Lock-bypass race: AppLockGate ignores a background recorded after a foreground
pass began; the ViewModel captures the foreground timestamp synchronously.
- FLAG_SECURE: set while app-lock is on (recents/screenshot protection).
- Resume + re-lock: the gate covers content with an opaque overlay instead of
removing it, so no stale frame renders and in-progress state (nav, drafts)
survives re-lock.
- Retry feedback: lock emissions carry a nonce so a retry updates the UI.
Tests: AppLockGate stale-foreground race cases + an exhaustive
KeyInvalidationPolicy table. Fast gate green + androidTest compiles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brings the screen-lock app gate (#22) up to date with 25 commits of main
(signatures, backup opt-in, battery optimization, rich compose, reporting).
Conflicts resolved as a union of both features:
- SettingsRepository: adopt main's top-level Keys + shared toAppSettings()
refactor and thread appLock through it; keep both appLock and includeInBackup
- DatabaseModule: keep provideSignatureDao; keep DatabaseFiles.NAME for DB_NAME
- MainActivity: wrap LibreMailApp(pendingCompose=...) inside AppLockGateHost
- SettingsViewModel/SettingsScreen: union app-lock and battery state/effects;
keep LocalResources for the app-lock toast (LocalContextGetResourceValueCall lint)
- SettingsScreenTest: construct SettingsViewModel with the merged 5 args
- strings.xml: keep both the app-lock and battery/diagnostics string blocks
Fast gate green with JDK 21: assembleDebug + testDebugUnitTest + lintDebug +
compileDebugAndroidTestKotlin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an opt-in "Require screen lock" setting that gates the whole app behind
BiometricPrompt (strong biometric with device-credential fallback) and binds
the encrypted cache's SQLCipher passphrase to user authentication.
- App-lock gate: AppLockGateHost wraps the app; a pure AppLockGate state machine
locks on cold start / resume-after-timeout and unlocks on auth.
- Auth-bound decrypt: DatabaseKeyCipher seals the DB passphrase with a Keystore
key requiring user auth (setUserAuthenticationRequired, time-bound validity,
setInvalidatedByBiometricEnrollment). PassphraseSession holds the unwrapped
passphrase in memory; provideDatabase reads it only after auth.
- The non-auth master key (KeystoreCrypto) is unchanged, so background credential
access (IDLE push) still works.
- Invalidation / lock removal: KeyInvalidationPolicy decides clear-vs-disable;
the cache is wiped only at cold start in provideDatabase (never while Room holds
it open) via a persisted flag + process restart, then re-synced. No corruption.
- Enabling requires a secure device lock; disabling reseals the passphrase back
under the master key first (guarded to avoid a passphrase mismatch).
Adds androidx.biometric; MainActivity becomes a FragmentActivity (required by
BiometricPrompt). JVM tests cover the gate state machine, invalidation policy,
and passphrase session; the Keystore/BiometricPrompt/restart paths are
device-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>