mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
+121
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-89
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+61
@@ -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()
|
||||
}
|
||||
}
|
||||
+213
@@ -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"
|
||||
}
|
||||
+18
-170
@@ -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"
|
||||
}
|
||||
|
||||
+69
@@ -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()
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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,
|
||||
)
|
||||
|
||||
+49
@@ -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
|
||||
}
|
||||
+131
@@ -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")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
-10
@@ -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 -> {
|
||||
|
||||
+8
-7
@@ -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.",
|
||||
|
||||
+108
-95
@@ -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: _________
|
||||
Reference in New Issue
Block a user