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