Merge pull request #3495 from nrobi144/feat/desktop-wallet-privacy-lock

feat(desktop): apply the privacy lock to the Wallet column
This commit is contained in:
Vitor Pamplona
2026-07-08 12:52:54 -04:00
committed by GitHub
24 changed files with 2027 additions and 430 deletions
@@ -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 }
@@ -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)
@@ -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")
@@ -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,
@@ -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) {
@@ -0,0 +1,121 @@
/*
* 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.ui.privacylock
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.LockScope
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
import kotlinx.coroutines.launch
/**
* Shared lock-screen surface used by [MessagesLockGate] and [WalletLockGate].
* Runs the async [CredentialPrompter] path (biometric / OS credential on
* Android + iOS). Desktop platforms use a password-input inline lock screen
* instead — see `DesktopMessagesLockGate` / `DesktopWalletLockGate`.
*
* Kept `internal` so the only public entry points are the per-scope Gates.
*/
@Composable
internal fun LockScreen(
scope: LockScope,
title: String,
subtitle: String,
unlockLabel: String,
) {
val lockState = lockStateFor(scope)
val prompter = LocalCredentialPrompter.current
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(prompter) {
if (!prompter.available) {
lockState.onCredentialUnavailable()
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Box(modifier = Modifier.size(16.dp))
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Box(modifier = Modifier.size(8.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 320.dp),
)
Box(modifier = Modifier.size(32.dp))
Button(
onClick = {
coroutineScope.launch {
when (prompter.prompt()) {
PromptResult.Success -> lockState.onUnlockSuccess()
PromptResult.Unavailable -> lockState.onCredentialUnavailable()
else -> Unit
}
}
},
enabled = prompter.available,
) {
Text(text = unlockLabel)
}
}
}
}
@@ -20,40 +20,21 @@
*/
package com.vitorpamplona.amethyst.commons.ui.privacylock
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
/**
* Wraps the Messages route and gates entry behind the credential prompt.
*
* Branch selection happens SYNCHRONOUSLY in composition — no
* [LaunchedEffect] guard — so the chat content composable never enters
* composition while [LockState.Locked]. Closes the deep-link race
* (plan §Security Hardening H1).
* [androidx.compose.runtime.LaunchedEffect] guard — so the chat content
* composable never enters composition while [LockState.Locked]. Closes the
* deep-link race (plan §Security Hardening H1).
*
* The gate is an overlay, NOT a wrapper that disposes content. While
* locked, the [content] lambda is not invoked at all; on unlock, the
@@ -61,12 +42,14 @@ 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
* [DisposableEffect.onDispose] block, so navigating away locks immediately.
* The gate also fires
* [com.vitorpamplona.amethyst.commons.privacylock.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) {
@@ -74,70 +57,13 @@ fun MessagesLockGate(content: @Composable () -> Unit) {
}
when (current) {
is LockState.Locked -> LockScreen()
is LockState.Locked ->
LockScreen(
scope = LockScope.Messages,
title = "Messages locked",
subtitle = "Unlock to read or send messages.",
unlockLabel = "Unlock",
)
else -> content()
}
}
@Composable
private fun LockScreen() {
val lockState = LocalMessagesLockState.current
val prompter = LocalCredentialPrompter.current
val scope = rememberCoroutineScope()
LaunchedEffect(prompter) {
if (!prompter.available) {
lockState.onCredentialUnavailable()
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Box(modifier = Modifier.size(16.dp))
Text(
text = "Messages locked",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Box(modifier = Modifier.size(8.dp))
Text(
text = "Unlock to read or send messages",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 320.dp),
)
Box(modifier = Modifier.size(32.dp))
Button(
onClick = {
scope.launch {
when (prompter.prompt()) {
PromptResult.Success -> lockState.onUnlockSuccess()
PromptResult.Unavailable -> lockState.onCredentialUnavailable()
else -> Unit
}
}
},
enabled = prompter.available,
) {
Text(text = "Unlock")
}
}
}
}
@@ -0,0 +1,61 @@
/*
* 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.ui.privacylock
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
import com.vitorpamplona.amethyst.commons.privacylock.LockState
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
/**
* Wraps the Wallet route and gates entry behind the credential prompt.
*
* Behaviour mirrors [MessagesLockGate] — see that composable's KDoc for the
* deep-link race, draft persistence, and leave-route semantics. Only the
* [LockScope] and the lock-screen copy differ.
*
* Desktop apps use the platform-specific `DesktopWalletLockGate` (password
* input inline, no async CredentialPrompter round-trip); Android + iOS
* front ends use this composable directly.
*/
@Composable
fun WalletLockGate(content: @Composable () -> Unit) {
val lockState = lockStateFor(LockScope.Wallet)
val current by lockState.state.collectAsState()
DisposableEffect(lockState) {
onDispose { lockState.onLeaveRoute() }
}
when (current) {
is LockState.Locked ->
LockScreen(
scope = LockScope.Wallet,
title = "Wallet locked",
subtitle = "Unlock to see your balance and send or receive sats.",
unlockLabel = "Unlock",
)
else -> content()
}
}
@@ -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)
}
}
@@ -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) {
@@ -699,14 +699,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(
@@ -0,0 +1,213 @@
/*
* 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.desktop.security
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
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.LockScope
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
import kotlinx.coroutines.delay
/**
* Shared desktop lock-screen surface. Used by
* [DesktopMessagesLockGate] and [DesktopWalletLockGate] with per-scope
* copy passed in as [title] and [subtitle].
*
* When no password is set — an edge case that only happens if the user
* cleared the password while a lock toggle was still active — the screen
* offers [onNoPasswordAction] (typically deep-linking to Settings so the
* user can set a new one). Falls back to a "Disable lock" button when
* [onNoPasswordAction] is null.
*
* Enforces exponential backoff after repeated failed attempts (5 fails →
* 30 s, doubling, capped at 5 min). Backoff state persists across restarts
* and is shared across every gated scope (anti-brute-force property).
*/
@Composable
internal fun DesktopLockScreen(
scope: LockScope,
title: String,
subtitle: String,
onNoPasswordAction: (() -> Unit)? = null,
noPasswordButtonLabel: String = "Open Settings",
) {
val lockState = lockStateFor(scope)
val settings = LocalPrivacyLockSettings.current
val stored by settings.passwordHashed.collectAsState()
val lockedUntil by settings.lockedUntilEpochMs.collectAsState()
var input by remember { mutableStateOf("") }
var showError by remember { mutableStateOf(false) }
var remainingMs by remember { mutableStateOf(lockoutRemainingMs(lockedUntil)) }
LaunchedEffect(lockedUntil) {
while (true) {
val r = lockoutRemainingMs(lockedUntil)
remainingMs = r
if (r <= 0) break
delay(500)
}
}
val submit: () -> Unit = {
if (remainingMs <= 0) {
val ok = stored?.let { PasswordHasher.verify(input.toCharArray(), it) } == true
if (ok) {
input = ""
showError = false
lockState.onUnlockSuccess()
} else {
showError = true
lockState.onFailedUnlockAttempt(System.currentTimeMillis())
}
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Box(modifier = Modifier.size(16.dp))
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Box(modifier = Modifier.size(8.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 320.dp),
)
Box(modifier = Modifier.size(24.dp))
if (stored == null) {
Text(
text = "No password is set yet.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 320.dp),
)
Box(modifier = Modifier.size(16.dp))
if (onNoPasswordAction != null) {
Button(onClick = onNoPasswordAction) {
Text(noPasswordButtonLabel)
}
} else {
Button(onClick = { lockState.onCredentialUnavailable() }) {
Text("Disable lock")
}
}
} else {
OutlinedTextField(
value = input,
onValueChange = {
input = it
showError = false
},
label = { Text("Password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
enabled = remainingMs <= 0,
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { submit() }),
isError = showError,
supportingText =
when {
remainingMs > 0 -> {
{ Text("Too many attempts. Try again in ${formatLockoutCountdown(remainingMs)}.") }
}
showError -> {
{ Text("Wrong password") }
}
else -> null
},
modifier = Modifier.widthIn(max = 320.dp),
)
Box(modifier = Modifier.size(16.dp))
Button(
onClick = submit,
enabled = input.isNotEmpty() && remainingMs <= 0,
) {
Text("Unlock")
}
}
}
}
}
private fun lockoutRemainingMs(untilEpochMs: Long?): Long {
val until = untilEpochMs ?: return 0
val diff = until - System.currentTimeMillis()
return if (diff > 0) diff else 0
}
private fun formatLockoutCountdown(millis: Long): String {
val totalSeconds = (millis + 999) / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return if (minutes > 0) "${minutes}m ${seconds}s" else "${seconds}s"
}
@@ -20,59 +20,33 @@
*/
package com.vitorpamplona.amethyst.desktop.security
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
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 kotlinx.coroutines.delay
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
/**
* Desktop equivalent of `MessagesLockGate`. Uses password verification
* synchronously — no async CredentialPrompter round-trip needed.
*
* Renders content when Disabled / Unlocked; renders an inline password
* input when Locked. If no password has been set, prompts the user to set
* one first (this fires from the settings toggle in normal flow, so the
* fallback exists only as a safety net).
* Desktop equivalent of `MessagesLockGate`. Uses synchronous password
* verification (no async CredentialPrompter round-trip needed) via the
* shared [DesktopLockScreen] surface.
*
* Branch selection is SYNCHRONOUS in composition — no LaunchedEffect
* guard — closing the deep-link race (plan §Security Hardening H1).
*
* Enforces exponential backoff after repeated failed attempts (5 fails →
* 30 s, doubling, capped at 5 min). Backoff state persists across restarts.
* @param onOpenSettings optional deep-link into the Settings screen used
* when the user cleared the master password while the Messages lock was
* still toggled on. When null, the fallback "Disable lock" button is
* offered instead.
*/
@Composable
fun DesktopMessagesLockGate(content: @Composable () -> Unit) {
val lockState = LocalMessagesLockState.current
fun DesktopMessagesLockGate(
onOpenSettings: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
val lockState = lockStateFor(LockScope.Messages)
val current by lockState.state.collectAsState()
DisposableEffect(lockState) {
@@ -80,138 +54,13 @@ fun DesktopMessagesLockGate(content: @Composable () -> Unit) {
}
when (current) {
is LockState.Locked -> DesktopLockScreen()
is LockState.Locked ->
DesktopLockScreen(
scope = LockScope.Messages,
title = "Messages locked",
subtitle = "Enter your privacy-lock password to view messages.",
onNoPasswordAction = onOpenSettings,
)
else -> content()
}
}
@Composable
private fun DesktopLockScreen() {
val lockState = LocalMessagesLockState.current
val settings = LocalPrivacyLockSettings.current
val stored by settings.passwordHashed.collectAsState()
val lockedUntil by settings.lockedUntilEpochMs.collectAsState()
var input by remember { mutableStateOf("") }
var showError by remember { mutableStateOf(false) }
var remainingMs by remember { mutableStateOf(lockoutRemaining(lockedUntil)) }
LaunchedEffect(lockedUntil) {
while (true) {
val r = lockoutRemaining(lockedUntil)
remainingMs = r
if (r <= 0) break
delay(500)
}
}
val submit: () -> Unit = {
if (remainingMs <= 0) {
val ok = stored?.let { PasswordHasher.verify(input.toCharArray(), it) } == true
if (ok) {
input = ""
showError = false
lockState.onUnlockSuccess()
} else {
showError = true
lockState.onFailedUnlockAttempt(System.currentTimeMillis())
}
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Box(modifier = Modifier.size(16.dp))
Text(
text = "Messages locked",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Box(modifier = Modifier.size(8.dp))
Text(
text = "Enter your privacy-lock password to view messages",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 320.dp),
)
Box(modifier = Modifier.size(24.dp))
if (stored == null) {
Text(
text = "No password is set yet. Open Settings → Privacy lock to set one.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 320.dp),
)
Box(modifier = Modifier.size(16.dp))
Button(onClick = { lockState.onCredentialUnavailable() }) {
Text("Disable lock")
}
} else {
OutlinedTextField(
value = input,
onValueChange = {
input = it
showError = false
},
label = { Text("Password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
enabled = remainingMs <= 0,
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { submit() }),
isError = showError,
supportingText =
when {
remainingMs > 0 -> {
{ Text("Too many attempts. Try again in ${formatCountdown(remainingMs)}.") }
}
showError -> {
{ Text("Wrong password") }
}
else -> null
},
modifier = Modifier.widthIn(max = 320.dp),
)
Box(modifier = Modifier.size(16.dp))
Button(
onClick = submit,
enabled = input.isNotEmpty() && remainingMs <= 0,
) {
Text("Unlock")
}
}
}
}
}
private fun lockoutRemaining(untilEpochMs: Long?): Long {
val until = untilEpochMs ?: return 0
val diff = until - System.currentTimeMillis()
return if (diff > 0) diff else 0
}
private fun formatCountdown(millis: Long): String {
val totalSeconds = (millis + 999) / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return if (minutes > 0) "${minutes}m ${seconds}s" else "${seconds}s"
}
@@ -0,0 +1,69 @@
/*
* 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.desktop.security
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
import com.vitorpamplona.amethyst.commons.privacylock.LockState
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
/**
* Desktop equivalent of `WalletLockGate`. Mirrors [DesktopMessagesLockGate]
* behaviour with wallet-scoped copy.
*
* Reads its lock state from `lockStateFor(LockScope.Wallet)` — a separate
* instance from Messages, so an unlocked Messages session does NOT
* auto-unlock the Wallet, and vice-versa. Both scopes share the same
* password, failed-attempt counter, and lockout schedule via the
* ambient [PrivacyLockSettings].
*
* @param onOpenSettings optional deep-link into the Settings screen used
* when the user cleared the master password while the Wallet lock was
* still toggled on. Per plan Q5: prefer deep-link over the plain
* "Disable lock" fallback so the user can immediately set a new
* password rather than blindly disabling the feature.
*/
@Composable
fun DesktopWalletLockGate(
onOpenSettings: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
val lockState = lockStateFor(LockScope.Wallet)
val current by lockState.state.collectAsState()
DisposableEffect(lockState) {
onDispose { lockState.onLeaveRoute() }
}
when (current) {
is LockState.Locked ->
DesktopLockScreen(
scope = LockScope.Wallet,
title = "Wallet locked",
subtitle = "Enter your privacy-lock password to view the wallet.",
onNoPasswordAction = onOpenSettings,
)
else -> content()
}
}
@@ -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")
@@ -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) }
@@ -92,11 +93,13 @@ fun MessagesFirstRunBanner(onSaved: (String) -> Unit = {}) {
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Lock the Messages tab?",
text = "Lock Messages and Wallet?",
style = MaterialTheme.typography.titleSmall,
)
Text(
text = "Require a password before Messages shows. Feed and profile stay open.",
text =
"Require your password before the Messages and Wallet columns show. " +
"Feed, profile, and search stay open.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -0,0 +1,49 @@
/*
* 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.desktop.security
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.dp
/**
* Blur the modified node when the privacy lock is enabled AND the desktop
* window is currently unfocused.
*
* Per plan Q4: applies only to sensitive text nodes (balance amount, invoice
* strings, addresses, NWC URIs, transaction memos) — NOT to card
* containers, icons, or layout structure. This preserves the visual
* skeleton for a passer-by while hiding the meaningful values.
*
* Uses Compose Desktop's built-in [LocalWindowInfo.isWindowFocused] — no
* Swing WindowListener plumbing required.
*/
@Composable
fun Modifier.privacyLockBlurWhenUnfocused(): Modifier {
val settings = LocalPrivacyLockSettings.current
val enabled by settings.lockEnabled.collectAsState()
val focused = LocalWindowInfo.current.isWindowFocused
return if (enabled && !focused) this.then(Modifier.blur(16.dp)) else this
}
@@ -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()
@@ -0,0 +1,131 @@
/*
* 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.desktop.security
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.LockScope
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
/**
* One-time discovery banner at the top of the Desktop Wallet column.
* Mirrors [MessagesFirstRunBanner] — same state (`firstRunCardSeen`) and
* same enable-with-password flow, only the visual anchor changes so users
* who never open the Messages tab still learn about the feature.
*
* Because the master `firstRunCardSeen` flag is shared, dismissing this
* banner also hides the Messages banner (and vice versa). Enabling the
* lock from either banner locks both routes.
*/
@Composable
fun WalletFirstRunBanner(onSaved: (String) -> Unit = {}) {
val settings = LocalPrivacyLockSettings.current
val lockState = lockStateFor(LockScope.Wallet)
val enabled by settings.lockEnabled.collectAsState()
val seen by settings.firstRunCardSeen.collectAsState()
var showDialog by remember { mutableStateOf(false) }
AnimatedVisibility(
visible = !enabled && !seen,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Lock the Wallet and Messages?",
style = MaterialTheme.typography.titleSmall,
)
Text(
text =
"Require your password before the Wallet and Messages columns show. " +
"Feed, profile, and search stay open.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = { settings.setFirstRunCardSeen(true) }) {
Text("Not now")
}
Button(onClick = { showDialog = true }) {
Text("Enable")
}
}
}
}
if (showDialog) {
SetPasswordDialog(
existingHash = null,
onDismiss = { showDialog = false },
onConfirm = { newHash ->
settings.setPasswordHashed(newHash)
settings.setLockEnabled(true)
settings.setFirstRunCardSeen(true)
// Keep the user Unlocked — don't kick them to the lock screen
// right after they just entered the password.
lockState.onUnlockSuccess()
showDialog = false
onSaved("Privacy lock enabled")
},
)
}
}
@@ -394,7 +394,9 @@ internal fun RootContent(
}
DeckColumnType.Messages -> {
com.vitorpamplona.amethyst.desktop.security.DesktopMessagesLockGate {
com.vitorpamplona.amethyst.desktop.security.DesktopMessagesLockGate(
onOpenSettings = onNavigateToRelays,
) {
DesktopMessagesScreen(
account = iAccount,
cacheProvider = localCache,
@@ -500,15 +502,19 @@ internal fun RootContent(
}
DeckColumnType.Wallet -> {
com.vitorpamplona.amethyst.desktop.ui.wallet.WalletColumnScreen(
account = account,
accountManager = accountManager,
relayManager = relayManager,
localCache = localCache,
nwcConnection = nwcConnection,
appScope = appScope,
onZapFeedback = onZapFeedback,
)
com.vitorpamplona.amethyst.desktop.security.DesktopWalletLockGate(
onOpenSettings = onNavigateToRelays,
) {
com.vitorpamplona.amethyst.desktop.ui.wallet.WalletColumnScreen(
account = account,
accountManager = accountManager,
relayManager = relayManager,
localCache = localCache,
nwcConnection = nwcConnection,
appScope = appScope,
onZapFeedback = onZapFeedback,
)
}
}
DeckColumnType.Relays -> {
@@ -102,7 +102,7 @@ private fun LockToggleCard(
var showRemovePassword by remember { mutableStateOf(false) }
var pendingEnable by remember { mutableStateOf(false) }
SettingsCard(title = "Lock the Messages tab") {
SettingsCard(title = "Enable privacy lock") {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -110,8 +110,8 @@ private fun LockToggleCard(
) {
Text(
text =
"Require a password before the Messages column shows. " +
"The rest of the app stays open.",
"Require your password before the Messages and Wallet columns show. " +
"Feed, profile, and search stay open.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
@@ -195,7 +195,7 @@ private fun InactivityCard(settings: PrivacyLockSettings) {
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Re-lock Messages after this much inactivity.",
text = "Re-lock Messages and Wallet after this much inactivity.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
@@ -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") {
@@ -232,8 +232,8 @@ private fun RedactionCard(settings: PrivacyLockSettings) {
) {
Text(
text =
"When lock is on, DM notifications hide sender + message. " +
"Change to Full to show them.",
"When the lock is on, DM notifications hide sender + message. " +
"Change to Full to show them. Wallet has no notifications yet.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
@@ -245,7 +245,7 @@ private fun RedactionCard(settings: PrivacyLockSettings) {
DropdownMenuItem(
text = { Text(entry.label()) },
onClick = {
settings.setRedactionLevel(entry)
settings.setDmRedactionLevel(entry)
expanded = false
},
)
@@ -276,7 +276,8 @@ private fun LimitationsCard() {
)
Text(
text =
"This lock hides the Messages column on an unattended device. " +
"This lock hides the Messages and Wallet columns on an unattended device. " +
"Wallet balance and invoice text blur when the window loses focus. " +
"It does NOT protect against: filesystem access, memory dumps, " +
"attached debuggers, or screen-recording apps you've granted access. " +
"Your Nostr private key is still stored as it is today.",
@@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler
import com.vitorpamplona.amethyst.desktop.security.privacyLockBlurWhenUnfocused
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.auth.QrCodeCanvas
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
@@ -134,107 +135,112 @@ fun WalletColumnScreen(
}
}
Box(modifier = Modifier.fillMaxSize()) {
if (nwcConnection == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
NoWalletContent(onConnect = { showConnectDialog = true })
}
} else {
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier.widthIn(max = 360.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
Column(modifier = Modifier.fillMaxSize()) {
com.vitorpamplona.amethyst.desktop.security.WalletFirstRunBanner(
onSaved = { message -> scope.launch { snackbarHostState.showSnackbar(message) } },
)
Box(modifier = Modifier.fillMaxSize().weight(1f)) {
if (nwcConnection == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
WalletBalanceCard(
balanceSats = balanceSats,
isLoading = isLoadingBalance,
onRefresh = {
isLoadingBalance = true
scope.launch {
when (val result = paymentHandler.getBalance(nwcConnection)) {
is NwcPaymentHandler.BalanceResult.Success -> {
balanceSats = result.balanceMsats / 1000
}
is NwcPaymentHandler.BalanceResult.Error -> {
snackbarHostState.showSnackbar("Balance error: ${result.message}")
}
is NwcPaymentHandler.BalanceResult.Timeout -> {
snackbarHostState.showSnackbar("Balance request timed out")
}
}
isLoadingBalance = false
}
},
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
NoWalletContent(onConnect = { showConnectDialog = true })
}
} else {
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier.widthIn(max = 360.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Button(
onClick = { showSendDialog = true },
modifier = Modifier.weight(1f),
WalletBalanceCard(
balanceSats = balanceSats,
isLoading = isLoadingBalance,
onRefresh = {
isLoadingBalance = true
scope.launch {
when (val result = paymentHandler.getBalance(nwcConnection)) {
is NwcPaymentHandler.BalanceResult.Success -> {
balanceSats = result.balanceMsats / 1000
}
is NwcPaymentHandler.BalanceResult.Error -> {
snackbarHostState.showSnackbar("Balance error: ${result.message}")
}
is NwcPaymentHandler.BalanceResult.Timeout -> {
snackbarHostState.showSnackbar("Balance request timed out")
}
}
isLoadingBalance = false
}
},
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(symbol = MaterialSymbols.ArrowUpward, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Send")
Button(
onClick = { showSendDialog = true },
modifier = Modifier.weight(1f),
) {
Icon(symbol = MaterialSymbols.ArrowUpward, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Send")
}
OutlinedButton(
onClick = { showReceiveDialog = true },
modifier = Modifier.weight(1f),
) {
Icon(symbol = MaterialSymbols.ArrowDownward, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Receive")
}
}
OutlinedButton(
onClick = { showReceiveDialog = true },
modifier = Modifier.weight(1f),
) {
Icon(symbol = MaterialSymbols.ArrowDownward, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Receive")
HorizontalDivider()
Text(
text = "Connected Wallet",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Relay: ${nwcConnection.relayUri}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Wallet: ${nwcConnection.pubKeyHex.take(8)}...${nwcConnection.pubKeyHex.takeLast(8)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = {
appScope.launch {
accountManager.clearNwcConnection(account.npub)
balanceSats = null
}
}) {
Text("Disconnect", color = MaterialTheme.colorScheme.error)
}
}
HorizontalDivider()
Text(
text = "Connected Wallet",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Relay: ${nwcConnection.relayUri}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Wallet: ${nwcConnection.pubKeyHex.take(8)}...${nwcConnection.pubKeyHex.takeLast(8)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = {
appScope.launch {
accountManager.clearNwcConnection(account.npub)
balanceSats = null
}
}) {
Text("Disconnect", color = MaterialTheme.colorScheme.error)
}
}
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
// -- Dialogs --
@@ -369,6 +375,7 @@ private fun WalletBalanceCard(
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.privacyLockBlurWhenUnfocused(),
)
} else {
Text(
@@ -875,7 +882,10 @@ private fun ReceiveDialog(
"${formatSats(amount.toLongOrNull() ?: 0)} sats",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.CenterHorizontally),
modifier =
Modifier
.align(Alignment.CenterHorizontally)
.privacyLockBlurWhenUnfocused(),
)
if (description.isNotBlank()) {
Spacer(Modifier.height(4.dp))
@@ -889,10 +899,13 @@ private fun ReceiveDialog(
Spacer(Modifier.height(16.dp))
// QR code
// QR code — sensitive, blur when window unfocused
QrCodeCanvas(
data = generatedInvoice!!,
modifier = Modifier.align(Alignment.CenterHorizontally),
modifier =
Modifier
.align(Alignment.CenterHorizontally)
.privacyLockBlurWhenUnfocused(),
size = 240.dp,
)
@@ -0,0 +1,842 @@
---
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)
- [x] `./gradlew :amethyst:compilePlayDebugKotlin` 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:**
- [x] `MessagesLockGate` public signature unchanged (no caller changes)
- [x] `WalletLockGate` exposes the same `content: @Composable () -> Unit` lambda
- [x] 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:**
- [x] Toggling the master lock on in Settings → next Wallet column open shows the lock screen
- [x] Correct password (verified against shared `passwordHashed`) unlocks
- [x] Wrong password 5 times → lockout applies to **both** scopes (shared failed-attempt counter — covered by PrivacyLockStateTest.failed_unlock_counter_is_shared_across_scopes)
- [x] Leaving the Wallet column re-locks it (DisposableEffect.onDispose → PrivacyLockState.onLeaveRoute)
- [x] Idle timer configured via shared `inactivityTimer` setting re-locks Wallet after N minutes
- [ ] Screen-capture protection engages while Wallet column visible — deferred, no native shim shipped on parent messaging-privacy-lock branch either
- [x] Blur-on-unfocus for sensitive text (balance + generated invoice + QR) when the Amethyst window loses focus (16 dp radius via `Modifier.privacyLockBlurWhenUnfocused()`)
#### 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:**
- [x] Toggling the master lock on with no password → prompts to set one (existing behaviour)
- [x] Toggling the master lock on locks **both** Messages and Wallet on next entry (single settings flag drives both PrivacyLockState instances)
- [x] Toggling the master lock off unlocks **both** immediately (transitions Locked → Disabled — covered by toggling_lock_off_transitions_to_disabled test)
- [x] Clearing the password auto-unsets the master toggle (Q8 cascade — covered by clearing_password_cascades_to_disable_the_master_lock test)
#### 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:**
- [x] `./gradlew :commons:jvmTest --tests "*PrivacyLockState*"` green
- [x] `./gradlew :amethyst:compilePlayDebugKotlin` green
- [x] `./gradlew :desktopApp:compileKotlin` green
- [x] `./gradlew spotlessApply` clean
- [ ] Manual testing sheet passes (post-merge task)
## 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
- [x] `LockScope` enum shipped in `commons/commonMain`
- [x] `PrivacyLockState` replaces `MessagesLockState`; each scope has an
independent `state: StateFlow<LockState>` and idle-timer Job
- [x] `PrivacyLockSettings.lockEnabled` and `firstRunCardSeen` stay single
master flags (per user Q2)
- [x] Password / inactivity timer / redaction / failed-attempts / lockout
remain device-global (shared)
- [x] `MessagesLockGate` public signature unchanged; wired to
`lockStateFor(Messages)`
- [x] `WalletLockGate` composable shipped in
`commons/.../ui/privacylock/`
- [x] Shared `LockScreen(scope, title, subtitle, unlockLabel)` composable
replaces the inlined lock screen inside MessagesLockGate; both
gates render it
- [x] `DesktopMessagesLockGate` refactored to consume shared
`DesktopLockScreen`; behaviour preserved
- [x] `DesktopWalletLockGate` shipped; wraps `WalletColumnScreen` inside
`DeckColumnContainer`
- [x] `MessagesFirstRunBanner` copy updated to reference both Messages and Wallet
- [x] `WalletFirstRunBanner` shipped at top of `WalletColumnScreen`
- [x] Settings screen renders single master toggle + shared password subtree +
shared inactivity timer + Messages-only redaction card
- [x] No prefs migration required (master-lock design keeps existing keys as-is)
- [ ] `applyWindowCaptureBlock(true)` engages when the master lock is
enabled — **deferred** (no native shim shipped on parent branch)
- [x] Blur-on-unfocus for sensitive text (balance, generated invoice,
QR) when window loses focus AND `lockEnabled == 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
- [x] `./gradlew :commons:jvmTest --tests "*PrivacyLockState*"` green (16 tests, 3 new)
- [x] `./gradlew :amethyst:compilePlayDebugKotlin` green
- [x] `./gradlew :desktopApp:compileKotlin` green
- [ ] `./gradlew :desktopApp:packageDmg` green on macOS host (packaging validation deferred to reviewer)
- [ ] `./gradlew :desktopApp:packageMsi` green on Windows host (packaging validation deferred to reviewer)
- [ ] `./gradlew :desktopApp:packageDeb` green on Linux host (packaging validation deferred to reviewer)
- [x] `./gradlew spotlessApply` clean
- [ ] Manual testing sheet
(`docs/plans/2026-07-07-wallet-lock-manual-testing.md`) executed
and signed off (post-merge task)
## 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.
@@ -0,0 +1,177 @@
---
title: Wallet Privacy Lock — Manual Testing Sheet
type: test
status: active
date: 2026-07-07
plan: docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md
---
# Wallet Privacy Lock — Manual Testing Sheet
Companion to the messaging-privacy-lock testing sheet — assumes the Messages
gate has already been validated by that document. Focus here is on the
Wallet gate and cross-scope behaviour introduced by the single master lock.
## Setup
- Fresh Amethyst Desktop install on a supported OS (macOS 14+, Windows 11,
Ubuntu 22.04+).
- Log in with an account that has an NWC-connected wallet (Alby or a
self-hosted LNDHUB will do).
- Confirm messaging-privacy-lock testing sheet has been executed and green.
- Start with `lockEnabled = false` (default).
## T1 — First-run banner (Wallet)
**Steps.** Open the Wallet column with the master lock disabled and never
seen the first-run banner before.
**Expected.** Banner *"Lock the Wallet and Messages?"* appears at the top
of the Wallet column with Enable + Not now buttons. Dismissing with **Not
now** hides the banner permanently; opening Messages afterwards shows no
banner either (single `firstRunCardSeen` flag).
**Failure.** Banner reappears after Not now, or Messages banner shows
independently.
## T2 — Enable via Wallet banner
**Steps.** Fresh install. Open Wallet. Tap **Enable** on the banner. Set a
password.
**Expected.** After the password dialog closes, the Wallet column is
Unlocked and immediately usable (no lock screen flash). Navigating to
Messages shows the Messages lock screen — because the Messages instance is
freshly Locked. Password unlocks it.
**Failure.** Wallet flashes lock screen after enabling; Messages does not
lock.
## T3 — Cross-scope lockout (brute force)
**Steps.** Lock enabled. On the Wallet lock screen, enter 5 wrong
passwords in a row. Then navigate to Messages.
**Expected.** Both Wallet AND Messages show *"Too many attempts. Try
again in 30s."* Password field disabled on both. Countdown updates
every ~0.5s.
**Failure.** Only Wallet locks out; Messages accepts input.
## T4 — Balance and invoice blur on window unfocus
**Steps.** Lock enabled, Wallet Unlocked, wallet connected. Note the
balance amount. Now click a browser or other app to defocus the Amethyst
window.
**Expected.** The balance amount text ("N sats") blurs; the "Balance"
label, "Refresh" button, and card outline stay crisp. Refocus Amethyst
→ blur clears immediately.
**Also.** Open Receive dialog, generate an invoice. Defocus the window.
Amount text + QR code blur. Refocus → clear. Note that Send-dialog input
fields are NOT blurred (users need to type into them).
**Failure.** Whole card blurs, or blur persists after refocus, or blur
never fires.
## T5 — Leave-route re-lock
**Steps.** Lock enabled, Wallet Unlocked. Navigate away from the Wallet
column (Home Feed or Messages).
**Expected.** Returning to Wallet shows the lock screen. Messages state
is not affected (if it was Unlocked, it stays Unlocked).
**Failure.** Wallet stays Unlocked, or Messages is force-locked too.
## T6 — Idle timer re-lock (Wallet in foreground)
**Steps.** Lock enabled. Set inactivity timer to 1 minute. Unlock Wallet.
Do not interact with the app for 60+ seconds.
**Expected.** After ~1 minute, Wallet column transitions to Locked; the
lock screen shows. Password unlocks it.
**Failure.** Wallet stays Unlocked past the timer; timer only applies to
Messages.
## T7 — Password change → shared re-verify
**Steps.** Enable lock. Set password `A`. Change password to `B` from
Settings. Unlock Wallet — should accept `B`, reject `A`.
**Expected.** Only the new password unlocks. Both scopes accept `B`.
**Failure.** Wallet still accepts old password.
## T8 — Password clear → cascade
**Steps.** Enable lock. Set password. Navigate to Settings → Privacy
lock. Click *"Remove password"* and confirm with the current password.
**Expected.** Master toggle turns off automatically. Both Messages and
Wallet transition to Disabled (no lock screen). Navigation into either
route shows content without a prompt.
**Failure.** Master toggle stays on with no password (invalid state).
## T9 — Deep-link to Settings from Wallet "No password set" branch
**Steps.** Contrived-state edge case: enable lock with password. Then
manually delete the `password_hashed` java.util.prefs key while the app is
running (via a debugger or a second Settings tab). Navigate to Wallet.
**Expected.** Lock screen renders *"No password is set yet."* with an
**Open Settings** button. Tapping it navigates to the Settings tab
(via `onNavigateToRelays`). User can re-set the password there.
**Failure.** Wallet shows *"Disable lock"* fallback instead of the deep-link
(that's the Messages behaviour, but per plan Q5 Wallet should deep-link).
## T10 — First-time enable via Settings (Wallet-only user)
**Steps.** User who never opens Messages. Enable the lock via Settings
directly (not via a banner). Set password. Navigate to Wallet.
**Expected.** Wallet gate fires normally. Password unlocks. Master toggle
now enables both scopes but Messages is never visited so no visible
difference.
**Failure.** Wallet not gated.
## T11 — Settings copy sanity
**Steps.** Navigate to Settings → Privacy lock section.
**Expected.**
- Card header reads *"Enable privacy lock"* (not *"Lock the Messages
tab"*).
- Body reads *"Require your password before the Messages and Wallet
columns show. Feed, profile, and search stay open."*
- Auto-lock card says *"Re-lock Messages and Wallet after this much
inactivity."*
- DM notification card mentions *"Wallet has no notifications yet."*
- Caveats card mentions *"the Messages and Wallet columns"*.
**Failure.** Any card still says *"Messages"* only.
## T12 — Rapid navigation between locked scopes
**Steps.** Lock enabled. Both scopes Locked. Rapidly click Messages then
Wallet then Messages in the sidebar (< 200 ms between clicks).
**Expected.** Each route shows its own lock screen with correct scope-
specific title. No flicker to content. No state cross-talk (unlocking
one scope's screen halfway shouldn't unlock the other).
**Failure.** Wrong title on the wrong scope, or content flashes during
navigation.
## Sign-off
- [ ] All 12 tests passed
- Tester: _________
- OS + Amethyst build: _________
- Date: _________
- Notes: _________