Extends the messaging privacy lock to the Wallet deck column via the
same master `lockEnabled` flag (single toggle, single password) with
per-scope lock state so each route re-locks independently.
commons/ui/privacylock/
LockScreen.kt Shared internal composable (scope + copy)
WalletLockGate.kt Mirrors MessagesLockGate for scope=Wallet
MessagesLockGate.kt Shrunk to a 20-LOC wrapper delegating to LockScreen
desktopApp/security/
DesktopLockScreen.kt Shared password-input surface with optional
"No password set" deep-link (plan Q5).
DesktopMessagesLockGate.kt Now delegates to DesktopLockScreen
DesktopWalletLockGate.kt New; deep-links to Settings via
onNavigateToRelays when no password is set
WalletFirstRunBanner.kt Mirrors MessagesFirstRunBanner; both read
the single firstRunCardSeen flag (dismiss
once = dismissed everywhere)
MessagesFirstRunBanner.kt Copy updated: "Lock Messages and Wallet?"
PrivacyLockBlurModifier.kt Modifier.privacyLockBlurWhenUnfocused()
reads LocalWindowInfo.isWindowFocused;
applied to text nodes only (balance,
generated-invoice amount, QR code) — cards
and layout stay crisp (plan Q4).
desktopApp/ui/
wallet/WalletColumnScreen.kt Inserts WalletFirstRunBanner at top;
wraps sensitive text with blur modifier.
deck/DeckColumnContainer.kt Wraps Wallet branch with
DesktopWalletLockGate; passes
onNavigateToRelays so the "No password"
branch deep-links to Settings.
settings/PrivacyLockSettingsScreen.kt
Master-lock copy: "Enable privacy lock"
header; body mentions Messages AND Wallet
columns; auto-lock + caveat cards updated
to reference both routes.
Testing sheet: docs/plans/2026-07-07-wallet-lock-manual-testing.md
12 manual scenarios covering cross-scope lockout, blur-on-unfocus,
password-clear cascade, deep-link to Settings, and first-run banner
parity across the two routes.
All existing PrivacyLockStateTest cases green + the 3 Wallet-reuse
tests from the previous commit. amethyst + desktopApp compile clean.
46 KiB
title, type, status, date, origin, depends_on
| title | type | status | date | origin | depends_on |
|---|---|---|---|---|---|
| Reuse Messaging Privacy Lock on Wallet | feat | active | 2026-07-07 | docs/brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md | 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
lockEnabledtoggle protects both Messages and Wallet routes (per user decision — not per-scope enable). - Single
firstRunCardSeenflag (dismiss once = dismissed everywhere). LockScopeenum 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
MessagesLockGateplumbing 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
LockScopeenum (Messages,Wallet) — the single new type.PrivacyLockState(renamed fromMessagesLockState) parameterised byLockScope; one instance per scope, both provided via CompositionLocal at the App root.PrivacyLockSettingsgains per-scopelockEnabledandfirstRunCardSeen. Password, inactivity timer, redaction level, failed-attempts, and lockout stay device-global.- Shared
LockScreen()composable takes a scope; renders scope-aware title- subtitle strings.
DesktopWalletLockGate— 30-line wrapper mirroringDesktopMessagesLockGate; also drivesapplyWindowCaptureBlockand blur-on-unfocus overlay while the Wallet column is visible.WalletFirstRunBanner— inline card at top of Wallet column, mirroringMessagesFirstRunBanner.PrivacyLockSettingsScreengets a second card ("Lock the Wallet tab") + shared subtree for password, inactivity, redaction.- Strings genericised: existing
messages_*keys stay for Messages, newwallet_*mirrors added; a small set of neutral keys added underprivacy_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
PrivacyLockStatescoped, 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()inUpdateZapAmountDialog.kt:394-490. Gating them again would double-prompt. Called out under §System-Wide Impact. amyCLIwalletverbs — currently amy does not expose NWC actions. If they land, they should re-usePrivacyLockPreferencesfor 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.ktpackage com.vitorpamplona.amethyst.commons.privacylock enum class LockScope { Messages, Wallet } - RENAME
commons/.../privacylock/MessagesLockState.kt→PrivacyLockState.kt- Rename class →
PrivacyLockState, add constructorscope: LockScope. - Store
scopeon the instance; pass through tosettings.lockEnabled(scope)/settings.firstRunCardSeen(scope). - Companion: replace
LocalMessagesLockState: ProvidableCompositionLocal<MessagesLockState>withLocalPrivacyLockState: ProvidableCompositionLocal<Map<LockScope, PrivacyLockState>>. - Add extension:
@Composable fun lockStateFor(scope: LockScope) = LocalPrivacyLockState.current.getValue(scope).
- Rename class →
- MODIFY
commons/.../privacylock/PrivacyLockSettings.ktinterface:- Replace
val lockEnabled: StateFlow<Boolean>withfun 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 objectconstants: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
- Replace
- 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
initblock (see §Data Migration). - Setters write to the scope-suffixed key.
- Add per-scope
- 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 = 2written.
Ship this phase as its own commit — no UI changes; keeps git bisect
useful.
Acceptance:
./gradlew :commons:jvmTest --tests "*PrivacyLockState*"green (all 5 existing + 3 new)./gradlew :desktopApp:compileKotlingreen (only rename+delegate calls updated)./gradlew :amethyst:compilePlayDebugKotlingreen
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 inMessagesLockGate.kt. - Make
internal, takescope: LockScope, title: String, subtitle: String, unlockLabel: String. - No behaviour change beyond parameterisation.
- Extract
- SHRINK
commons/.../ui/privacylock/MessagesLockGate.ktto a ~15-line wrapper that fetcheslockStateFor(LockScope.Messages),DisposableEffect(onLeaveRoute), and delegates the locked branch toLockScreen(LockScope.Messages, stringRes(R.string.privacy_lock_messages_title), …). - NEW
commons/.../ui/privacylock/WalletLockGate.kt— 15-line mirror. Scope =Wallet. Strings fromR.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:
MessagesLockGatepublic signature unchanged (no caller changes)WalletLockGateexposes the samecontent: @Composable () -> Unitlambda- Extracted
LockScreenrenders 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— mirrorDesktopMessagesLockGate.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 == nullbranch: "No password is set yet." + button "Open Settings" that navigates viaSinglePaneState.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.
- Reads
- NEW
desktopApp/.../security/WalletFirstRunBanner.kt— mirrorMessagesFirstRunBanner.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.
- Reads
DesktopWalletLockGatealso drives capture-block and blur-on-unfocus the same way the Messages gate does — expandWindowCaptureBlock.ktso 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 indesktopApp/.../platform/that readsLocalWindowFocus.currentand appliesModifier.blur(radius = 16.dp)only when unfocused ANDlockEnabled == 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)inWalletColumnScreen.kt,OnchainSection.kt(if reused in Desktop), and NWC dialogs.
- New
Wire into DeckColumnContainer.kt:
DeckColumnType.Wallet -> {
DesktopWalletLockGate {
WalletColumnScreen(
account = account,
accountManager = accountManager,
relayManager = relayManager,
localCache = localCache,
nwcConnection = nwcConnection,
appScope = appScope,
onZapFeedback = onZapFeedback,
)
}
}
Place WalletFirstRunBanner at the top of WalletColumnScreen's Column
(mirroring where MessagesFirstRunBanner sits in the Messages column
entry).
Acceptance:
- Toggling the master lock on in Settings → next Wallet column open shows the lock screen
- Correct password (verified against shared
passwordHashed) unlocks - Wrong password 5 times → lockout applies to both scopes (shared failed-attempt counter — covered by PrivacyLockStateTest.failed_unlock_counter_is_shared_across_scopes)
- Leaving the Wallet column re-locks it (DisposableEffect.onDispose → PrivacyLockState.onLeaveRoute)
- Idle timer configured via shared
inactivityTimersetting 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
- 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:
- Toggling the master lock on with no password → prompts to set one (existing behaviour)
- Toggling the master lock on locks both Messages and Wallet on next entry (single settings flag drives both PrivacyLockState instances)
- Toggling the master lock off unlocks both immediately (transitions Locked → Disabled — covered by toggling_lock_off_transitions_to_disabled test)
- 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— mentionLockScopeunder theprivacylock/package entry. - Update
MEMORY.md— add pointer to this plan alongside the messaging-privacy-lock pointer. ./gradlew spotlessApply.- Update manual testing sheet (see §Documentation Plan) — copy the Messages sheet, adjust for Wallet.
- Verify Crowdin sync propagates the new keys (existing PR pipeline already syncs; no new machinery needed).
Acceptance:
./gradlew :commons:jvmTest --tests "*PrivacyLockState*"green./gradlew :amethyst:compilePlayDebugKotlingreen./gradlew :desktopApp:compileKotlingreen./gradlew spotlessApplyclean- 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
DisabledorUnlocked→WalletColumnScreencomposes;WalletFirstRunBannermay 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()→WalletColumnScreencomposes.
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
- 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,
Unlockbutton disabled. Failure mode: counter scoped per-gate would defeat brute-force protection. - 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.
- 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
firstRunCardSeenwould suppress Wallet banner. - 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.
- 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. - 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.
- Migration from legacy prefs: User on a build with the shipped
feat/desktop-privacy-lock(singlelock_enabledkey) upgrades to this build. Expected: Messages toggle preserved; Wallet toggle defaults off. Legacy prefs keys removed;schema_version = 2written. Failure mode: users get silently un-locked on upgrade, OR migration re-runs and clobbers a subsequent Wallet toggle.
Acceptance Criteria
Functional
LockScopeenum shipped incommons/commonMainPrivacyLockStatereplacesMessagesLockState; each scope has an independentstate: StateFlow<LockState>and idle-timer JobPrivacyLockSettings.lockEnabledandfirstRunCardSeenstay single master flags (per user Q2)- Password / inactivity timer / redaction / failed-attempts / lockout remain device-global (shared)
MessagesLockGatepublic signature unchanged; wired tolockStateFor(Messages)WalletLockGatecomposable shipped incommons/.../ui/privacylock/- Shared
LockScreen(scope, title, subtitle, unlockLabel)composable replaces the inlined lock screen inside MessagesLockGate; both gates render it DesktopMessagesLockGaterefactored to consume sharedDesktopLockScreen; behaviour preservedDesktopWalletLockGateshipped; wrapsWalletColumnScreeninsideDeckColumnContainerMessagesFirstRunBannercopy updated to reference both Messages and WalletWalletFirstRunBannershipped at top ofWalletColumnScreen- Settings screen renders single master toggle + shared password subtree + shared inactivity timer + Messages-only redaction card
- 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)- 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.statereads are constant-time regardless of scope count (Map lookup, no reflection)- No new deps added — everything stays inside kotlinx.coroutines + Compose + the existing java.util.prefs / SharedPreferences setup
- Password comparison stays constant-time via
PasswordHasher.verify(unchanged) - No visible flash of Wallet content on cold start when
lockEnabled(Wallet) = true— seeded synchronously (deep-link race fix H1 from messaging plan applies to both scopes)
Quality Gates
./gradlew :commons:jvmTest --tests "*PrivacyLockState*"green (16 tests, 3 new)./gradlew :amethyst:compilePlayDebugKotlingreen./gradlew :desktopApp:compileKotlingreen./gradlew :desktopApp:packageDmggreen on macOS host (packaging validation deferred to reviewer)./gradlew :desktopApp:packageMsigreen on Windows host (packaging validation deferred to reviewer)./gradlew :desktopApp:packageDebgreen on Linux host (packaging validation deferred to reviewer)./gradlew spotlessApplyclean- 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-lockmerged 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_EXCLUDEFROMCAPTUREshims — all already shipped byfeat/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 sendland, they should refuse to run whenPrivacyLockPreferences.lockEnabled(Wallet)istrue— 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 addLockScope.Accountto gate the Account backup screen. Today that screen already uses OS-credential re-prompts, so added value is marginal. redactionLevelextension for wallet notifications. If Desktop gets a notification pipeline for NWC events (balance changes, incoming zaps), add aWalletRedactionLeveland gate the same way DM notifications are gated. Currently no such pipeline exists.
Documentation Plan
commons/ARCHITECTURE.md— update theprivacylock/package entry: mention theLockScopeenum and the "one settings, many scopes" contract.docs/plans/2026-07-07-wallet-lock-manual-testing.md— new manual testing sheet mirroringdocs/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— 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— 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 branchfeat/desktop-privacy-lock) - Extracted from:
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt(on branchfeat/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.mdgoverns 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;
passwordHashedstorage 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)
- Merge order — ✅ Solo PR stacked on
feat/desktop-privacy-lock. - Lock granularity — ✅ Single master lock protects both Messages
and Wallet. No per-scope enable flag. Single
firstRunCardSeentoo. - Wallet first-run banner on empty NWC — Show anyway (feature is valuable pre-connect).
- 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. - "No password set" branch behaviour — ✅ Deep-link to Settings → Privacy lock section (not just show the message).
- Rename
redactionLevel→dmRedactionLevel— ✅ Yes. Kotlin-side only; persisted keyredaction_level_ordinalstays for compatibility. LockScopepackage — ✅ Inside existingprivacylock/package.- Cascade
passwordHashed → nullunsetslockEnabled— ✅ Yes. Implement inPreferencesPrivacyLockSettings.setPasswordHashed(null)→ alsosetLockEnabled(false)atomically.