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
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.
AppLockGateHost idioms. Replaced the hand-rolled DisposableEffect + LifecycleEventObserver with two androidx.lifecycle.compose.LifecycleEventEffect calls (ON_START/ON_STOP), and the ContextWrapperfindFragmentActivity() walk with androidx.activity.compose.LocalActivity. The derived FragmentActivity and the authenticate lambda are now remembered instead of rebuilt each recomposition.
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_CREDENTIALcanAuthenticate combo is unsupported on minSdk 29. Kept isDeviceSecure() and AUTHENTICATORS; updated the SettingsScreenTest fake to drop the override.
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.
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)
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.
Closes #104
Behavior-preserving cleanup of the app-lock UI plumbing (built on the post-#119 state where
AppLockViewModelinjects the application-scoped@Singleton AppLockGate). No functional change.What changed
SettingsScreenToast → snackbar. Replaced the app's onlyToast(app-lock rejection message) with the canonicalSnackbarHostState+Scaffold(snackbarHost = …)+ consume pattern (matchesMailboxScreen). TheSettingsViewModelstill exposes the@StringRes Int?; it is resolved viaLocalResources.currentat the display boundary (lintLocalContextGetResourceValueCallforbidsLocalContext.getStringin Compose). This makes the path visible to Compose test semantics.AppLockGateHostidioms. Replaced the hand-rolledDisposableEffect+LifecycleEventObserverwith twoandroidx.lifecycle.compose.LifecycleEventEffectcalls (ON_START/ON_STOP), and theContextWrapperfindFragmentActivity()walk withandroidx.activity.compose.LocalActivity. The derivedFragmentActivityand theauthenticatelambda are nowremembered instead of rebuilt each recomposition.AppLockManager.availability()and the four-valueAppLockAvailabilityenum — no production caller (only interface/impl/test-fake), and theirBIOMETRIC_STRONG or DEVICE_CREDENTIALcanAuthenticatecombo is unsupported on minSdk 29. KeptisDeviceSecure()andAUTHENTICATORS; updated theSettingsScreenTestfake to drop the override.AppLockUiState. Introduced a singlepublish(error)helper that derives the gated state fromgate.state(the injected singleton — the source of truth) instead of hand-mirroring it at each site. Routed the gate-authoritative sites (REQUIRE_AUTH resolution,onAuthenticatedOK/RETRY,onAuthError) through it, replacing the oldemitLocked()and theif (state == UNLOCKED) … else …mirror.Behavior preservation
publish()readsgate.stateimmediately after the same gate call the old code made, so the emitted state (and thelockSeq/nonce bump on Locked) is bit-identical. The existingAppLockViewModelTest(onBackgrounddelegates to the gate;onAuthErrorlocks + surfaces the error) and the pureAppLockGateTestremain green.Checkingcover 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.AppLockGateHostsemantics are unchanged:LifecycleEventEffect(ON_START/ON_STOP)fires the same callbacks as the old observer, andLocalActivity.current as? FragmentActivityresolves the same host theContextWrapperwalk did.Tests
SettingsScreenTestwithenablingAppLockWithoutSecureDevice_showsRejectionSnackbar(device reports no secure lock → toggling app-lock on surfaces the rejection snackbar), and droppedavailability()from itsAppLockManagerfake.:app:assembleDebug :app:testDebugUnitTest :app:lintDebug :app:ktlintCheck :app:detektand:app:compileDebugAndroidTestKotlin.Notes for the maintainer
AppLockViewModeluiState/publish. Did not touchDatabaseModule/provideDatabase/entities/migrations (#93/#111),clearCacheAndRestart/restartProcess(#99), or the gate's grace/scoping logic (#101).LockAction.PROCEEDbranch inonForegroundremains defensively present but is unreachable (decide(appLockEnabled = true, …)never returnsPROCEED); left as-is to keep thewhenexhaustive.🤖 Generated with Claude Code