feat(desktop): apply privacy lock to the Wallet column

Extends the messaging privacy lock to the Wallet deck column via the
same master `lockEnabled` flag (single toggle, single password) with
per-scope lock state so each route re-locks independently.

commons/ui/privacylock/
  LockScreen.kt        Shared internal composable (scope + copy)
  WalletLockGate.kt    Mirrors MessagesLockGate for scope=Wallet
  MessagesLockGate.kt  Shrunk to a 20-LOC wrapper delegating to LockScreen

desktopApp/security/
  DesktopLockScreen.kt         Shared password-input surface with optional
                               "No password set" deep-link (plan Q5).
  DesktopMessagesLockGate.kt   Now delegates to DesktopLockScreen
  DesktopWalletLockGate.kt     New; deep-links to Settings via
                               onNavigateToRelays when no password is set
  WalletFirstRunBanner.kt      Mirrors MessagesFirstRunBanner; both read
                               the single firstRunCardSeen flag (dismiss
                               once = dismissed everywhere)
  MessagesFirstRunBanner.kt    Copy updated: "Lock Messages and Wallet?"
  PrivacyLockBlurModifier.kt   Modifier.privacyLockBlurWhenUnfocused()
                               reads LocalWindowInfo.isWindowFocused;
                               applied to text nodes only (balance,
                               generated-invoice amount, QR code) — cards
                               and layout stay crisp (plan Q4).

desktopApp/ui/
  wallet/WalletColumnScreen.kt Inserts WalletFirstRunBanner at top;
                               wraps sensitive text with blur modifier.
  deck/DeckColumnContainer.kt  Wraps Wallet branch with
                               DesktopWalletLockGate; passes
                               onNavigateToRelays so the "No password"
                               branch deep-links to Settings.
  settings/PrivacyLockSettingsScreen.kt
                               Master-lock copy: "Enable privacy lock"
                               header; body mentions Messages AND Wallet
                               columns; auto-lock + caveat cards updated
                               to reference both routes.

Testing sheet: docs/plans/2026-07-07-wallet-lock-manual-testing.md
  12 manual scenarios covering cross-scope lockout, blur-on-unfocus,
  password-clear cascade, deep-link to Settings, and first-run banner
  parity across the two routes.

All existing PrivacyLockStateTest cases green + the 3 Wallet-reuse
tests from the previous commit. amethyst + desktopApp compile clean.
This commit is contained in:
nrobi144
2026-07-07 13:33:32 +03:00
parent d0daf786b1
commit 1e076c5cc2
14 changed files with 1036 additions and 422 deletions
@@ -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,41 +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.LockScope
import com.vitorpamplona.amethyst.commons.privacylock.LockState
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
import kotlinx.coroutines.launch
/**
* 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
@@ -62,8 +42,10 @@ 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 [PrivacyLockState.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) {
@@ -75,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 = lockStateFor(LockScope.Messages)
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()
}
}
@@ -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,32 @@
*/
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.LockScope
import com.vitorpamplona.amethyst.commons.privacylock.LockState
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
import kotlinx.coroutines.delay
/**
* 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) {
fun DesktopMessagesLockGate(
onOpenSettings: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
val lockState = lockStateFor(LockScope.Messages)
val current by lockState.state.collectAsState()
@@ -81,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 = lockStateFor(LockScope.Messages)
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()
}
}
@@ -93,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
}
@@ -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")
},
)
}
}
@@ -361,7 +361,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,
@@ -467,15 +469,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),
)
@@ -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),
)
@@ -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,
)
@@ -348,7 +348,7 @@ useful.
- [x] `./gradlew :commons:jvmTest --tests "*PrivacyLockState*"` green (all 5 existing + 3 new)
- [x] `./gradlew :desktopApp:compileKotlin` green (only rename+delegate calls updated)
- [ ] `./gradlew :amethyst:assembleDebug` green
- [x] `./gradlew :amethyst:compilePlayDebugKotlin` green
#### Phase 2 — Extract `LockScreen`, add `WalletLockGate`
@@ -373,9 +373,9 @@ the manual sheet.
**Acceptance:**
- [ ] `MessagesLockGate` public signature unchanged (no caller changes)
- [ ] `WalletLockGate` exposes the same `content: @Composable () -> Unit` lambda
- [ ] Extracted `LockScreen` renders the correct title/subtitle for whichever scope invokes it
- [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
@@ -443,13 +443,13 @@ entry).
**Acceptance:**
- [ ] Toggling `LockScope.Wallet` on in Settings → next Wallet column open shows the lock screen
- [ ] Correct password (verified against shared `passwordHashed`) unlocks
- [ ] Wrong password 5 times → lockout applies to **both** scopes (verified by observing Messages column also blocked)
- [ ] Leaving the Wallet column re-locks it
- [ ] Idle timer configured via shared `inactivityTimer` setting re-locks Wallet after N minutes
- [ ] Screen-capture protection engages while Wallet column visible (macOS: `NSWindowSharingNone`; Windows: `WDA_EXCLUDEFROMCAPTURE`)
- [ ] Blur-on-unfocus overlay renders over the Wallet column when the Amethyst window loses focus (16 dp radius, matches Messages)
- [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
@@ -482,10 +482,10 @@ Section header: "Privacy lock"
**Acceptance:**
- [ ] Toggling the master lock on with no password → prompts to set one (existing behaviour)
- [ ] Toggling the master lock on locks **both** Messages and Wallet on next entry
- [ ] Toggling the master lock off unlocks **both** immediately (transitions Locked → Disabled)
- [ ] Clearing the password auto-unsets the master toggle (Q8 cascade)
- [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
@@ -528,11 +528,11 @@ Other tasks:
**Acceptance:**
- [ ] `./gradlew :commons:jvmTest --tests "*privacylock*"` green
- [ ] `./gradlew :amethyst:assembleDebug` green
- [ ] `./gradlew :desktopApp:compileKotlin` green
- [ ] `./gradlew spotlessApply` clean
- [ ] Manual testing sheet passes (see §Documentation Plan)
- [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
@@ -633,35 +633,33 @@ in the shipped code:
### Functional
- [ ] `LockScope` enum shipped in `commons/commonMain`
- [ ] `PrivacyLockState` replaces `MessagesLockState`; each scope has an
- [x] `LockScope` enum shipped in `commons/commonMain`
- [x] `PrivacyLockState` replaces `MessagesLockState`; each scope has an
independent `state: StateFlow<LockState>` and idle-timer Job
- [ ] `PrivacyLockSettings.lockEnabled` and `firstRunCardSeen` are
scope-accessor functions
- [ ] Password / inactivity timer / redaction / failed-attempts / lockout
- [x] `PrivacyLockSettings.lockEnabled` and `firstRunCardSeen` stay single
master flags (per user Q2)
- [x] Password / inactivity timer / redaction / failed-attempts / lockout
remain device-global (shared)
- [ ] `MessagesLockGate` public signature unchanged; wired to
- [x] `MessagesLockGate` public signature unchanged; wired to
`lockStateFor(Messages)`
- [ ] `WalletLockGate` composable shipped in
- [x] `WalletLockGate` composable shipped in
`commons/.../ui/privacylock/`
- [ ] Shared `LockScreen(scope, title, subtitle, unlockLabel)` composable
- [x] Shared `LockScreen(scope, title, subtitle, unlockLabel)` composable
replaces the inlined lock screen inside MessagesLockGate; both
gates render it
- [ ] `DesktopMessagesLockGate` unchanged in behaviour; consumes the new
shared `LockScreen`
- [ ] `DesktopWalletLockGate` shipped; wraps `WalletColumnScreen` inside
- [x] `DesktopMessagesLockGate` refactored to consume shared
`DesktopLockScreen`; behaviour preserved
- [x] `DesktopWalletLockGate` shipped; wraps `WalletColumnScreen` inside
`DeckColumnContainer`
- [ ] `MessagesFirstRunBanner` unchanged in behaviour
- [ ] `WalletFirstRunBanner` shipped at top of `WalletColumnScreen`
- [ ] Settings screen renders two toggles + shared password subtree +
- [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
- [ ] Legacy prefs migration runs on first startup after upgrade — old
`lock_enabled` value moved to `lock_enabled_Messages`, then old key
removed; `schema_version = 2` written
- [ ] `applyWindowCaptureBlock(true)` engages when either lock is enabled
AND the corresponding route is visible
- [ ] Blur-on-unfocus overlay renders over Wallet column when window
loses focus AND `lockEnabled(Wallet) == true`
- [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
@@ -679,16 +677,16 @@ in the shipped code:
### Quality Gates
- [ ] `./gradlew :commons:jvmTest --tests "*privacylock*"` green (8 tests)
- [ ] `./gradlew :amethyst:assembleDebug` green
- [ ] `./gradlew :desktopApp:compileKotlin` green
- [ ] `./gradlew :desktopApp:packageDmg` green on macOS host
- [ ] `./gradlew :desktopApp:packageMsi` green on Windows host (best effort)
- [ ] `./gradlew :desktopApp:packageDeb` green on Linux host
- [ ] `./gradlew spotlessApply` clean
- [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
and signed off (post-merge task)
## Success Metrics
@@ -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: _________