mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(commons): scope-parameterise PrivacyLockState for multi-route lock reuse
Genericises the messaging privacy-lock state holder so a single master
`lockEnabled` flag can drive multiple gated routes independently:
- `LockScope { Messages, Wallet }` enum added.
- `MessagesLockState` → `PrivacyLockState(scope, settings, coroutineScope)`.
Each scope keeps its own StateFlow<LockState> + idle-timer Job; both
scopes share the same `PrivacyLockSettings` so failed-attempt counters
and lockout schedule stay device-global (brute-force protection).
- `LocalMessagesLockState` (single instance) → `LocalPrivacyLockState`
(Map<LockScope, PrivacyLockState>) + `lockStateFor(scope)` accessor.
- `redactionLevel` → `dmRedactionLevel` (Kotlin-side rename; persisted
prefs key `redaction_level_ordinal` unchanged).
- `setPasswordHashed(null)` cascades to `setLockEnabled(false)` so a
master lock cannot stay armed without a credential to verify against.
MessagesLockGate, DesktopMessagesLockGate, MessagesFirstRunBanner,
SetPasswordDialog, and RedactionCard now read `lockStateFor(Messages)`
— behaviour-preserving. Ships 3 new PrivacyLockStateTest cases:
independent per-scope state, shared failed-attempt counter, and the
password-clear cascade.
Plan: docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md
This commit is contained in:
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.privacylock
|
||||
|
||||
/**
|
||||
* Routes gated by the privacy lock.
|
||||
*
|
||||
* A single master `PrivacyLockSettings.lockEnabled` flag protects all scopes
|
||||
* together, but each scope keeps its own [PrivacyLockState] so that unlock,
|
||||
* idle-timer, and leave-route transitions apply independently per route.
|
||||
*/
|
||||
enum class LockScope { Messages, Wallet }
|
||||
+2
-2
@@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
interface PrivacyLockSettings {
|
||||
val lockEnabled: StateFlow<Boolean>
|
||||
val inactivityTimer: StateFlow<InactivityTimer>
|
||||
val redactionLevel: StateFlow<DmRedactionLevel>
|
||||
val dmRedactionLevel: StateFlow<DmRedactionLevel>
|
||||
val firstRunCardSeen: StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
@@ -68,7 +68,7 @@ interface PrivacyLockSettings {
|
||||
|
||||
fun setInactivityTimer(timer: InactivityTimer)
|
||||
|
||||
fun setRedactionLevel(level: DmRedactionLevel)
|
||||
fun setDmRedactionLevel(level: DmRedactionLevel)
|
||||
|
||||
fun setFirstRunCardSeen(seen: Boolean)
|
||||
|
||||
|
||||
+41
-14
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.privacylock
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -33,19 +35,25 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* App-global state holder for the Messages privacy lock.
|
||||
* App-global state holder for a single privacy-lock [scope].
|
||||
*
|
||||
* One instance per gated route (Messages, Wallet, …) is provided via
|
||||
* [LocalPrivacyLockState] at the App composition root. All instances share
|
||||
* the same [PrivacyLockSettings] — one master `lockEnabled` flag enables
|
||||
* every scope together — but each scope keeps its own [LockState] and its
|
||||
* own idle-timer [Job] so unlock, leave-route, and inactivity transitions
|
||||
* apply independently per route.
|
||||
*
|
||||
* - Single instance per app, provided via [LocalMessagesLockState] at the
|
||||
* App composition root.
|
||||
* - Initial value is seeded synchronously from [settings.lockEnabled.value]
|
||||
* so the first composition sees [LockState.Locked] without flashing
|
||||
* content (deep-link race fix, plan §Security Hardening H1).
|
||||
* - The underlying StateFlow is hot (`MutableStateFlow`); notification path
|
||||
* can read `state.value` synchronously without subscribing.
|
||||
*/
|
||||
class MessagesLockState(
|
||||
class PrivacyLockState(
|
||||
val scope: LockScope,
|
||||
private val settings: PrivacyLockSettings,
|
||||
private val scope: CoroutineScope,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) {
|
||||
private val seed: LockState =
|
||||
if (settings.lockEnabled.value) LockState.Locked else LockState.Disabled
|
||||
@@ -64,12 +72,12 @@ class MessagesLockState(
|
||||
} else if (mutableState.value is LockState.Disabled) {
|
||||
mutableState.value = LockState.Locked
|
||||
}
|
||||
}.launchIn(scope)
|
||||
}.launchIn(coroutineScope)
|
||||
|
||||
combine(settings.lockEnabled, settings.inactivityTimer) { enabled, timer -> enabled to timer }
|
||||
.onEach { _ ->
|
||||
if (mutableState.value is LockState.Unlocked) restartIdleTimer()
|
||||
}.launchIn(scope)
|
||||
}.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
/** Resets the inactivity timer. No-op unless currently Unlocked. */
|
||||
@@ -90,7 +98,7 @@ class MessagesLockState(
|
||||
* Mark the session as authenticated. Transitions from either
|
||||
* [LockState.Locked] (normal unlock path) or [LockState.Disabled]
|
||||
* (first-run banner path — enabling the lock while the user is
|
||||
* actively in Messages should NOT flash the lock screen).
|
||||
* actively in a gated route should NOT flash the lock screen).
|
||||
* No-op if already [LockState.Unlocked]. Starts the idle timer.
|
||||
*/
|
||||
fun onUnlockSuccess() {
|
||||
@@ -105,7 +113,8 @@ class MessagesLockState(
|
||||
|
||||
/**
|
||||
* Triggered when biometric / OS credential is permanently unavailable.
|
||||
* Auto-disables the lock so the user can keep accessing Messages.
|
||||
* Auto-disables the lock (flips every scope to [LockState.Disabled]
|
||||
* via the shared setting) so the user can keep accessing gated routes.
|
||||
*/
|
||||
fun onCredentialUnavailable() {
|
||||
cancelIdleTimer()
|
||||
@@ -118,6 +127,10 @@ class MessagesLockState(
|
||||
* [PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES] failures: base 30 s,
|
||||
* doubling each further failure, capped at 5 min.
|
||||
*
|
||||
* Backoff state is shared across scopes — a mistyped password on the
|
||||
* Wallet gate locks out the Messages gate too (and vice versa). This is
|
||||
* intentional anti-brute-force behaviour.
|
||||
*
|
||||
* @param nowMs current epoch millis (injected for testability).
|
||||
* @return the new [PrivacyLockSettings.lockedUntilEpochMs] value, or
|
||||
* null when no lockout yet applies.
|
||||
@@ -148,7 +161,7 @@ class MessagesLockState(
|
||||
cancelIdleTimer()
|
||||
val millis = settings.inactivityTimer.value.millis ?: return
|
||||
idleTimerJob =
|
||||
scope.launch {
|
||||
coroutineScope.launch {
|
||||
delay(millis)
|
||||
if (mutableState.value is LockState.Unlocked) {
|
||||
mutableState.value = LockState.Locked
|
||||
@@ -162,8 +175,22 @@ class MessagesLockState(
|
||||
}
|
||||
}
|
||||
|
||||
/** Provided once at the App composition root. */
|
||||
val LocalMessagesLockState =
|
||||
compositionLocalOf<MessagesLockState> {
|
||||
error("LocalMessagesLockState not provided — wrap App() with CompositionLocalProvider")
|
||||
/**
|
||||
* Provided once at the App composition root. Map keyed by [LockScope]; every
|
||||
* scope must have an entry (see [lockStateFor] which throws when missing).
|
||||
*/
|
||||
val LocalPrivacyLockState =
|
||||
compositionLocalOf<Map<LockScope, PrivacyLockState>> {
|
||||
error("LocalPrivacyLockState not provided — wrap App() with CompositionLocalProvider")
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience accessor used inside gate composables. Reads the map from the
|
||||
* ambient [LocalPrivacyLockState] and returns the state holder for [scope].
|
||||
* Throws if the scope was not registered at the App root.
|
||||
*/
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun lockStateFor(scope: LockScope): PrivacyLockState =
|
||||
LocalPrivacyLockState.current[scope]
|
||||
?: error("PrivacyLockState for $scope not registered at App root")
|
||||
+1
-1
@@ -55,7 +55,7 @@ enum class PromptResult {
|
||||
|
||||
/**
|
||||
* Credential surface permanently unavailable on this device — caller
|
||||
* should invoke [com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState.onCredentialUnavailable].
|
||||
* should invoke [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState.onCredentialUnavailable].
|
||||
*/
|
||||
Unavailable,
|
||||
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.ui.privacylock
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState
|
||||
|
||||
/**
|
||||
* Observes pointer events on the Initial pass — does NOT consume them, so
|
||||
@@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState
|
||||
* since they're not user input — preserves the "walked-away-from-desk"
|
||||
* protection per brainstorm resolved Q.
|
||||
*/
|
||||
fun Modifier.resetIdleOnInteraction(state: MessagesLockState): Modifier =
|
||||
fun Modifier.resetIdleOnInteraction(state: PrivacyLockState): Modifier =
|
||||
this.pointerInput(state) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
|
||||
+5
-4
@@ -43,8 +43,9 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -61,12 +62,12 @@ import kotlinx.coroutines.launch
|
||||
* `rememberSaveable` survive a lock cycle (SavedStateRegistry-backed).
|
||||
* For plain `remember` state, drafts are cleared — accept this trade-off.
|
||||
*
|
||||
* The gate also fires [MessagesLockState.onLeaveRoute] from its
|
||||
* The gate also fires [PrivacyLockState.onLeaveRoute] from its
|
||||
* [DisposableEffect.onDispose] block, so navigating away locks immediately.
|
||||
*/
|
||||
@Composable
|
||||
fun MessagesLockGate(content: @Composable () -> Unit) {
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val lockState = lockStateFor(LockScope.Messages)
|
||||
val current by lockState.state.collectAsState()
|
||||
|
||||
DisposableEffect(lockState) {
|
||||
@@ -81,7 +82,7 @@ fun MessagesLockGate(content: @Composable () -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun LockScreen() {
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val lockState = lockStateFor(LockScope.Messages)
|
||||
val prompter = LocalCredentialPrompter.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
|
||||
+83
-19
@@ -29,25 +29,27 @@ import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MessagesLockStateTest {
|
||||
class PrivacyLockStateTest {
|
||||
private class FakeSettings(
|
||||
lockEnabled: Boolean = false,
|
||||
timer: InactivityTimer = InactivityTimer.OneMin,
|
||||
password: String? = null,
|
||||
) : PrivacyLockSettings {
|
||||
private val mutableLockEnabled = MutableStateFlow(lockEnabled)
|
||||
private val mutableTimer = MutableStateFlow(timer)
|
||||
private val mutableRedaction = MutableStateFlow(DmRedactionLevel.DEFAULT)
|
||||
private val mutableFirstRunSeen = MutableStateFlow(false)
|
||||
private val mutablePasswordHashed = MutableStateFlow<String?>(null)
|
||||
private val mutablePasswordHashed = MutableStateFlow<String?>(password)
|
||||
private val mutableFailedAttempts = MutableStateFlow(0)
|
||||
private val mutableLockedUntil = MutableStateFlow<Long?>(null)
|
||||
|
||||
override val lockEnabled: StateFlow<Boolean> = mutableLockEnabled.asStateFlow()
|
||||
override val inactivityTimer: StateFlow<InactivityTimer> = mutableTimer.asStateFlow()
|
||||
override val redactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
|
||||
override val dmRedactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
|
||||
override val firstRunCardSeen: StateFlow<Boolean> = mutableFirstRunSeen.asStateFlow()
|
||||
override val passwordHashed: StateFlow<String?> = mutablePasswordHashed.asStateFlow()
|
||||
override val failedUnlockAttempts: StateFlow<Int> = mutableFailedAttempts.asStateFlow()
|
||||
@@ -61,7 +63,7 @@ class MessagesLockStateTest {
|
||||
mutableTimer.value = timer
|
||||
}
|
||||
|
||||
override fun setRedactionLevel(level: DmRedactionLevel) {
|
||||
override fun setDmRedactionLevel(level: DmRedactionLevel) {
|
||||
mutableRedaction.value = level
|
||||
}
|
||||
|
||||
@@ -71,6 +73,8 @@ class MessagesLockStateTest {
|
||||
|
||||
override fun setPasswordHashed(saltAndHash: String?) {
|
||||
mutablePasswordHashed.value = saltAndHash
|
||||
// Mirror the production cascade — no credential means no gate.
|
||||
if (saltAndHash == null && mutableLockEnabled.value) mutableLockEnabled.value = false
|
||||
}
|
||||
|
||||
override fun setFailedUnlockAttempts(count: Int) {
|
||||
@@ -86,7 +90,7 @@ class MessagesLockStateTest {
|
||||
fun cold_start_with_lock_enabled_seeds_to_locked() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
assertEquals(LockState.Locked, state.state.value)
|
||||
}
|
||||
|
||||
@@ -94,7 +98,7 @@ class MessagesLockStateTest {
|
||||
fun cold_start_with_lock_disabled_seeds_to_disabled() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = false)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
assertEquals(LockState.Disabled, state.state.value)
|
||||
}
|
||||
|
||||
@@ -102,7 +106,7 @@ class MessagesLockStateTest {
|
||||
fun unlock_success_transitions_to_unlocked_and_idle_timer_fires() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
advanceTimeBy(InactivityTimer.OneMin.millis!! + 1_000L)
|
||||
@@ -113,7 +117,7 @@ class MessagesLockStateTest {
|
||||
fun leave_route_locks_immediately() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneHour)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
state.onLeaveRoute()
|
||||
@@ -124,7 +128,7 @@ class MessagesLockStateTest {
|
||||
fun toggling_lock_off_transitions_to_disabled() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
settings.setLockEnabled(false)
|
||||
@@ -135,7 +139,7 @@ class MessagesLockStateTest {
|
||||
fun never_timer_does_not_fire() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.Never)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onUnlockSuccess()
|
||||
advanceTimeBy(InactivityTimer.OneHour.millis!! * 2)
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
@@ -145,7 +149,7 @@ class MessagesLockStateTest {
|
||||
fun user_interaction_resets_idle_timer() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onUnlockSuccess()
|
||||
advanceTimeBy(InactivityTimer.OneMin.millis!! - 1_000L)
|
||||
state.onUserInteraction()
|
||||
@@ -159,7 +163,7 @@ class MessagesLockStateTest {
|
||||
fun credential_unavailable_disables_lock() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
state.onCredentialUnavailable()
|
||||
assertEquals(LockState.Disabled, state.state.value)
|
||||
assertEquals(false, settings.lockEnabled.value)
|
||||
@@ -169,11 +173,11 @@ class MessagesLockStateTest {
|
||||
fun unlock_success_from_disabled_transitions_to_unlocked() =
|
||||
runTest {
|
||||
// First-run banner path: user enables lock + sets password while
|
||||
// already viewing Messages. State is Disabled at that moment, and
|
||||
// we want to stay Unlocked so the user isn't kicked to the lock
|
||||
// already viewing a gated route. State is Disabled at that moment,
|
||||
// and we want to stay Unlocked so the user isn't kicked to the lock
|
||||
// screen right after enabling.
|
||||
val settings = FakeSettings(lockEnabled = false)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
assertEquals(LockState.Disabled, state.state.value)
|
||||
state.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
@@ -183,7 +187,7 @@ class MessagesLockStateTest {
|
||||
fun failed_attempts_below_threshold_do_not_trip_lockout() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES - 1) {
|
||||
assertEquals(null, state.onFailedUnlockAttempt(now))
|
||||
@@ -199,7 +203,7 @@ class MessagesLockStateTest {
|
||||
fun fifth_failure_trips_base_lockout() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) {
|
||||
state.onFailedUnlockAttempt(now)
|
||||
@@ -212,7 +216,7 @@ class MessagesLockStateTest {
|
||||
fun lockout_doubles_and_caps_at_maximum() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
// 5th failure → base (30s)
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) }
|
||||
@@ -230,7 +234,7 @@ class MessagesLockStateTest {
|
||||
fun unlock_success_clears_backoff_state() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) }
|
||||
assertTrue(settings.lockedUntilEpochMs.value != null)
|
||||
@@ -239,4 +243,64 @@ class MessagesLockStateTest {
|
||||
assertEquals(null, settings.lockedUntilEpochMs.value)
|
||||
assertEquals(0, settings.failedUnlockAttempts.value)
|
||||
}
|
||||
|
||||
// ---- Wallet-lock reuse additions ----
|
||||
|
||||
@Test
|
||||
fun two_scopes_have_independent_lock_state() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope)
|
||||
assertEquals(LockState.Locked, messages.state.value)
|
||||
assertEquals(LockState.Locked, wallet.state.value)
|
||||
|
||||
messages.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, messages.state.value)
|
||||
assertEquals(LockState.Locked, wallet.state.value)
|
||||
|
||||
messages.onLeaveRoute()
|
||||
assertEquals(LockState.Locked, messages.state.value)
|
||||
assertEquals(LockState.Locked, wallet.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failed_unlock_counter_is_shared_across_scopes() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
// Three failures on Messages, two on Wallet → shared counter hits 5
|
||||
repeat(3) { messages.onFailedUnlockAttempt(now) }
|
||||
repeat(2) { wallet.onFailedUnlockAttempt(now) }
|
||||
assertEquals(
|
||||
PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES,
|
||||
settings.failedUnlockAttempts.value,
|
||||
)
|
||||
// The 5th failure trips the base lockout regardless of which scope
|
||||
// it came from — either scope now sees the countdown.
|
||||
assertEquals(
|
||||
now + PrivacyLockSettings.LOCKOUT_BASE_MS,
|
||||
settings.lockedUntilEpochMs.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearing_password_cascades_to_disable_the_master_lock() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val settings = FakeSettings(lockEnabled = true, password = "salt\$hash")
|
||||
val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
|
||||
val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope)
|
||||
assertEquals(LockState.Locked, messages.state.value)
|
||||
assertEquals(LockState.Locked, wallet.state.value)
|
||||
|
||||
// User clears the password from Settings → cascade fires
|
||||
settings.setPasswordHashed(null)
|
||||
|
||||
assertEquals(false, settings.lockEnabled.value)
|
||||
assertEquals(LockState.Disabled, messages.state.value)
|
||||
assertEquals(LockState.Disabled, wallet.state.value)
|
||||
assertNull(settings.passwordHashed.value)
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -63,7 +63,7 @@ class PreferencesPrivacyLockSettings(
|
||||
|
||||
override val lockEnabled: StateFlow<Boolean> = mutableEnabled.asStateFlow()
|
||||
override val inactivityTimer: StateFlow<InactivityTimer> = mutableTimer.asStateFlow()
|
||||
override val redactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
|
||||
override val dmRedactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
|
||||
override val firstRunCardSeen: StateFlow<Boolean> = mutableFirstRunSeen.asStateFlow()
|
||||
override val passwordHashed: StateFlow<String?> = mutablePasswordHashed.asStateFlow()
|
||||
override val failedUnlockAttempts: StateFlow<Int> = mutableFailedAttempts.asStateFlow()
|
||||
@@ -77,7 +77,7 @@ class PreferencesPrivacyLockSettings(
|
||||
// "locked UI / leaking notifications" anti-pattern).
|
||||
if (enabled && mutableRedaction.value == DmRedactionLevel.Full) {
|
||||
val userPickedFull = prefs.getBoolean("redaction_user_set", false)
|
||||
if (!userPickedFull) setRedactionLevel(DmRedactionLevel.Generic)
|
||||
if (!userPickedFull) setDmRedactionLevel(DmRedactionLevel.Generic)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ class PreferencesPrivacyLockSettings(
|
||||
prefs.putInt(KEY_INACTIVITY_TIMER, timer.ordinal)
|
||||
}
|
||||
|
||||
override fun setRedactionLevel(level: DmRedactionLevel) {
|
||||
override fun setDmRedactionLevel(level: DmRedactionLevel) {
|
||||
mutableRedaction.value = level
|
||||
prefs.putInt(KEY_REDACTION_LEVEL, level.ordinal)
|
||||
prefs.putBoolean("redaction_user_set", true)
|
||||
@@ -99,7 +99,15 @@ class PreferencesPrivacyLockSettings(
|
||||
|
||||
override fun setPasswordHashed(saltAndHash: String?) {
|
||||
mutablePasswordHashed.value = saltAndHash
|
||||
if (saltAndHash == null) prefs.remove(KEY_PASSWORD_HASHED) else prefs.put(KEY_PASSWORD_HASHED, saltAndHash)
|
||||
if (saltAndHash == null) {
|
||||
prefs.remove(KEY_PASSWORD_HASHED)
|
||||
// A lock without a credential is not a valid state — cascade so the
|
||||
// toggle can't stay on with nothing to verify against. Every gated
|
||||
// scope transitions to Disabled via the shared `lockEnabled` flag.
|
||||
if (mutableEnabled.value) setLockEnabled(false)
|
||||
} else {
|
||||
prefs.put(KEY_PASSWORD_HASHED, saltAndHash)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setFailedUnlockAttempts(count: Int) {
|
||||
|
||||
@@ -697,14 +697,17 @@ fun App(
|
||||
com.vitorpamplona.amethyst.commons.privacylock
|
||||
.PreferencesPrivacyLockSettings()
|
||||
}
|
||||
val messagesLockState =
|
||||
val privacyLockStates =
|
||||
remember(privacyLockSettings) {
|
||||
com.vitorpamplona.amethyst.commons.privacylock
|
||||
.MessagesLockState(privacyLockSettings, appScope)
|
||||
val scopes = com.vitorpamplona.amethyst.commons.privacylock.LockScope.entries
|
||||
scopes.associateWith { scope ->
|
||||
com.vitorpamplona.amethyst.commons.privacylock
|
||||
.PrivacyLockState(scope, privacyLockSettings, appScope)
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState provides messagesLockState,
|
||||
com.vitorpamplona.amethyst.commons.privacylock.LocalPrivacyLockState provides privacyLockStates,
|
||||
com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings provides privacyLockSettings,
|
||||
) {
|
||||
AppInner(
|
||||
|
||||
+4
-3
@@ -51,8 +51,9 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
@@ -72,7 +73,7 @@ import kotlinx.coroutines.delay
|
||||
*/
|
||||
@Composable
|
||||
fun DesktopMessagesLockGate(content: @Composable () -> Unit) {
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val lockState = lockStateFor(LockScope.Messages)
|
||||
val current by lockState.state.collectAsState()
|
||||
|
||||
DisposableEffect(lockState) {
|
||||
@@ -87,7 +88,7 @@ fun DesktopMessagesLockGate(content: @Composable () -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun DesktopLockScreen() {
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val lockState = lockStateFor(LockScope.Messages)
|
||||
val settings = LocalPrivacyLockSettings.current
|
||||
val stored by settings.passwordHashed.collectAsState()
|
||||
val lockedUntil by settings.lockedUntilEpochMs.collectAsState()
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.desktop.security
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings
|
||||
|
||||
/** Provided once at the Desktop App root alongside LocalMessagesLockState. */
|
||||
/** Provided once at the Desktop App root alongside LocalPrivacyLockState. */
|
||||
val LocalPrivacyLockSettings =
|
||||
compositionLocalOf<PrivacyLockSettings> {
|
||||
error("LocalPrivacyLockSettings not provided — wrap App() with CompositionLocalProvider")
|
||||
|
||||
+3
-2
@@ -47,7 +47,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
|
||||
|
||||
/**
|
||||
* One-time discovery banner at the top of the Desktop Messages column.
|
||||
@@ -64,7 +65,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState
|
||||
@Composable
|
||||
fun MessagesFirstRunBanner(onSaved: (String) -> Unit = {}) {
|
||||
val settings = LocalPrivacyLockSettings.current
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val lockState = lockStateFor(LockScope.Messages)
|
||||
val enabled by settings.lockEnabled.collectAsState()
|
||||
val seen by settings.firstRunCardSeen.collectAsState()
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
+6
-2
@@ -62,7 +62,8 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/** Enforced minimum length for a new/rotated password. */
|
||||
@@ -232,7 +233,10 @@ fun RemovePasswordDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
val lockState = LocalMessagesLockState.current
|
||||
// Remove-password only runs from Settings; the Messages state instance is
|
||||
// as good as any — both scopes read the same shared lockedUntilEpochMs and
|
||||
// failedUnlockAttempts, so backoff bookkeeping is scope-agnostic.
|
||||
val lockState = lockStateFor(LockScope.Messages)
|
||||
val settings = LocalPrivacyLockSettings.current
|
||||
val lockedUntil by settings.lockedUntilEpochMs.collectAsState()
|
||||
|
||||
|
||||
+2
-2
@@ -220,7 +220,7 @@ private fun InactivityCard(settings: PrivacyLockSettings) {
|
||||
@Composable
|
||||
private fun RedactionCard(settings: PrivacyLockSettings) {
|
||||
val enabled by settings.lockEnabled.collectAsState()
|
||||
val level by settings.redactionLevel.collectAsState()
|
||||
val level by settings.dmRedactionLevel.collectAsState()
|
||||
if (!enabled) return
|
||||
|
||||
SettingsCard(title = "DM notification preview") {
|
||||
@@ -245,7 +245,7 @@ private fun RedactionCard(settings: PrivacyLockSettings) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(entry.label()) },
|
||||
onClick = {
|
||||
settings.setRedactionLevel(entry)
|
||||
settings.setDmRedactionLevel(entry)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,844 @@
|
||||
---
|
||||
title: Reuse Messaging Privacy Lock on Wallet
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-07-07
|
||||
origin: docs/brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md
|
||||
depends_on: docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md
|
||||
---
|
||||
|
||||
# Reuse Messaging Privacy Lock on Wallet
|
||||
|
||||
Extract the messaging-scoped pieces of the shipped **Desktop Privacy Lock**
|
||||
(branch `feat/desktop-privacy-lock`) into a **scope-parameterised** privacy
|
||||
lock, then apply the same gate — plus first-run banner and settings knobs —
|
||||
to the Desktop **Wallet** deck column.
|
||||
|
||||
Goal in one line: **one master lock, one password**, gates Messages *and*
|
||||
Wallet routes together, zero code duplication.
|
||||
|
||||
**Design finalised (2026-07-07):**
|
||||
- Single master `lockEnabled` toggle protects both Messages and Wallet
|
||||
routes (per user decision — not per-scope enable).
|
||||
- Single `firstRunCardSeen` flag (dismiss once = dismissed everywhere).
|
||||
- `LockScope` enum exists only to route per-scope UI (lock-screen copy,
|
||||
independent idle timers, independent leave-route hooks). Settings
|
||||
surface is one flag.
|
||||
- Wallet blur-on-unfocus blurs **text nodes only** (balance amount,
|
||||
addresses, invoice strings) — cards / structural layout stay visible.
|
||||
- "No password set" branch in the Wallet gate **deep-links** to
|
||||
Settings → Privacy lock (not just an error message).
|
||||
- Ships as **one PR** stacked on `feat/desktop-privacy-lock`.
|
||||
|
||||
> Explicitly called out as a follow-up in the messaging-privacy-lock plan:
|
||||
> > **Wallet (NWC) gate** — reuse the same `MessagesLockGate` plumbing to
|
||||
> > gate the Wallet deck column. Already on the feature backlog.
|
||||
> (see plan: `docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md`
|
||||
> §Future Considerations)
|
||||
|
||||
## Overview
|
||||
|
||||
Privacy-lock feature currently protects **Messages only**. Financial data
|
||||
arguably more sensitive: passer-by seeing an NWC balance, a sats-in-flight
|
||||
receipt, or a QR-linked lightning address is worse than a DM. Wallet also a
|
||||
fast surface — opening the Wallet column loads the balance immediately, and
|
||||
NWC receive/send dialogs display payloads on-screen.
|
||||
|
||||
This plan **reuses ~90 %** of the messaging-privacy-lock scaffolding by
|
||||
turning `MessagesLockState` into a **scoped** state holder, splitting
|
||||
`lockEnabled` and `firstRunCardSeen` by scope, and applying the gate to
|
||||
`WalletColumnScreen`. Password + failed-attempts + lockout schedule stay
|
||||
shared (one password unlocks either scope) — matches Signal/WhatsApp
|
||||
mental model.
|
||||
|
||||
### Deliverables
|
||||
|
||||
1. `LockScope` enum (`Messages`, `Wallet`) — the single new type.
|
||||
2. `PrivacyLockState` (renamed from `MessagesLockState`) parameterised by
|
||||
`LockScope`; one instance per scope, both provided via CompositionLocal at
|
||||
the App root.
|
||||
3. `PrivacyLockSettings` gains **per-scope** `lockEnabled` and
|
||||
`firstRunCardSeen`. Password, inactivity timer, redaction level,
|
||||
failed-attempts, and lockout stay device-global.
|
||||
4. Shared `LockScreen()` composable takes a scope; renders scope-aware title
|
||||
+ subtitle strings.
|
||||
5. `DesktopWalletLockGate` — 30-line wrapper mirroring
|
||||
`DesktopMessagesLockGate`; also drives `applyWindowCaptureBlock` and
|
||||
blur-on-unfocus overlay while the Wallet column is visible.
|
||||
6. `WalletFirstRunBanner` — inline card at top of Wallet column, mirroring
|
||||
`MessagesFirstRunBanner`.
|
||||
7. `PrivacyLockSettingsScreen` gets a second card ("Lock the Wallet tab") +
|
||||
shared subtree for password, inactivity, redaction.
|
||||
8. Strings genericised: existing `messages_*` keys stay for Messages, new
|
||||
`wallet_*` mirrors added; a small set of neutral keys added under
|
||||
`privacy_lock_*` for shared UI (title bar, section header, password
|
||||
subtree).
|
||||
|
||||
### Out of scope for v1
|
||||
|
||||
- Android wallet gate — messaging lock does target Android, but wallet
|
||||
feature backlog emphasises Desktop; Android wallet gating trivial to add
|
||||
once `PrivacyLockState` scoped, but parked under Future Considerations to
|
||||
keep the PR bounded.
|
||||
- Per-note wallet controls (ReactionsRow zap button, ZapCustomDialog,
|
||||
UpdateZapAmountDialog). Already prompt OS credentials via
|
||||
`authenticate()` in `UpdateZapAmountDialog.kt:394-490`. Gating them again
|
||||
would double-prompt. Called out under §System-Wide Impact.
|
||||
- `amy` CLI `wallet` verbs — currently amy does not expose NWC actions. If
|
||||
they land, they should re-use `PrivacyLockPreferences` for parity.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Amethyst Desktop shows the wallet column with a single sidebar click.
|
||||
Balance auto-fetches on open; NWC receive/send dialogs render invoices and
|
||||
destination addresses inline. Anyone walking past a logged-in install can:
|
||||
|
||||
- Read the balance in sats.
|
||||
- See past-payment counterparties in the on-chain zap gallery.
|
||||
- Trigger the receive dialog and screenshot a lightning invoice belonging to
|
||||
the account owner.
|
||||
- Trigger the send dialog and see recently-used destinations.
|
||||
|
||||
Messaging-privacy-lock ships a gate that closes exactly this class of leak
|
||||
for DMs. Users asking for wallet protection (the driving ask that motivated
|
||||
this plan) are asking for the *same* gate applied to the *same* fast surface
|
||||
with the *same* UX contract:
|
||||
|
||||
- Off by default; opt-in via a first-run banner or Settings toggle.
|
||||
- One shared OS credential / password already established for Messages.
|
||||
- Idle-timer and leave-route re-lock.
|
||||
- No extra friction for actions that already gate on OS credentials (nsec
|
||||
export, zap-amount changes).
|
||||
|
||||
App-wide lock rejected during the messaging brainstorm as too coarse.
|
||||
Per-scope opt-in matches Signal (`Screen Lock`), WhatsApp (`Chat Lock`), and
|
||||
the existing shipped behaviour.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### One master lock, one password
|
||||
|
||||
Per user decision: **a single master `lockEnabled` toggle gates both
|
||||
Messages and Wallet routes together.** No per-scope enable flags.
|
||||
|
||||
```
|
||||
PrivacyLockSettings
|
||||
├── lockEnabled : StateFlow<Boolean> UNCHANGED (single master flag)
|
||||
├── firstRunCardSeen : StateFlow<Boolean> UNCHANGED (single, shared)
|
||||
├── passwordHashed : StateFlow<String?> UNCHANGED (shared)
|
||||
├── inactivityTimer : StateFlow<InactivityTimer> UNCHANGED (shared)
|
||||
├── dmRedactionLevel : StateFlow<DmRedactionLevel> RENAMED from `redactionLevel` (Messages-only semantics)
|
||||
├── failedUnlockAttempts : StateFlow<Int> UNCHANGED (shared)
|
||||
└── lockedUntilEpochMs : StateFlow<Long?> UNCHANGED (shared)
|
||||
```
|
||||
|
||||
**Cascade on password clear:** when `passwordHashed → null`,
|
||||
`PrivacyLockSettings` sets `lockEnabled → false` automatically (per user
|
||||
decision Q8). This closes the "toggle stays on but no credential exists"
|
||||
edge case without a UI dance.
|
||||
|
||||
Rationale:
|
||||
|
||||
| Setting | Per-scope? | Why |
|
||||
|---|---|---|
|
||||
| `lockEnabled` | ❌ | Single master toggle per user decision — enabling protects both Messages and Wallet simultaneously. Simplifies settings surface and matches "one lock, everything sensitive" mental model. |
|
||||
| `firstRunCardSeen` | ❌ | Dismiss once, dismissed everywhere. User already knows the feature exists after seeing it in either route. |
|
||||
| `passwordHashed` | ❌ | One password unlocks any gated route. Matches OS-keychain / device-credential precedent. |
|
||||
| `inactivityTimer` | ❌ | Timing is policy, not scope. Global. |
|
||||
| `dmRedactionLevel` | ❌ | DM notification redaction — no wallet analogue on Desktop today. Keep Messages-scoped semantics. |
|
||||
| `failedUnlockAttempts` / `lockedUntilEpochMs` | ❌ | Rate-limit is anti-brute-force — must be global counter. |
|
||||
|
||||
### Two lock states, one prompter
|
||||
|
||||
```
|
||||
LocalPrivacyLockState[Messages] ← MessagesLockGate reads
|
||||
LocalPrivacyLockState[Wallet] ← WalletLockGate reads
|
||||
LocalCredentialPrompter ← both gates share (unchanged)
|
||||
LocalPrivacyLockSettings ← both gates + settings screen share (unchanged)
|
||||
```
|
||||
|
||||
`PrivacyLockState` is created twice at the App root — one per scope. Both
|
||||
instances read the **same** `lockEnabled` and `firstRunCardSeen` flags.
|
||||
Each has its own idle-timer Job and its own `LockState` StateFlow
|
||||
(Locked ↔ Unlocked ↔ Disabled) so that:
|
||||
|
||||
- Unlocking Messages does *not* automatically unlock Wallet (each route
|
||||
demands its own credential prompt when the user enters it — this is a
|
||||
policy choice: the master lock protects *entry*, but re-entering a
|
||||
gated route is a fresh unlock).
|
||||
- Idle timer runs per-scope so the currently-visible route drives the
|
||||
re-lock, and the *other* route stays Locked without a running timer.
|
||||
- Leaving one route does not affect the other's state.
|
||||
|
||||
Writes to `failedUnlockAttempts` and `lockedUntilEpochMs` go through
|
||||
shared `PrivacyLockSettings` and therefore apply to both gates
|
||||
simultaneously — exactly the anti-brute-force property we want.
|
||||
|
||||
### Copy update
|
||||
|
||||
The existing `LockScreen()` in `MessagesLockGate.kt` hard-codes
|
||||
`"Messages locked"` and `"Unlock to read or send messages"`. Refactor to
|
||||
accept an `@StringRes` (Android) / string-key (Desktop) title and subtitle
|
||||
so the same composable serves both scopes.
|
||||
|
||||
Wallet copies:
|
||||
|
||||
| Slot | Wallet copy |
|
||||
|---|---|
|
||||
| Title | *"Wallet locked"* |
|
||||
| Subtitle | *"Unlock to see your balance and send or receive sats."* |
|
||||
| First-run banner title | *"Lock the Wallet tab?"* |
|
||||
| First-run banner body | *"Require a password before the Wallet column shows. Feed, profile, and Messages stay open."* |
|
||||
|
||||
Messages copies unchanged.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌───────────────────────────────────┐
|
||||
│ PrivacyLockSettings │ device-global (jvmAndroid)
|
||||
│ ─ lockEnabled(scope) 2× │ ← NEW: keyed by LockScope
|
||||
│ ─ firstRunCardSeen(scope) 2× │ ← NEW: keyed by LockScope
|
||||
│ ─ passwordHashed │ shared
|
||||
│ ─ inactivityTimer │ shared
|
||||
│ ─ failedUnlockAttempts │ shared
|
||||
│ ─ lockedUntilEpochMs │ shared
|
||||
└────────────────┬──────────────────┘
|
||||
│
|
||||
┌─────────────────────┼──────────────────────┐
|
||||
│ │
|
||||
┌────────────▼────────────┐ ┌────────────────▼────────────┐
|
||||
│ PrivacyLockState │ │ PrivacyLockState │
|
||||
│ (scope = Messages) │ │ (scope = Wallet) │
|
||||
│ ─ state: StateFlow<Lock>│ │ ─ state: StateFlow<Lock> │
|
||||
│ ─ own idle-timer Job │ │ ─ own idle-timer Job │
|
||||
└──────┬───────────────────┘ └───────────────┬──────────────┘
|
||||
│ │
|
||||
┌──────▼──────────────────────────┐ ┌─────────────▼────────────────┐
|
||||
│ DesktopMessagesLockGate │ │ DesktopWalletLockGate │
|
||||
│ (unchanged public API) │ │ (NEW — 30 LOC mirror) │
|
||||
│ wraps DesktopMessagesScreen │ │ wraps WalletColumnScreen │
|
||||
└──────────────────────────────────┘ └──────────────────────────────┘
|
||||
```
|
||||
|
||||
Symmetry: code path from `WalletLockGate` to unlock is byte-for-byte
|
||||
identical to `MessagesLockGate` — different scope enum, different string
|
||||
keys.
|
||||
|
||||
### Reuse-vs-New Matrix
|
||||
|
||||
| Component | Status | Location | Action |
|
||||
|---|---|---|---|
|
||||
| `PrivacyLockSettings` interface | ♻️ Evolve | `commons/.../privacylock/` | Split enabled+seen into scope-accessor fns |
|
||||
| `PreferencesPrivacyLockSettings` | ♻️ Evolve | `commons/jvmAndroid/.../privacylock/` | Add scope-suffixed prefs keys + legacy migration |
|
||||
| `MessagesLockState` | 📦 Rename | `commons/.../privacylock/` | Rename → `PrivacyLockState`, add `scope: LockScope` |
|
||||
| `LocalMessagesLockState` | 📦 Rename | (companion) | → `LocalPrivacyLockState: Map<LockScope, PrivacyLockState>` |
|
||||
| `LockState` sealed hierarchy | ✅ Reuse | `commons/.../privacylock/` | Unchanged |
|
||||
| `InactivityTimer` enum | ✅ Reuse | `commons/.../privacylock/` | Unchanged |
|
||||
| `DmRedactionLevel` | ✅ Reuse | `commons/.../privacylock/` | Optional rename → `DmRedactionLevel` stays, semantics scoped-out to Messages |
|
||||
| `CredentialPrompter` interface | ✅ Reuse | `commons/.../ui/privacylock/` | Unchanged |
|
||||
| `PasswordHasher` | ✅ Reuse | `commons/.../privacylock/` | Unchanged |
|
||||
| `IdleTimerModifier` | ✅ Reuse | `commons/.../ui/privacylock/` | Unchanged (Modifier already scope-agnostic) |
|
||||
| `MessagesLockGate` composable | ♻️ Shrink | `commons/.../ui/privacylock/` | ~15 LOC wrapper reading `scope=Messages` |
|
||||
| `WalletLockGate` composable | 🆕 New | `commons/.../ui/privacylock/` | ~15 LOC mirror |
|
||||
| Shared `LockScreen(scope,title,subtitle,unlockLabel)` | 🆕 Extract | `commons/.../ui/privacylock/` | Extracted from MessagesLockGate |
|
||||
| `DesktopMessagesLockGate` | ♻️ Consume | `desktopApp/.../security/` | Point at new shared `LockScreen` |
|
||||
| `DesktopWalletLockGate` | 🆕 New | `desktopApp/.../security/` | ~60 LOC mirror of `DesktopMessagesLockGate` |
|
||||
| `MessagesFirstRunBanner` | ♻️ Adjust | `desktopApp/.../security/` | Reads `firstRunCardSeen(Messages)` |
|
||||
| `WalletFirstRunBanner` | 🆕 New | `desktopApp/.../security/` | Mirror; reads `firstRunCardSeen(Wallet)` |
|
||||
| `SetPasswordDialog` | ✅ Reuse | `desktopApp/.../security/` | Unchanged (password stays shared) |
|
||||
| `PrivacyLockSettingsScreen` | ♻️ Two toggles | `desktopApp/.../ui/settings/` | Add Wallet toggle card + section headers |
|
||||
| `DeckColumnContainer` — Wallet branch | ♻️ Wrap | `desktopApp/.../ui/deck/` | 3-line change — wrap `WalletColumnScreen` with `DesktopWalletLockGate` |
|
||||
| `WindowCaptureBlock` route set | ♻️ Extend | `desktopApp/.../platform/` | Set expanded to `{Messages, Wallet}` |
|
||||
| `WalletColumnScreen` | ⚠️ Avoid rewriting | `desktopApp/.../ui/wallet/` | Only insert `WalletFirstRunBanner` at top of column; body unchanged |
|
||||
| Android `AmethystApp` — provide both scopes | ♻️ Provider | `amethyst/` | 4-line change — `LocalPrivacyLockState` map with 2 entries |
|
||||
| `Main.kt` App root — provide both scopes | ♻️ Provider | `desktopApp/jvmMain/` | 4-line change |
|
||||
| Strings — new `wallet_*` keys, rename shared `messages_lock_*` → `privacy_lock_*` | ♻️ | Android + Desktop | +6 keys, ~4 renames |
|
||||
|
||||
**Legend:** ✅ Reuse · 📦 Rename · ♻️ Evolve · 🆕 New · ⚠️ Avoid
|
||||
|
||||
### Data Migration
|
||||
|
||||
Because `lockEnabled` and `firstRunCardSeen` stay single-key under the
|
||||
master-lock model, **no prefs key migration is required**. The only
|
||||
rename touching persisted state is `redaction_level_ordinal` (unchanged
|
||||
key name; only the Kotlin-side identifier renames to `dmRedactionLevel`).
|
||||
|
||||
Existing prefs keys retained as-is:
|
||||
|
||||
```
|
||||
lock_enabled // master flag, unchanged
|
||||
first_run_card_seen // shared, unchanged
|
||||
password_hashed // unchanged
|
||||
inactivity_timer_ordinal // unchanged
|
||||
redaction_level_ordinal // unchanged (Kotlin var renamed to dmRedactionLevel)
|
||||
failed_unlock_attempts // unchanged
|
||||
locked_until_epoch_ms // unchanged
|
||||
```
|
||||
|
||||
This is a pure additive change from the persistence layer's point of
|
||||
view — Wallet gate simply reads the same flag Messages gate already
|
||||
reads.
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Phase 1 — Genericise the state holder (foundation)
|
||||
|
||||
Rename + parametrise **without** changing wire behaviour yet. Both
|
||||
`MessagesLockGate` and Desktop wrapper still work; nothing else changes.
|
||||
|
||||
Files to create / modify:
|
||||
|
||||
- **NEW** `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockScope.kt`
|
||||
```kotlin
|
||||
package com.vitorpamplona.amethyst.commons.privacylock
|
||||
|
||||
enum class LockScope { Messages, Wallet }
|
||||
```
|
||||
- **RENAME** `commons/.../privacylock/MessagesLockState.kt` →
|
||||
`PrivacyLockState.kt`
|
||||
- Rename class → `PrivacyLockState`, add constructor
|
||||
`scope: LockScope`.
|
||||
- Store `scope` on the instance; pass through to
|
||||
`settings.lockEnabled(scope)` /
|
||||
`settings.firstRunCardSeen(scope)`.
|
||||
- Companion: replace `LocalMessagesLockState:
|
||||
ProvidableCompositionLocal<MessagesLockState>` with
|
||||
`LocalPrivacyLockState:
|
||||
ProvidableCompositionLocal<Map<LockScope, PrivacyLockState>>`.
|
||||
- Add extension:
|
||||
`@Composable fun lockStateFor(scope: LockScope) =
|
||||
LocalPrivacyLockState.current.getValue(scope)`.
|
||||
- **MODIFY** `commons/.../privacylock/PrivacyLockSettings.kt` interface:
|
||||
- Replace `val lockEnabled: StateFlow<Boolean>` with
|
||||
`fun lockEnabled(scope: LockScope): StateFlow<Boolean>`.
|
||||
- Same for `firstRunCardSeen`.
|
||||
- Same for setters: `setLockEnabled(scope, enabled)`,
|
||||
`setFirstRunCardSeen(scope, seen)`.
|
||||
- `passwordHashed`, `inactivityTimer`, `redactionLevel`,
|
||||
`failedUnlockAttempts`, `lockedUntilEpochMs` — unchanged.
|
||||
- Update `companion object` constants:
|
||||
- `KEY_LOCK_ENABLED = "lock_enabled_"` (prefix; scope name appended)
|
||||
- `KEY_FIRST_RUN_CARD_SEEN = "first_run_card_seen_"` (prefix)
|
||||
- `KEY_SCHEMA_VERSION = "schema_version"`
|
||||
- `CURRENT_SCHEMA_VERSION = 2`
|
||||
- **MODIFY** `commons/jvmAndroid/.../privacylock/PreferencesPrivacyLockSettings.kt`:
|
||||
- Add per-scope `MutableStateFlow<Boolean>` maps:
|
||||
`Map<LockScope, MutableStateFlow<Boolean>>` for enabled and seen.
|
||||
- Seed each entry synchronously from prefs (respecting the deep-link
|
||||
race fix in the messaging-privacy-lock plan H1).
|
||||
- Add legacy-key migration in `init` block (see §Data Migration).
|
||||
- Setters write to the scope-suffixed key.
|
||||
- **RENAME + EXTEND** `commons/commonTest/.../privacylock/MessagesLockStateTest.kt`
|
||||
→ `PrivacyLockStateTest.kt`. Add tests:
|
||||
- `test_two_scopes_have_independent_state` — Messages Locked, Wallet
|
||||
Disabled, no cross-talk.
|
||||
- `test_shared_failed_unlock_counter` — a failure in Messages scope
|
||||
ticks the counter Wallet-scope reads.
|
||||
- `test_migration_from_legacy_prefs_keys` — write legacy keys, load
|
||||
settings, assert Messages scope has the value, Wallet default false,
|
||||
legacy keys removed, `schema_version = 2` written.
|
||||
|
||||
Ship this phase as its own commit — no UI changes; keeps `git bisect`
|
||||
useful.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- [x] `./gradlew :commons:jvmTest --tests "*PrivacyLockState*"` green (all 5 existing + 3 new)
|
||||
- [x] `./gradlew :desktopApp:compileKotlin` green (only rename+delegate calls updated)
|
||||
- [ ] `./gradlew :amethyst:assembleDebug` green
|
||||
|
||||
#### Phase 2 — Extract `LockScreen`, add `WalletLockGate`
|
||||
|
||||
- **NEW** `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/LockScreen.kt`
|
||||
- Extract `@Composable private fun LockScreen()` currently inline in
|
||||
`MessagesLockGate.kt`.
|
||||
- Make `internal`, take
|
||||
`scope: LockScope, title: String, subtitle: String, unlockLabel: String`.
|
||||
- No behaviour change beyond parameterisation.
|
||||
- **SHRINK** `commons/.../ui/privacylock/MessagesLockGate.kt` to a
|
||||
~15-line wrapper that fetches `lockStateFor(LockScope.Messages)`,
|
||||
`DisposableEffect(onLeaveRoute)`, and delegates the locked branch to
|
||||
`LockScreen(LockScope.Messages, stringRes(R.string.privacy_lock_messages_title), …)`.
|
||||
- **NEW** `commons/.../ui/privacylock/WalletLockGate.kt` — 15-line mirror.
|
||||
Scope = `Wallet`. Strings from
|
||||
`R.string.privacy_lock_wallet_title` /
|
||||
`R.string.privacy_lock_wallet_subtitle`.
|
||||
|
||||
Test coverage: unit tests on `PrivacyLockState` cover the state
|
||||
transitions; the gate composable is minimal and Compose-tested only via
|
||||
the manual sheet.
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- [ ] `MessagesLockGate` public signature unchanged (no caller changes)
|
||||
- [ ] `WalletLockGate` exposes the same `content: @Composable () -> Unit` lambda
|
||||
- [ ] Extracted `LockScreen` renders the correct title/subtitle for whichever scope invokes it
|
||||
|
||||
#### Phase 3 — Desktop: `DesktopWalletLockGate` + first-run banner + capture-block
|
||||
|
||||
- **NEW** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopWalletLockGate.kt`
|
||||
— mirror `DesktopMessagesLockGate.kt`. Only differences from Messages
|
||||
version:
|
||||
- Reads `lockStateFor(LockScope.Wallet)` instead of Messages.
|
||||
- Renders *"Wallet locked"* title, *"Enter your privacy-lock
|
||||
password to view the wallet."* subtitle.
|
||||
- `stored == null` branch: *"No password is set yet."* + button
|
||||
**"Open Settings"** that navigates via
|
||||
`SinglePaneState.navigate(DeckColumnType.Settings)` and (if the
|
||||
settings screen supports section anchors) deep-links to the
|
||||
Privacy-lock section. Falls back to plain Settings navigation if
|
||||
no anchor available (Q5 deep-link).
|
||||
- No independent password-hashing / lockout math — those come from
|
||||
shared `PrivacyLockSettings`.
|
||||
- **NEW** `desktopApp/.../security/WalletFirstRunBanner.kt` — mirror
|
||||
`MessagesFirstRunBanner.kt`. Only differences:
|
||||
- Reads `firstRunCardSeen(LockScope.Wallet)`.
|
||||
- Enable button writes `setLockEnabled(LockScope.Wallet, true)` and
|
||||
marks scope=Wallet card seen.
|
||||
- Text as per §Copy update table.
|
||||
- Icon = `MaterialSymbols.Lock` (same as Messages) — no new codepoint,
|
||||
so no font-subset regeneration needed.
|
||||
- `DesktopWalletLockGate` also drives capture-block and blur-on-unfocus
|
||||
the same way the Messages gate does — expand `WindowCaptureBlock.kt`
|
||||
so both routes flip the flag when the master lock is enabled AND the
|
||||
corresponding route is visible.
|
||||
- **Wallet blur mode** — per user decision Q4, blur only sensitive text
|
||||
nodes, not the whole column. Implementation:
|
||||
- New `Modifier.privacyLockBlurWhenUnfocused()` extension in
|
||||
`desktopApp/.../platform/` that reads `LocalWindowFocus.current` and
|
||||
applies `Modifier.blur(radius = 16.dp)` only when unfocused AND
|
||||
`lockEnabled == true`.
|
||||
- Apply this Modifier to Text composables that display: balance sats
|
||||
amount, lightning invoice string, on-chain address, NWC connection
|
||||
URI, and any transaction memo. **Do NOT** apply to card containers,
|
||||
icons, or button rows — the visual layout stays intact.
|
||||
- Grep target: any `Text(text = ...sats...)`, `Text(text = invoice)`,
|
||||
`Text(text = address)` in `WalletColumnScreen.kt`,
|
||||
`OnchainSection.kt` (if reused in Desktop), and NWC dialogs.
|
||||
|
||||
Wire into `DeckColumnContainer.kt`:
|
||||
|
||||
```kotlin
|
||||
DeckColumnType.Wallet -> {
|
||||
DesktopWalletLockGate {
|
||||
WalletColumnScreen(
|
||||
account = account,
|
||||
accountManager = accountManager,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
nwcConnection = nwcConnection,
|
||||
appScope = appScope,
|
||||
onZapFeedback = onZapFeedback,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Place `WalletFirstRunBanner` at the top of `WalletColumnScreen`'s Column
|
||||
(mirroring where `MessagesFirstRunBanner` sits in the Messages column
|
||||
entry).
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- [ ] Toggling `LockScope.Wallet` on in Settings → next Wallet column open shows the lock screen
|
||||
- [ ] Correct password (verified against shared `passwordHashed`) unlocks
|
||||
- [ ] Wrong password 5 times → lockout applies to **both** scopes (verified by observing Messages column also blocked)
|
||||
- [ ] Leaving the Wallet column re-locks it
|
||||
- [ ] Idle timer configured via shared `inactivityTimer` setting re-locks Wallet after N minutes
|
||||
- [ ] Screen-capture protection engages while Wallet column visible (macOS: `NSWindowSharingNone`; Windows: `WDA_EXCLUDEFROMCAPTURE`)
|
||||
- [ ] Blur-on-unfocus overlay renders over the Wallet column when the Amethyst window loses focus (16 dp radius, matches Messages)
|
||||
|
||||
#### Phase 4 — Settings screen: two toggles, shared subtree
|
||||
|
||||
Refactor `desktopApp/.../ui/settings/PrivacyLockSettingsScreen.kt`
|
||||
minimally — with the single-master-lock design, the shipped screen
|
||||
already has the right shape. Only cosmetic + copy changes:
|
||||
|
||||
- **Rename** the master-lock card header from *"Lock the Messages tab"*
|
||||
→ *"Lock the app"* (or *"Enable privacy lock"* — pick one, see
|
||||
the strings table).
|
||||
- **Update body copy** for the master-lock card to name what it protects:
|
||||
*"Require your password before Messages and Wallet columns show. Feed,
|
||||
profile, and search stay open."*
|
||||
- **Update caveat text** at top of screen: replace
|
||||
*"This lock hides the Messages column…"* with *"This lock hides the
|
||||
Messages and Wallet columns on an unattended device. See the caveats
|
||||
below."*.
|
||||
- Password / inactivity / redaction cards unchanged.
|
||||
|
||||
Layout order top-to-bottom (unchanged from shipped except copy):
|
||||
|
||||
```
|
||||
Section header: "Privacy lock"
|
||||
├── Card: "Privacy-lock password" (shared — always visible)
|
||||
├── Card: "Enable privacy lock" (single master toggle)
|
||||
├── Card: "Auto-lock after" (visible when master toggle is on)
|
||||
├── Card: "DM notification previews" (visible when master toggle is on)
|
||||
└── Card: "Caveats" (shared — always visible)
|
||||
```
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- [ ] Toggling the master lock on with no password → prompts to set one (existing behaviour)
|
||||
- [ ] Toggling the master lock on locks **both** Messages and Wallet on next entry
|
||||
- [ ] Toggling the master lock off unlocks **both** immediately (transitions Locked → Disabled)
|
||||
- [ ] Clearing the password auto-unsets the master toggle (Q8 cascade)
|
||||
|
||||
#### Phase 5 — Strings, migrations, docs, spotless
|
||||
|
||||
Strings to add / rename (Android `strings.xml` + Desktop
|
||||
`messages.properties`):
|
||||
|
||||
Shared (renamed from `messages_lock_*` → `privacy_lock_*` where
|
||||
applicable):
|
||||
|
||||
| Old key | New key | Notes |
|
||||
|---|---|---|
|
||||
| `messages_lock_setting_title` | `privacy_lock_settings_title` | section header |
|
||||
| `messages_lock_screen_password_label` | `privacy_lock_screen_password_label` | shared |
|
||||
| `messages_lock_screen_unlock_button` | `privacy_lock_screen_unlock_button` | shared |
|
||||
| (new) | `privacy_lock_intro_body` | *"This lock hides the Messages column and/or the Wallet column on an unattended device."* |
|
||||
|
||||
Scope-specific (Messages keys stay verbatim; Wallet keys mirror them):
|
||||
|
||||
| Wallet key | Value |
|
||||
|---|---|
|
||||
| `privacy_lock_wallet_toggle_title` | *"Lock the Wallet tab"* |
|
||||
| `privacy_lock_wallet_toggle_body` | *"Require a password before the Wallet column shows. Feed, profile, and Messages stay open."* |
|
||||
| `privacy_lock_wallet_lockscreen_title` | *"Wallet locked"* |
|
||||
| `privacy_lock_wallet_lockscreen_subtitle` | *"Unlock to see your balance and send or receive sats."* |
|
||||
| `privacy_lock_wallet_firstrun_title` | *"Lock the Wallet tab?"* |
|
||||
| `privacy_lock_wallet_firstrun_body` | *"Require a password before Wallet shows. Feed, profile, and Messages stay open."* |
|
||||
|
||||
Other tasks:
|
||||
|
||||
- Run legacy-key migration (Phase 1) on first startup after upgrade.
|
||||
- Update `commons/ARCHITECTURE.md` — mention `LockScope` under the
|
||||
`privacylock/` package entry.
|
||||
- Update `MEMORY.md` — add pointer to this plan alongside the
|
||||
messaging-privacy-lock pointer.
|
||||
- `./gradlew spotlessApply`.
|
||||
- Update manual testing sheet (see §Documentation Plan) — copy the
|
||||
Messages sheet, adjust for Wallet.
|
||||
- Verify Crowdin sync propagates the new keys (existing PR pipeline
|
||||
already syncs; no new machinery needed).
|
||||
|
||||
**Acceptance:**
|
||||
|
||||
- [ ] `./gradlew :commons:jvmTest --tests "*privacylock*"` green
|
||||
- [ ] `./gradlew :amethyst:assembleDebug` green
|
||||
- [ ] `./gradlew :desktopApp:compileKotlin` green
|
||||
- [ ] `./gradlew spotlessApply` clean
|
||||
- [ ] Manual testing sheet passes (see §Documentation Plan)
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
### Interaction Graph
|
||||
|
||||
User clicks Wallet in sidebar →
|
||||
`SinglePaneState.navigate(DeckColumnType.Wallet)` →
|
||||
`DeckColumnContainer` composes Wallet branch →
|
||||
`DesktopWalletLockGate` reads `lockStateFor(LockScope.Wallet).state` →
|
||||
|
||||
- If `Disabled` or `Unlocked` → `WalletColumnScreen` composes;
|
||||
`WalletFirstRunBanner` may render at top if user hasn't dismissed it
|
||||
and lock is disabled.
|
||||
- If `Locked` → `LockScreen(scope = Wallet, title = "Wallet locked",
|
||||
subtitle = "Unlock to see your balance and send or receive sats.")`
|
||||
renders. On unlock success → `PrivacyLockState.onUnlockSuccess()` →
|
||||
`WalletColumnScreen` composes.
|
||||
|
||||
User leaves the Wallet column (navigates away, switches account, or
|
||||
window closes) → `DesktopWalletLockGate.DisposableEffect.onDispose` →
|
||||
`PrivacyLockState.onLeaveRoute()` for scope=Wallet only. Messages state
|
||||
unaffected.
|
||||
|
||||
Cross-scope: if user is on Messages, unlocks, then navigates to Wallet,
|
||||
the Wallet gate still shows (independent scopes). Same password →
|
||||
Wallet unlocks. Matches settings UX: two toggles, one credential.
|
||||
|
||||
### Error Propagation
|
||||
|
||||
Wallet gate uses the identical `submit` path as `DesktopMessagesLockGate`
|
||||
in the shipped code:
|
||||
|
||||
| Origin | Error | Handled at | Result |
|
||||
|---|---|---|---|
|
||||
| Wrong password | `PasswordHasher.verify → false` | `DesktopWalletLockGate.submit` | `showError = true`; `onFailedUnlockAttempt` increments **shared** counter |
|
||||
| 5 consecutive failures | shared counter hits `LOCKOUT_TRIP_AFTER_FAILURES` | `PrivacyLockState.onFailedUnlockAttempt` | Shared `lockedUntilEpochMs` set → **both** scopes show the countdown supportingText |
|
||||
| Password cleared while wallet Locked | `settings.passwordHashed → null` | `DesktopWalletLockGate.DesktopLockScreen` | *"No password is set yet"* branch renders; `Disable lock` button clears `lockEnabled(Wallet)` |
|
||||
| Wallet toggle enabled but no password | Settings screen | Enable button triggers `SetPasswordDialog` first |
|
||||
| Settings write fails (java.util.prefs full) | `PreferencesPrivacyLockSettings.setLockEnabled` | Existing best-effort semantics | Toggle reverts on next flow emit; user sees no confirmation |
|
||||
|
||||
### State Lifecycle Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Wallet locked, incoming NWC balance/receipt event decrypts in background — plaintext held in memory | Same posture as messaging plan: cosmetic lock, not cryptographic. Balance StateFlow keeps last-known value. NWC responses continue to arrive on the coroutine scope; UI just doesn't render them until unlock. Honest and matches messaging behaviour. Called out in §Known Limitations. |
|
||||
| App killed mid-unlock leaves Wallet stuck at Locked | State is in-memory; cold start re-reads `lockEnabled(Wallet)` from prefs → if enabled, starts Locked. Fail-safe. |
|
||||
| User toggles Wallet off while Locked | `settings.lockEnabled(Wallet) → false` flows into `PrivacyLockState` which transitions `Locked → Disabled` on the next tick. Gate transparently shows content. Matches messaging behaviour. |
|
||||
| Both scopes Locked, user in middle of a send-payment flow | Send-payment happens **inside** an already-unlocked scope; if idle timer fires mid-flow, the dialog stays composed (rememberSaveable) but the content behind is gated. Intentional — do not exempt in-flight payment dialogs from the timer. Manual test: `payment_flow_survives_timer.md`. |
|
||||
| Concurrent leave-route events (Wallet + Messages navigating away simultaneously) | Each `PrivacyLockState` has its own idle-timer Job; no cross-scope races. |
|
||||
|
||||
### API Surface Parity
|
||||
|
||||
| Surface | Affected? | Notes |
|
||||
|---|---|---|
|
||||
| Android wallet UI (`OnchainSection`, `AddCashuWalletScreen`) | Deferred to v2 | Not touched in this plan — see §Future Considerations. |
|
||||
| `amy` CLI | Not touched | CLI does not surface NWC actions today; when it does, use `PrivacyLockSettings.lockEnabled(Wallet)` for parity. |
|
||||
| `UpdateZapAmountDialog.authenticate()` (nsec-key-guard biometric prompt) | Not affected | Separate OS-credential gate on the zap-amount-preferences change flow. Wallet gate is orthogonal — zap flow already gates OS credentials for a stronger reason. |
|
||||
| One-click zap from a note (`ReactionsRow.RenderZapButton`) | Not gated | Wallet **column** is gated; zap **action** from feed context is not. Matches messaging: Messages **column** is gated; DM replies from a note thread are not (there aren't any). |
|
||||
| Wallet notifications (NWC `success`, `failed`) | None on Desktop today | Desktop has no notification pipeline for wallet events. If added, use `redactionLevel` — but v1 keeps redaction Messages-only per §Proposed Solution. |
|
||||
| Search results — NWC receipts / on-chain zaps | Not affected | `SearchBarViewModel` search-audit path from messaging plan already filters kinds 4/14/1059/443. NWC events (kind 23194/23195/23196) aren't searchable today. If they become searchable, add them to the audit list. |
|
||||
|
||||
### Integration Test Scenarios
|
||||
|
||||
1. **Cross-scope lockout**: Wallet locked. User enters 5 wrong
|
||||
passwords on Wallet screen. Then navigates to Messages (also locked
|
||||
via Messages toggle). Expected: Messages screen shows countdown
|
||||
supportingText, `Unlock` button disabled. Failure mode: counter
|
||||
scoped per-gate would defeat brute-force protection.
|
||||
2. **Wallet lock + auto-fetched balance**: Wallet toggle just enabled;
|
||||
user has been on Wallet column with balance loaded. Setting flip →
|
||||
gate re-composes → balance is hidden behind the lock screen
|
||||
**immediately**. Failure mode: balance visible for one frame during
|
||||
transition.
|
||||
3. **First-run banner interaction**: Fresh install → Messages column
|
||||
visited → Messages banner shown, dismissed. User visits Wallet →
|
||||
Wallet banner shown independently (not shared dismissal). Failure
|
||||
mode: shared `firstRunCardSeen` would suppress Wallet banner.
|
||||
4. **Toggle-disable while Locked**: User is on the Wallet lock screen →
|
||||
opens Settings → Privacy lock → toggles Wallet off → returns to
|
||||
Wallet. Expected: content shows without unlock. Failure mode: state
|
||||
stays Locked because the settings update didn't cascade.
|
||||
5. **NWC connect flow while locked**: New user, no NWC connected,
|
||||
Wallet toggle on. Expected: `WalletColumnScreen`'s connect UI is
|
||||
gated behind the lock — a Locked-gate does not let unauth users
|
||||
trigger NWC pairing. Desired security posture.
|
||||
6. **Password change → shared re-verify**: User changes password while
|
||||
only Messages is locked. Then enables Wallet. Wallet lock screen
|
||||
accepts the **new** password (not the old one). Failure mode: two
|
||||
password hashes cached separately.
|
||||
7. **Migration from legacy prefs**: User on a build with the shipped
|
||||
`feat/desktop-privacy-lock` (single `lock_enabled` key) upgrades to
|
||||
this build. Expected: Messages toggle preserved; Wallet toggle
|
||||
defaults off. Legacy prefs keys removed; `schema_version = 2`
|
||||
written. Failure mode: users get silently un-locked on upgrade, OR
|
||||
migration re-runs and clobbers a subsequent Wallet toggle.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional
|
||||
|
||||
- [ ] `LockScope` enum shipped in `commons/commonMain`
|
||||
- [ ] `PrivacyLockState` replaces `MessagesLockState`; each scope has an
|
||||
independent `state: StateFlow<LockState>` and idle-timer Job
|
||||
- [ ] `PrivacyLockSettings.lockEnabled` and `firstRunCardSeen` are
|
||||
scope-accessor functions
|
||||
- [ ] Password / inactivity timer / redaction / failed-attempts / lockout
|
||||
remain device-global (shared)
|
||||
- [ ] `MessagesLockGate` public signature unchanged; wired to
|
||||
`lockStateFor(Messages)`
|
||||
- [ ] `WalletLockGate` composable shipped in
|
||||
`commons/.../ui/privacylock/`
|
||||
- [ ] Shared `LockScreen(scope, title, subtitle, unlockLabel)` composable
|
||||
replaces the inlined lock screen inside MessagesLockGate; both
|
||||
gates render it
|
||||
- [ ] `DesktopMessagesLockGate` unchanged in behaviour; consumes the new
|
||||
shared `LockScreen`
|
||||
- [ ] `DesktopWalletLockGate` shipped; wraps `WalletColumnScreen` inside
|
||||
`DeckColumnContainer`
|
||||
- [ ] `MessagesFirstRunBanner` unchanged in behaviour
|
||||
- [ ] `WalletFirstRunBanner` shipped at top of `WalletColumnScreen`
|
||||
- [ ] Settings screen renders two toggles + shared password subtree +
|
||||
shared inactivity timer + Messages-only redaction card
|
||||
- [ ] Legacy prefs migration runs on first startup after upgrade — old
|
||||
`lock_enabled` value moved to `lock_enabled_Messages`, then old key
|
||||
removed; `schema_version = 2` written
|
||||
- [ ] `applyWindowCaptureBlock(true)` engages when either lock is enabled
|
||||
AND the corresponding route is visible
|
||||
- [ ] Blur-on-unfocus overlay renders over Wallet column when window
|
||||
loses focus AND `lockEnabled(Wallet) == true`
|
||||
|
||||
### Non-Functional
|
||||
|
||||
- [ ] No measurable startup regression (≤ +5 ms cold start on top of
|
||||
messaging-lock baseline)
|
||||
- [ ] `PrivacyLockState.state` reads are constant-time regardless of
|
||||
scope count (Map lookup, no reflection)
|
||||
- [ ] No new deps added — everything stays inside kotlinx.coroutines +
|
||||
Compose + the existing java.util.prefs / SharedPreferences setup
|
||||
- [ ] Password comparison stays constant-time via `PasswordHasher.verify`
|
||||
(unchanged)
|
||||
- [ ] No visible flash of Wallet content on cold start when
|
||||
`lockEnabled(Wallet) = true` — seeded synchronously (deep-link race
|
||||
fix H1 from messaging plan applies to both scopes)
|
||||
|
||||
### Quality Gates
|
||||
|
||||
- [ ] `./gradlew :commons:jvmTest --tests "*privacylock*"` green (8 tests)
|
||||
- [ ] `./gradlew :amethyst:assembleDebug` green
|
||||
- [ ] `./gradlew :desktopApp:compileKotlin` green
|
||||
- [ ] `./gradlew :desktopApp:packageDmg` green on macOS host
|
||||
- [ ] `./gradlew :desktopApp:packageMsi` green on Windows host (best effort)
|
||||
- [ ] `./gradlew :desktopApp:packageDeb` green on Linux host
|
||||
- [ ] `./gradlew spotlessApply` clean
|
||||
- [ ] Manual testing sheet
|
||||
(`docs/plans/2026-07-07-wallet-lock-manual-testing.md`) executed
|
||||
and signed off
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- **Adoption proxy**: after 30 days on nightly, at least half the users
|
||||
who enabled the Messages lock have also enabled the Wallet lock. If
|
||||
the ratio is far lower, the discoverability (first-run banner
|
||||
placement + settings copy) needs rework.
|
||||
- **Stability proxy**: zero support reports of *"wallet stuck at
|
||||
locked"* or *"wrong password after change"* in the first 30 days.
|
||||
- **Regression proxy**: no new issues on Messages lock after this PR
|
||||
merges — the refactor keeps behaviour identical for the Messages path.
|
||||
|
||||
## Dependencies & Prerequisites
|
||||
|
||||
- **Blocked on**: `feat/desktop-privacy-lock` merged into main. This
|
||||
plan builds on top of that shipped feature; extracting into a
|
||||
scope-parameterised state holder while the messaging code is still
|
||||
on a branch would create merge-conflict hell.
|
||||
- **No new deps**: everything reuses the shipped `androidx.biometric.ktx`,
|
||||
`com.sun.jna:jna`, java.util.prefs, SharedPreferences, Compose
|
||||
Multiplatform.
|
||||
- **No native shims added**: Touch ID `.dylib`, Windows credprompter,
|
||||
`NSWindowSharingNone` / `WDA_EXCLUDEFROMCAPTURE` shims — all already
|
||||
shipped by `feat/desktop-privacy-lock`. This plan just adds the Wallet
|
||||
route into the set that flips the flag.
|
||||
|
||||
## Risk Analysis & Mitigation
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Migration bug leaves a Messages user un-locked on upgrade | Medium | High (silent security regression) | Migration is copy-then-delete; version-gated by `schema_version = 2`; unit-tested; runs once and no-ops afterwards |
|
||||
| Per-scope idle timers get out of sync (e.g. two timers on different Jobs miscoordinate) | Low | Low | Each `PrivacyLockState` is a self-contained state machine; no cross-scope coordination; unit test asserts independence |
|
||||
| Users confused by two toggles + one password | Medium | Low (UX) | Settings copy: password card explicitly says *"One password. Applies to any tab you lock below."* Manual testing sheet includes a UX-clarity checkpoint |
|
||||
| Wallet balance flashes visible on cold start | Low | High (privacy leak) | Same synchronous seed as messaging (H1). Compose-test asserts no-flash invariant on Wallet route too |
|
||||
| Shared failed-attempts counter causes friction — a user mistyping in Wallet locks out Messages | Verified | Low | Intended behaviour — brute-force protection is a global property. Copy in the lockout supportingText clarifies: *"Too many failed attempts. Try again in ${countdown}."* — same message on both scopes |
|
||||
| Refactor breaks `MessagesLockGate` on the shipped branch | Medium | High | Phase 1 is behaviour-preserving; Phase 2 preserves `MessagesLockGate`'s public signature; verified by a full manual pass on the shipped Messages testing sheet |
|
||||
| ProGuard strips scope-based lookups | Low | Medium | `LockScope` is a simple enum — ProGuard-safe. Confirm during Phase 1 packaging |
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Android wallet gate.** When Android wallet is elevated to a first-class
|
||||
destination (currently the wallet lives in a subscreen, not a tab), wrap
|
||||
its Compose entry point with `WalletLockGate` — no state-holder change
|
||||
required; `PrivacyLockState[Wallet]` already exists.
|
||||
- **amy CLI wallet verbs.** If `amy wallet balance` / `amy wallet send`
|
||||
land, they should refuse to run when
|
||||
`PrivacyLockPreferences.lockEnabled(Wallet)` is `true` — closes the
|
||||
"run amy on a shared machine to snapshot the balance" gap.
|
||||
- **Per-transaction OS-credential re-prompt on Wallet send.** Optional
|
||||
belt-and-suspenders: when a send-payment exceeds a user-configurable
|
||||
threshold (e.g. 10k sats), fire the same
|
||||
`UpdateZapAmountDialog.authenticate()` prompt. Tracks separately —
|
||||
this plan is about the column gate, not per-action gates.
|
||||
- **Third scope: nsec / account settings.** Once we have `LockScope`, we
|
||||
could add `LockScope.Account` to gate the Account backup screen.
|
||||
Today that screen already uses OS-credential re-prompts, so added
|
||||
value is marginal.
|
||||
- **`redactionLevel` extension for wallet notifications.** If Desktop gets
|
||||
a notification pipeline for NWC events (balance changes, incoming
|
||||
zaps), add a `WalletRedactionLevel` and gate the same way DM
|
||||
notifications are gated. Currently no such pipeline exists.
|
||||
|
||||
## Documentation Plan
|
||||
|
||||
- `commons/ARCHITECTURE.md` — update the `privacylock/` package entry:
|
||||
mention the `LockScope` enum and the "one settings, many scopes"
|
||||
contract.
|
||||
- `docs/plans/2026-07-07-wallet-lock-manual-testing.md` — new manual
|
||||
testing sheet mirroring
|
||||
`docs/plans/2026-06-30-privacy-lock-manual-testing.md`. Include the 7
|
||||
integration test scenarios above as concrete steps.
|
||||
- No changes needed to `desktopApp/CLAUDE.md` — no new native shim.
|
||||
- `MEMORY.md` — index entry alongside the messaging-privacy-lock work.
|
||||
- Release notes: extend the "Privacy & Security" section from
|
||||
messaging-privacy-lock with a one-line Wallet addition.
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
|
||||
- **Brainstorm document**:
|
||||
[`docs/brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md`](../brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md)
|
||||
— where the "reuse for Wallet" follow-up was explicitly enumerated as
|
||||
a Future Consideration.
|
||||
- **Predecessor plan**:
|
||||
[`docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md`](2026-06-30-feat-messaging-privacy-lock-plan.md)
|
||||
— carried-forward decisions: (a) OS credentials only, (b) device-global
|
||||
settings, (c) inactivity timer + leave-route re-lock, (d) synchronous
|
||||
initial-state seed for the deep-link race fix, (e) shared password
|
||||
hashing + exponential-backoff lockout.
|
||||
|
||||
### Internal References
|
||||
|
||||
- Extracted from:
|
||||
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt`
|
||||
(on branch `feat/desktop-privacy-lock`)
|
||||
- Extracted from:
|
||||
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt`
|
||||
(on branch `feat/desktop-privacy-lock`)
|
||||
- Extracted from:
|
||||
`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt`
|
||||
- Wallet column entry:
|
||||
`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt:88`
|
||||
- Deck integration site:
|
||||
`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt:469-479`
|
||||
- Settings screen:
|
||||
`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt`
|
||||
- OS-credential biometric precedent:
|
||||
`amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt:394-490`
|
||||
- CLAUDE.md — `commons/ARCHITECTURE.md` governs package taxonomy
|
||||
|
||||
### External References
|
||||
|
||||
- Signal Screen Lock (per-app opt-in, single credential):
|
||||
https://support.signal.org/hc/en-us/articles/360007059572
|
||||
- WhatsApp Chat Lock (per-chat, single credential):
|
||||
https://about.fb.com/news/2023/05/whatsapp-chat-lock/
|
||||
- Ledger Live "auto-lock all tabs" — this plan's per-scope model is
|
||||
weaker than Ledger's app-wide lock; intentional (matches Signal +
|
||||
WhatsApp UX and the brainstorm's explicit rejection of an app-wide
|
||||
lock).
|
||||
|
||||
### Related Work
|
||||
|
||||
- Messaging privacy lock plan (parent):
|
||||
`docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md`
|
||||
- Desktop wallet + zapping (defines the surface being gated): memory
|
||||
pointer *"Desktop Wallet & Zapping"* — branch
|
||||
`feat/desktop-wallet-zapping`
|
||||
- Account security hardening (concurrent work; `passwordHashed` storage
|
||||
lives in the same jvmAndroid source set that the account-security work
|
||||
touches — coordinate merge order):
|
||||
`docs/plans/2026-05-14-fix-account-security-hardening-plan.md`
|
||||
|
||||
## Open Questions — RESOLVED (2026-07-07)
|
||||
|
||||
1. **Merge order** — ✅ Solo PR stacked on `feat/desktop-privacy-lock`.
|
||||
2. **Lock granularity** — ✅ **Single master lock** protects both Messages
|
||||
and Wallet. No per-scope enable flag. Single `firstRunCardSeen` too.
|
||||
3. **Wallet first-run banner on empty NWC** — Show anyway (feature is
|
||||
valuable pre-connect).
|
||||
4. **Blur-on-unfocus for Wallet** — ✅ Blur **text nodes only** (balance
|
||||
amount, addresses, invoices). Cards / structural layout stay visible.
|
||||
Implementation: apply `Modifier.blur(16.dp)` at the Text-composable
|
||||
level for sensitive strings, not the LazyColumn wrapper.
|
||||
5. **"No password set" branch behaviour** — ✅ Deep-link to Settings →
|
||||
Privacy lock section (not just show the message).
|
||||
6. **Rename `redactionLevel` → `dmRedactionLevel`** — ✅ Yes. Kotlin-side
|
||||
only; persisted key `redaction_level_ordinal` stays for compatibility.
|
||||
7. **`LockScope` package** — ✅ Inside existing `privacylock/` package.
|
||||
8. **Cascade `passwordHashed → null` unsets `lockEnabled`** — ✅ Yes.
|
||||
Implement in `PreferencesPrivacyLockSettings.setPasswordHashed(null)`
|
||||
→ also `setLockEnabled(false)` atomically.
|
||||
Reference in New Issue
Block a user