refactor(security): app-lock UI plumbing cleanup (snackbar, LifecycleEventEffect, LocalActivity, dead availability()) #121

Merged
JMR-dev merged 1 commits from refactor-applock-ui-plumbing into main 2026-07-02 09:49:28 +00:00
JMR-dev commented 2026-07-02 09:38:59 +00:00 (Migrated from github.com)

Closes #104

Behavior-preserving cleanup of the app-lock UI plumbing (built on the post-#119 state where AppLockViewModel injects the application-scoped @Singleton AppLockGate). No functional change.

What changed

  1. SettingsScreen Toast → snackbar. Replaced the app's only Toast (app-lock rejection message) with the canonical SnackbarHostState + Scaffold(snackbarHost = …) + consume pattern (matches MailboxScreen). The SettingsViewModel still exposes the @StringRes Int?; it is resolved via LocalResources.current at the display boundary (lint LocalContextGetResourceValueCall forbids LocalContext.getString in Compose). This makes the path visible to Compose test semantics.
  2. AppLockGateHost idioms. Replaced the hand-rolled DisposableEffect + LifecycleEventObserver with two androidx.lifecycle.compose.LifecycleEventEffect calls (ON_START/ON_STOP), and the ContextWrapper findFragmentActivity() walk with androidx.activity.compose.LocalActivity. The derived FragmentActivity and the authenticate lambda are now remembered instead of rebuilt each recomposition.
  3. Deleted dead API. Removed AppLockManager.availability() and the four-value AppLockAvailability enum — no production caller (only interface/impl/test-fake), and their BIOMETRIC_STRONG or DEVICE_CREDENTIAL canAuthenticate combo is unsupported on minSdk 29. Kept isDeviceSecure() and AUTHENTICATORS; updated the SettingsScreenTest fake to drop the override.
  4. Derived AppLockUiState. Introduced a single publish(error) helper that derives the gated state from gate.state (the injected singleton — the source of truth) instead of hand-mirroring it at each site. Routed the gate-authoritative sites (REQUIRE_AUTH resolution, onAuthenticated OK/RETRY, onAuthError) through it, replacing the old emitLocked() and the if (state == UNLOCKED) … else … mirror.

Behavior preservation

  • Every site routed through publish() reads gate.state immediately after the same gate call the old code made, so the emitted state (and the lockSeq/nonce bump on Locked) is bit-identical. The existing AppLockViewModelTest (onBackground delegates to the gate; onAuthError locks + surfaces the error) and the pure AppLockGateTest remain green.
  • The transient Checking cover and the "app-lock off / just disabled" unlocked states are intentionally not gate-derived — they are settings/lifecycle-driven. Routing them through the gate would change reachable behavior (e.g. enabling app-lock then returning within the grace window would wrongly skip the prompt), so they stay explicit. Per the ticket, the gate's grace/scoping logic (#101) is only consumed, never modified — no new gate mutations were added.
  • AppLockGateHost semantics are unchanged: LifecycleEventEffect(ON_START/ON_STOP) fires the same callbacks as the old observer, and LocalActivity.current as? FragmentActivity resolves the same host the ContextWrapper walk did.

Tests

  • Extended SettingsScreenTest with enablingAppLockWithoutSecureDevice_showsRejectionSnackbar (device reports no secure lock → toggling app-lock on surfaces the rejection snackbar), and dropped availability() from its AppLockManager fake.
  • Fast gate green locally on JDK 21: :app:assembleDebug :app:testDebugUnitTest :app:lintDebug :app:ktlintCheck :app:detekt and :app:compileDebugAndroidTestKotlin.

Notes for the maintainer

  • Strictly scoped to app-lock UI + AppLockViewModel uiState/publish. Did not touch DatabaseModule/provideDatabase/entities/migrations (#93/#111), clearCacheAndRestart/restartProcess (#99), or the gate's grace/scoping logic (#101).
  • The LockAction.PROCEED branch in onForeground remains defensively present but is unreachable (decide(appLockEnabled = true, …) never returns PROCEED); left as-is to keep the when exhaustive.

🤖 Generated with Claude Code

Closes #104 Behavior-preserving cleanup of the app-lock UI plumbing (built on the post-#119 state where `AppLockViewModel` injects the application-scoped `@Singleton AppLockGate`). No functional change. ## What changed 1. **`SettingsScreen` Toast → snackbar.** Replaced the app's only `Toast` (app-lock rejection message) with the canonical `SnackbarHostState` + `Scaffold(snackbarHost = …)` + consume pattern (matches `MailboxScreen`). The `SettingsViewModel` still exposes the `@StringRes Int?`; it is resolved via `LocalResources.current` at the display boundary (lint `LocalContextGetResourceValueCall` forbids `LocalContext.getString` in Compose). This makes the path visible to Compose test semantics. 2. **`AppLockGateHost` idioms.** Replaced the hand-rolled `DisposableEffect` + `LifecycleEventObserver` with two `androidx.lifecycle.compose.LifecycleEventEffect` calls (ON_START/ON_STOP), and the `ContextWrapper` `findFragmentActivity()` walk with `androidx.activity.compose.LocalActivity`. The derived `FragmentActivity` and the `authenticate` lambda are now `remember`ed instead of rebuilt each recomposition. 3. **Deleted dead API.** Removed `AppLockManager.availability()` and the four-value `AppLockAvailability` enum — no production caller (only interface/impl/test-fake), and their `BIOMETRIC_STRONG or DEVICE_CREDENTIAL` `canAuthenticate` combo is unsupported on minSdk 29. Kept `isDeviceSecure()` and `AUTHENTICATORS`; updated the `SettingsScreenTest` fake to drop the override. 4. **Derived `AppLockUiState`.** Introduced a single `publish(error)` helper that derives the gated state from `gate.state` (the injected singleton — the source of truth) instead of hand-mirroring it at each site. Routed the gate-authoritative sites (REQUIRE_AUTH resolution, `onAuthenticated` OK/RETRY, `onAuthError`) through it, replacing the old `emitLocked()` and the `if (state == UNLOCKED) … else …` mirror. ## Behavior preservation - Every site routed through `publish()` reads `gate.state` immediately after the same gate call the old code made, so the emitted state (and the `lockSeq`/nonce bump on Locked) is bit-identical. The existing `AppLockViewModelTest` (`onBackground` delegates to the gate; `onAuthError` locks + surfaces the error) and the pure `AppLockGateTest` remain green. - The transient `Checking` cover and the "app-lock off / just disabled" unlocked states are intentionally **not** gate-derived — they are settings/lifecycle-driven. Routing them through the gate would change reachable behavior (e.g. enabling app-lock then returning within the grace window would wrongly skip the prompt), so they stay explicit. Per the ticket, the gate's grace/scoping logic (#101) is only *consumed*, never modified — no new gate mutations were added. - `AppLockGateHost` semantics are unchanged: `LifecycleEventEffect(ON_START/ON_STOP)` fires the same callbacks as the old observer, and `LocalActivity.current as? FragmentActivity` resolves the same host the `ContextWrapper` walk did. ## Tests - Extended `SettingsScreenTest` with `enablingAppLockWithoutSecureDevice_showsRejectionSnackbar` (device reports no secure lock → toggling app-lock on surfaces the rejection snackbar), and dropped `availability()` from its `AppLockManager` fake. - Fast gate green locally on JDK 21: `:app:assembleDebug :app:testDebugUnitTest :app:lintDebug :app:ktlintCheck :app:detekt` and `:app:compileDebugAndroidTestKotlin`. ## Notes for the maintainer - Strictly scoped to app-lock UI + `AppLockViewModel` uiState/publish. Did not touch `DatabaseModule`/`provideDatabase`/entities/migrations (#93/#111), `clearCacheAndRestart`/`restartProcess` (#99), or the gate's grace/scoping logic (#101). - The `LockAction.PROCEED` branch in `onForeground` remains defensively present but is unreachable (`decide(appLockEnabled = true, …)` never returns `PROCEED`); left as-is to keep the `when` exhaustive. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.