mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
docs(privacylock): manual testing sheet, banner plan, security review
- docs/plans/2026-06-30-privacy-lock-manual-testing.md — 10-path manual QA sheet covering setup, lock behavior, timer, deep-link race, change/remove password, banner discovery, cross-run persistence. - docs/plans/2026-07-01-feat-messages-first-run-lock-banner-plan.md — design doc for the discovery banner shipped in aadbf3601. - docs/plans/2026-07-01-privacy-lock-security-review.md — honest review of PasswordHasher (PBKDF2 100k / 16B salt) + prefs storage. Flags 4 medium-severity items (iterations below OWASP 2023 rec; no versioned hash format; no UI-layer rate-limiting; unencrypted prefs storage) with concrete fixes. All within accepted threat model but P0 items are cheap and high-value follow-ups.
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
---
|
||||
title: Messaging Privacy Lock — Desktop Manual Testing Sheet
|
||||
type: test
|
||||
status: active
|
||||
date: 2026-06-30
|
||||
---
|
||||
|
||||
# Messaging Privacy Lock — Desktop Manual Testing Sheet
|
||||
|
||||
Companion to `2026-06-30-feat-messaging-privacy-lock-plan.md` and
|
||||
`2026-06-30-feat-messaging-privacy-lock-brainstorm.md`.
|
||||
|
||||
## What ships on the `worktree-brainstorm-messaging-privacy-lock` branch
|
||||
|
||||
- ✅ **Commons foundation** — headless cross-platform state machine,
|
||||
preferences with password-hashed field, gate composable primitives,
|
||||
idle-timer modifier, 8 unit tests all green
|
||||
(`./gradlew :commons:jvmTest --tests "*MessagesLockStateTest*"`).
|
||||
- ✅ **Desktop wiring** — `DesktopMessagesLockGate` wrapping the
|
||||
Messages deck column; PBKDF2 password unlock (100k iterations,
|
||||
16-byte salt); `PrivacyLockSettingsScreen` embedded in the Settings
|
||||
pane; app-global CompositionLocals provided in `Main.kt`.
|
||||
|
||||
## What is NOT in this branch
|
||||
|
||||
- **Android app module changes** — reverted. Foundation code in
|
||||
`commons/` still compiles for Android; wiring is a separate future
|
||||
workstream.
|
||||
- **macOS Touch ID / Windows Hello / Linux biometrics** — Desktop v1
|
||||
uses a PBKDF2 password gate. Native biometric shims (Swift
|
||||
`LAContext`, Windows Hello, polkit) remain in Future Considerations
|
||||
per the plan.
|
||||
- **Notification redaction call site** — the `DmRedactionLevel` policy
|
||||
exists in `commons/.../privacylock/`; the actual write into
|
||||
Android's `NotificationUtils.kt` or a desktop notification path is
|
||||
deferred (no desktop notifications wired today).
|
||||
- **Screen-capture window blocks** — `NSWindowSharingNone` /
|
||||
`SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)` deferred. macOS 15+
|
||||
broke `sharingType` anyway; Windows can revisit as a v1.1 patch.
|
||||
- **Blur-on-unfocus** — Desktop deferred to v1.1; the gate's
|
||||
onLeaveRoute-fires-on-column-navigation already covers the "left
|
||||
Messages" scenario.
|
||||
- **Marmot group chats** — Marmot UI is Android-only in this repo.
|
||||
Desktop only gates the primary Messages column.
|
||||
- **Security hardening H2–H6** — crash-report scrubber, DM-content
|
||||
log audit, MessagingStyle history flush, NotificationListener
|
||||
redaction of the main builder, bunker decrypt queue drain. Each is
|
||||
independent scope; see the plan's §Security Hardening Additions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- macOS host recommended (the primary Amethyst Desktop dev target).
|
||||
Also runs on Windows and Linux (untested for this feature).
|
||||
- Java 17+ (Compose Desktop bundles its own JBR).
|
||||
- A Nostr account with at least one DM conversation for meaningful
|
||||
gating.
|
||||
|
||||
## Automated verification
|
||||
|
||||
```bash
|
||||
# Unit tests for the state machine + settings interface
|
||||
./gradlew :commons:jvmTest --tests "com.vitorpamplona.amethyst.commons.privacylock.MessagesLockStateTest"
|
||||
|
||||
# Full commons + desktop compile
|
||||
./gradlew :commons:compileKotlinJvm
|
||||
./gradlew :desktopApp:compileKotlin
|
||||
|
||||
# Formatter
|
||||
./gradlew spotlessApply
|
||||
```
|
||||
|
||||
Expected: green.
|
||||
|
||||
## Manual testing paths
|
||||
|
||||
### Launch
|
||||
|
||||
```bash
|
||||
./gradlew :desktopApp:run
|
||||
```
|
||||
|
||||
Log in with an account that has one or more DM conversations.
|
||||
Add a Messages column to your deck via the "+" button → Messages, if
|
||||
not already present.
|
||||
|
||||
### Path A — Enable the lock
|
||||
|
||||
1. Click **Settings** in the sidebar.
|
||||
2. Scroll to the **"Lock the Messages tab"** card.
|
||||
- ✅ Card shows a description and a **Switch**.
|
||||
- ✅ Switch is OFF by default.
|
||||
3. Click the Switch to ON.
|
||||
- ✅ A **Set a password** dialog appears (because no password is
|
||||
set yet).
|
||||
- ✅ Dialog has: "New password" field, "Confirm new password"
|
||||
field, Cancel + Save buttons.
|
||||
4. Enter a password shorter than 4 characters, click Save.
|
||||
- ✅ Error: "New password must be at least 4 characters".
|
||||
5. Enter mismatched passwords, click Save.
|
||||
- ✅ Error: "Passwords don't match".
|
||||
6. Enter matching passwords ≥ 4 chars, click Save.
|
||||
- ✅ Dialog closes.
|
||||
- ✅ Switch is now ON.
|
||||
- ✅ Below the toggle: **"Change password"** button appears.
|
||||
- ✅ Two new cards appear: **"Auto-lock after"** (default: 5 min)
|
||||
and **"DM notification preview"** (default: Full).
|
||||
|
||||
### Path B — Lock behavior
|
||||
|
||||
1. Lock enabled with a password set. Navigate to the Messages deck
|
||||
column.
|
||||
- ✅ **Lock screen** renders with a padlock icon, "Messages
|
||||
locked" title, "Enter your privacy-lock password" subtitle, a
|
||||
password field, and an Unlock button.
|
||||
- ✅ The chat list is NOT visible behind the lock screen.
|
||||
2. Click Unlock without typing.
|
||||
- ✅ Button is disabled.
|
||||
3. Type a wrong password, press Enter (or click Unlock).
|
||||
- ✅ "Wrong password" error under the field.
|
||||
- ✅ Chat still not visible.
|
||||
4. Type the correct password, press Enter.
|
||||
- ✅ Lock screen disappears.
|
||||
- ✅ Full Messages column visible with conversations + chat pane.
|
||||
|
||||
### Path C — Auto re-lock triggers
|
||||
|
||||
1. Unlocked. Set inactivity timer to **1 min** via Settings →
|
||||
Privacy lock → Auto-lock after.
|
||||
2. Return to Messages, don't interact for 60 seconds.
|
||||
- ✅ Column re-locks; password field reappears.
|
||||
3. Unlock. Scroll a chat back and forth for 30 seconds.
|
||||
- ✅ Column stays unlocked (user interaction resets timer).
|
||||
4. Unlock. Navigate to Feed or Discover column.
|
||||
- ✅ Return to Messages → **re-locked immediately** (leave-route
|
||||
trigger via `DisposableEffect(onDispose)`).
|
||||
5. Set timer to **Never**.
|
||||
- ✅ Wait 2 minutes without input → column stays unlocked.
|
||||
- ✅ Navigating away still re-locks (leave-route independent).
|
||||
|
||||
### Path D — Deep-link race (security H1)
|
||||
|
||||
Not directly testable in this branch — Desktop deck columns navigate
|
||||
via the sidebar, so there's no true "cold-start-to-chatroom deep link"
|
||||
flow. However, the invariant still applies: the gate reads
|
||||
`MessagesLockState.state` synchronously in composition (no
|
||||
`LaunchedEffect` guard). Verify by:
|
||||
|
||||
1. Enable lock, quit the app.
|
||||
2. Cold-start (`./gradlew :desktopApp:run`).
|
||||
3. Add the Messages column immediately.
|
||||
- ✅ Lock screen appears **from the first frame** — never see chat
|
||||
content flash before the lock overlay paints.
|
||||
|
||||
### Path E — Change / clear password
|
||||
|
||||
1. Lock enabled. Settings → Privacy lock → **Change password**.
|
||||
2. Dialog opens with: **Current password** field, New password,
|
||||
Confirm.
|
||||
3. Enter wrong current password.
|
||||
- ✅ "Current password is wrong" error.
|
||||
4. Enter correct current, new, confirm → Save.
|
||||
- ✅ Old password no longer works on the lock screen.
|
||||
- ✅ New password unlocks.
|
||||
5. Toggle lock OFF, then back ON.
|
||||
- ✅ Password persists — you're NOT re-prompted to set one, since
|
||||
the hash is still stored (`passwordHashed` is not cleared on
|
||||
disable).
|
||||
|
||||
### Path F — Fallback (no password set)
|
||||
|
||||
Rare: user manually clears `passwordHashed` from
|
||||
`~/.java/.userPrefs/com/vitorpamplona/amethyst/privacylock/` while
|
||||
`lockEnabled = true`. To simulate:
|
||||
|
||||
1. Quit the app.
|
||||
2. Open the prefs file for the node and remove
|
||||
`password_hashed=<value>` while keeping `lock_enabled=true`.
|
||||
3. Restart.
|
||||
4. Navigate to Messages.
|
||||
- ✅ Lock screen shows: "No password is set yet. Open Settings →
|
||||
Privacy lock to set one." + a **Disable lock** button.
|
||||
5. Click **Disable lock**.
|
||||
- ✅ Lock is disabled globally; Messages column visible.
|
||||
|
||||
### Path G — Multi-account switch
|
||||
|
||||
1. Lock enabled, unlocked. In Messages, viewing a conversation.
|
||||
2. Switch account via the sidebar / account switcher.
|
||||
- ✅ Return to Messages → column re-locked (account switch counts
|
||||
as leave-route because the deck column composable is disposed
|
||||
and re-composed).
|
||||
|
||||
### Path H — Regression sweep
|
||||
|
||||
1. Feed / Discover / Wallet / Videos columns still function normally,
|
||||
no gate anywhere else.
|
||||
2. Sending a DM (after unlock) works — the compose pane is inside the
|
||||
gated content, so unlock allows send.
|
||||
3. Settings pane still shows all other sections (Media servers, Local
|
||||
Relay, Namecoin, Logout).
|
||||
4. Long-press-and-drag column reordering still works.
|
||||
|
||||
### Path J — First-run discovery banner
|
||||
|
||||
Added by `docs/plans/2026-07-01-feat-messages-first-run-lock-banner-plan.md`.
|
||||
|
||||
1. Fresh state — quit app, delete
|
||||
`~/.java/.userPrefs/com/vitorpamplona/amethyst/privacylock/prefs.xml`
|
||||
(or edit to clear `first_run_card_seen` + `lock_enabled`).
|
||||
2. Launch app, open Messages column.
|
||||
- ✅ Inline banner at top of Messages column: padlock icon +
|
||||
**"Lock the Messages tab?"** + description + **Not now** +
|
||||
**Enable** buttons.
|
||||
- ✅ Conversation list + chat pane still visible below (banner is
|
||||
~50dp, doesn't dominate).
|
||||
3. Click **Not now**.
|
||||
- ✅ Banner animates out (shrinkVertically + fadeOut).
|
||||
- ✅ Navigate away and back → banner does NOT return.
|
||||
- ✅ Quit + relaunch → banner still does not return.
|
||||
4. Reset state again. Click **Enable** on the banner.
|
||||
- ✅ **Set a password** dialog opens (same dialog as Settings).
|
||||
- ✅ Enter matching password ≥ 4 chars → Save.
|
||||
- ✅ Banner animates out.
|
||||
- ✅ Column STAYS interactive — **no lock-screen flash** after
|
||||
save (verifies the `onUnlockSuccess()` leniency).
|
||||
5. Continue browsing chats without unlock prompt.
|
||||
6. Navigate to Feed → back to Messages.
|
||||
- ✅ Lock screen appears (leave-route trigger fired after enable).
|
||||
- ✅ Unlock with the password you just set.
|
||||
7. Reset state. In Settings, enable the lock first (via the toggle
|
||||
card). Then open Messages.
|
||||
- ✅ Banner does NOT appear (`lockEnabled == true` suppresses it).
|
||||
8. Reset state. Show the banner. Dismiss with **Not now**. Now
|
||||
enable the lock via Settings. Later disable it via Settings.
|
||||
Return to Messages.
|
||||
- ✅ Banner does NOT reappear (dismissal is sticky — the
|
||||
`firstRunCardSeen` flag persists across enable/disable cycles).
|
||||
|
||||
### Path I — Cross-run persistence
|
||||
|
||||
1. Set a password, enable lock, set timer to 15 min, redaction to
|
||||
Hidden.
|
||||
2. Quit the app.
|
||||
3. Restart via `./gradlew :desktopApp:run`.
|
||||
- ✅ Lock is still enabled; navigating to Messages requires
|
||||
password.
|
||||
- ✅ Timer setting persists.
|
||||
- ✅ Redaction persists.
|
||||
|
||||
## Known-honest limitations
|
||||
|
||||
Copy in the Settings pane's **Limitations** card matches the actual
|
||||
threat model:
|
||||
|
||||
- Filesystem-level attacker can read the `java.util.prefs` node and
|
||||
see the *hash* — cannot recover the password without brute-force,
|
||||
but 4-char passwords are weak. Recommend ≥ 8 chars, but v1 min is 4.
|
||||
- Memory dumps expose the plaintext password briefly during
|
||||
verification. Not defended.
|
||||
- The Nostr private key (`nsec`) is stored via `SecureKeyStorage` as
|
||||
it is today — the lock does not touch it.
|
||||
|
||||
## Sign-off checklist
|
||||
|
||||
- [ ] Paths A–I executed on macOS
|
||||
- [ ] `./gradlew :commons:jvmTest` green
|
||||
- [ ] `./gradlew :desktopApp:compileKotlin` green
|
||||
- [ ] `./gradlew spotlessApply` clean
|
||||
- [ ] Follow-up issues filed for:
|
||||
- Windows / Linux manual QA
|
||||
- Screen-capture window blocks (macOS 15+ caveat)
|
||||
- Notification redaction call site (desktop notifications don't
|
||||
exist yet — deferred to when they do)
|
||||
- Native biometrics (Touch ID / Windows Hello / polkit)
|
||||
- Security hardening H2–H6 (crash scrub, log audit, MessagingStyle
|
||||
history, NotificationListener redaction, bunker queue drain)
|
||||
- Marmot group chat gating (once Marmot desktop UI lands)
|
||||
@@ -0,0 +1,381 @@
|
||||
---
|
||||
title: Messages First-Run Privacy Lock Banner (Desktop)
|
||||
type: feat
|
||||
status: completed
|
||||
date: 2026-07-01
|
||||
---
|
||||
|
||||
# Messages First-Run Privacy Lock Banner (Desktop)
|
||||
|
||||
## Overview
|
||||
|
||||
Inline discovery banner at the top of the Desktop Messages deck column
|
||||
that appears when:
|
||||
|
||||
1. The privacy lock is **not enabled**, AND
|
||||
2. The user has **not dismissed** it before (`firstRunCardSeen == false`).
|
||||
|
||||
Two actions: **Enable** (opens the existing password-set dialog inline)
|
||||
or **Not now** (sets `firstRunCardSeen = true` and hides forever). One
|
||||
composable, no new state, no new settings.
|
||||
|
||||
Small follow-up to the shipped privacy-lock feature at
|
||||
[`docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md`](2026-06-30-feat-messaging-privacy-lock-plan.md).
|
||||
|
||||
## Problem Statement / Motivation
|
||||
|
||||
The privacy lock exists and works (Desktop v1 password gate + auto
|
||||
re-lock + settings pane), but it has **zero in-app discovery**. A user
|
||||
who never opens Settings will never find it. Yet the Messages column
|
||||
is exactly the surface where the feature's value is felt — anyone who
|
||||
opens it is by definition a DM user.
|
||||
|
||||
Existing users are the sharper case: they can install this update and
|
||||
never learn the feature exists unless they read the release notes.
|
||||
A one-time banner solves that with negligible UX cost.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Add a `MessagesFirstRunBanner` composable rendered at the top of
|
||||
`DesktopMessagesScreen`, above the two-pane / single-pane layout. It
|
||||
sits inside the `DesktopMessagesLockGate` content lambda, so the
|
||||
banner is only ever visible in Unlocked / Disabled states — never
|
||||
over the lock screen.
|
||||
|
||||
Uses `AnimatedVisibility(expandVertically + fadeIn / shrinkVertically
|
||||
+ fadeOut)` for the show/hide transition — same pattern as
|
||||
`desktopApp/.../ui/components/OfflineBanner.kt:49-56` (the embedded
|
||||
local-relay work). Same visual treatment (Surface + Row + Icon + Text
|
||||
+ TextButton).
|
||||
|
||||
The dialog behind the **Enable** action is the existing
|
||||
`SetPasswordDialog` from `PrivacyLockSettingsScreen.kt:270`. That dialog
|
||||
is currently `private`. Extract it to a new shared file so both the
|
||||
Settings pane and the banner point at the same composable.
|
||||
|
||||
On successful password save from the banner:
|
||||
|
||||
1. `settings.setPasswordHashed(hash)`
|
||||
2. `settings.setLockEnabled(true)`
|
||||
3. `settings.setFirstRunCardSeen(true)` (dismiss banner)
|
||||
4. `messagesLockState.onUnlockSuccess()` — keep the user Unlocked so
|
||||
they don't immediately hit a lock screen after enabling.
|
||||
|
||||
The last call requires a tiny leniency change to `onUnlockSuccess()`
|
||||
so it works from `LockState.Disabled` as well as `Locked` (see
|
||||
Technical Considerations below).
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### Architecture
|
||||
|
||||
- **New file**: `desktopApp/.../security/MessagesFirstRunBanner.kt`
|
||||
— the banner composable + interaction logic.
|
||||
- **New file**: `desktopApp/.../security/SetPasswordDialog.kt` —
|
||||
extracted from `PrivacyLockSettingsScreen.kt`. Public visibility so
|
||||
the banner can reuse it.
|
||||
- **Edit**: `PrivacyLockSettingsScreen.kt` — delete the private
|
||||
`SetPasswordDialog` body, add an import for the new shared version.
|
||||
- **Edit**: `DesktopMessagesScreen.kt` — insert the banner as the
|
||||
first child inside the existing root `Column` (or wrap the current
|
||||
content in a Column if the top-level isn't already one). Passes
|
||||
through in single-pane and compact modes identically.
|
||||
- **Edit**: `MessagesLockState.kt` — relax `onUnlockSuccess()` to
|
||||
accept `Disabled` as a valid previous state (transitions to
|
||||
`Unlocked`).
|
||||
|
||||
### Visibility logic
|
||||
|
||||
```kotlin
|
||||
// MessagesFirstRunBanner.kt
|
||||
@Composable
|
||||
fun MessagesFirstRunBanner() {
|
||||
val settings = LocalPrivacyLockSettings.current
|
||||
val lockState = LocalMessagesLockState.current
|
||||
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,
|
||||
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),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Lock the Messages tab?",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = "Require a password before Messages shows. Feed and profile 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)
|
||||
lockState.onUnlockSuccess() // stay Unlocked; don't force user through the gate immediately
|
||||
showDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Placement inside DesktopMessagesScreen
|
||||
|
||||
The banner goes at the top of the composable's root layout. In
|
||||
`DesktopMessagesScreen.kt` the current `@Composable fun
|
||||
DesktopMessagesScreen(...)` returns either a two-pane or single-pane
|
||||
layout. Wrap in a Column:
|
||||
|
||||
```kotlin
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MessagesFirstRunBanner()
|
||||
// existing two-pane / single-pane content, weight(1f)
|
||||
}
|
||||
```
|
||||
|
||||
Both pane modes need `Modifier.weight(1f)` on the pane container so
|
||||
they consume the remaining space.
|
||||
|
||||
### State transitions
|
||||
|
||||
Enabling from the banner drives this sequence:
|
||||
|
||||
| Step | Action | LockState |
|
||||
|---|---|---|
|
||||
| Before click | User on Messages, lock off | `Disabled` |
|
||||
| `setPasswordHashed(hash)` | Persist | `Disabled` |
|
||||
| `setLockEnabled(true)` | Persist + StateFlow emit | `Disabled` → eventually `Locked` via init collector |
|
||||
| `setFirstRunCardSeen(true)` | Persist | (no state change) |
|
||||
| `onUnlockSuccess()` | Force state = `Unlocked` | `Unlocked` |
|
||||
|
||||
Race: the `settings.lockEnabled` collector on `windowScope` runs
|
||||
whenever it's next scheduled. If it fires between step 3 and step 4,
|
||||
state briefly is `Locked`. If it fires after step 4, state is
|
||||
`Unlocked` and the collector's `else if (state is Disabled)` branch
|
||||
skips (state isn't Disabled anymore). Either ordering ends at
|
||||
`Unlocked`. The gate re-reads `state` each frame, so no lock-screen
|
||||
flash — `Unlocked` reaches the UI within the same recomposition batch.
|
||||
|
||||
### `onUnlockSuccess()` leniency
|
||||
|
||||
Current signature only transitions from `Locked`:
|
||||
|
||||
```kotlin
|
||||
fun onUnlockSuccess() {
|
||||
if (mutableState.value is LockState.Locked) {
|
||||
mutableState.value = LockState.Unlocked
|
||||
restartIdleTimer()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Change to:
|
||||
|
||||
```kotlin
|
||||
fun onUnlockSuccess() {
|
||||
if (mutableState.value !is LockState.Unlocked) {
|
||||
mutableState.value = LockState.Unlocked
|
||||
restartIdleTimer()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
New semantics: "the caller has authenticated the user; go to Unlocked
|
||||
regardless of previous state." No behavior change for any existing
|
||||
call site (they only ever call this from `Locked`).
|
||||
|
||||
### Performance
|
||||
|
||||
- Banner is inside a `Column` that's already the root layout — one
|
||||
extra `AnimatedVisibility` composable.
|
||||
- Reads two `StateFlow<Boolean>` via `collectAsState`. Both are hot
|
||||
(backed by `MutableStateFlow`) so no cost when not changing.
|
||||
- When `firstRunCardSeen` flips true or `lockEnabled` flips true,
|
||||
`AnimatedVisibility` runs its exit animation (~300ms) and unmounts
|
||||
the banner. One-time cost per session.
|
||||
- No new coroutine, no new dispatcher.
|
||||
|
||||
### Accessibility
|
||||
|
||||
- Icon has `contentDescription = null` (decorative — title conveys
|
||||
the meaning).
|
||||
- Text uses semantic `titleSmall` / `bodySmall` styles for screen
|
||||
readers.
|
||||
- Buttons have explicit text labels.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph**: `MessagesFirstRunBanner` → reads
|
||||
`LocalPrivacyLockSettings` + `LocalMessagesLockState` → on Enable
|
||||
action → `SetPasswordDialog` → onConfirm → 4 sequential settings
|
||||
writes + `onUnlockSuccess()` → `MessagesLockState.state` transitions
|
||||
→ gate recomposes → user sees banner disappear + column stays
|
||||
interactive.
|
||||
- **Error propagation**: none new. Password validation stays inside
|
||||
`SetPasswordDialog` (existing errors: length, mismatch, wrong
|
||||
current). Password hash write to `java.util.prefs` is best-effort
|
||||
(the underlying `prefs.put(...)` swallows IO errors — matches the
|
||||
existing settings pane).
|
||||
- **State lifecycle risks**: none. The banner only writes; it never
|
||||
reads then re-writes. Dismissal is idempotent (`setFirstRunCardSeen(true)`
|
||||
is safe to call twice). Enabling from banner is atomic-ish (see
|
||||
race analysis above — end state is deterministic).
|
||||
- **API surface parity**: `SetPasswordDialog` becomes a shared
|
||||
composable. Verify both call sites (banner + settings pane) render
|
||||
the same behavior after extraction.
|
||||
- **Integration test scenarios** (manual — no test infra for this):
|
||||
1. Fresh install → open Messages → banner visible; enable → password
|
||||
dialog → set + save → banner disappears, column stays interactive,
|
||||
lock is on for next session.
|
||||
2. Fresh install → open Messages → banner visible; **Not now** →
|
||||
banner disappears, does not reappear on subsequent Messages entries
|
||||
even after restart.
|
||||
3. Existing user with lock already enabled from Settings pane →
|
||||
`!enabled` is false → banner never renders.
|
||||
4. User dismisses banner, later disables the lock from Settings →
|
||||
banner does NOT reappear (dismissal is sticky by design).
|
||||
5. Cold-start with lock enabled → gate takes over immediately →
|
||||
banner never renders.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `MessagesFirstRunBanner` composable exists at
|
||||
`desktopApp/.../security/MessagesFirstRunBanner.kt`
|
||||
- [x] `SetPasswordDialog` extracted to
|
||||
`desktopApp/.../security/SetPasswordDialog.kt` and reused by
|
||||
both `PrivacyLockSettingsScreen` and the new banner (single
|
||||
source of truth)
|
||||
- [x] `PrivacyLockSettingsScreen` still opens the same dialog after
|
||||
the extraction (visual + behavior identical)
|
||||
- [x] Banner appears at the top of the Desktop Messages column when
|
||||
`!lockEnabled && !firstRunCardSeen`
|
||||
- [x] Banner never appears when the gate is Locked (implicit — the
|
||||
gate replaces content)
|
||||
- [x] "Enable" button opens the password set dialog inline
|
||||
- [x] After successful password save from banner: lock is enabled,
|
||||
hash stored, banner dismissed, column stays Unlocked (no
|
||||
lock-screen flash)
|
||||
- [x] "Not now" dismisses the banner permanently across restarts
|
||||
- [x] Banner does NOT reappear after the user later disables the lock
|
||||
from Settings (dismissal is sticky per `firstRunCardSeen`
|
||||
semantics)
|
||||
- [x] `MessagesLockState.onUnlockSuccess()` accepts `Disabled` as a
|
||||
valid previous state (Unit-tested)
|
||||
- [x] `./gradlew :commons:jvmTest` green (all 8 existing tests +
|
||||
any new coverage for the leniency change)
|
||||
- [x] `./gradlew :desktopApp:compileKotlin` green
|
||||
- [x] `./gradlew spotlessApply` clean
|
||||
- [x] Manual testing sheet updated with a new Path (banner discovery)
|
||||
|
||||
## Alternative Approaches Considered
|
||||
|
||||
1. **Modal dialog on first Messages entry** — rejected in the original
|
||||
brainstorm as too intrusive. Same rejection applies here.
|
||||
2. **Sidebar/menu badge on the Messages icon** — subtle but obscure;
|
||||
users don't associate a lock-glyph badge with the concept "you can
|
||||
lock this." Also requires editing the sidebar composable, more
|
||||
surface area.
|
||||
3. **Toast/snackbar on Messages open** — dismissive; easily missed;
|
||||
auto-fades. Terrible for a discovery affordance.
|
||||
4. **Onboarding flow** — Amethyst has no first-run onboarding today
|
||||
and adding one for this feature is disproportionate.
|
||||
5. **Inline chip inside the empty-state view** — misses users who
|
||||
already have chats (skips the empty-state).
|
||||
6. **No banner, rely on release notes** — status quo. Fails the
|
||||
existing-user discovery case.
|
||||
|
||||
Chosen: **top-of-column banner** — visible to all Messages users,
|
||||
non-blocking, dismissable.
|
||||
|
||||
## Success Metrics
|
||||
|
||||
Amethyst has no telemetry. Qualitative signals only:
|
||||
|
||||
- Zero GitHub issues about "banner won't go away" within 30 days.
|
||||
- One or more community reports of users discovering + enabling the
|
||||
lock via the banner (Nostr threads, Reddit, Discord).
|
||||
- No reports of a lock-screen flash after enable (validates the
|
||||
`onUnlockSuccess()` leniency + state-transition ordering).
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
- **Depends on** the shipped privacy-lock feature (Phase 1 + Phase 5
|
||||
Desktop wiring already committed on this branch). Nothing new to
|
||||
import.
|
||||
- **Depends on** `firstRunCardSeen` field already in
|
||||
`PrivacyLockSettings` — no new persistence needed.
|
||||
- **Risk (low)**: extraction of `SetPasswordDialog` accidentally
|
||||
changes behavior in the Settings pane. Mitigation: extract as
|
||||
literal copy, replace call site, no logic change.
|
||||
- **Risk (low)**: `onUnlockSuccess()` leniency affects existing
|
||||
callers. Mitigation: only one existing caller
|
||||
(`DesktopLockScreen`) and it calls this from `Locked`, so the new
|
||||
semantics are backward-compatible.
|
||||
- **Risk (very low)**: state race between the `setLockEnabled` init
|
||||
collector and `onUnlockSuccess()`. Analysis above shows the end
|
||||
state is deterministic; no lock-screen flash reaches the user
|
||||
because `Unlocked` is set within the same UI recomposition batch.
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
|
||||
- **Parent plan**:
|
||||
[`docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md`](2026-06-30-feat-messaging-privacy-lock-plan.md)
|
||||
— the shipped feature this banner promotes.
|
||||
- **Parent brainstorm**:
|
||||
[`docs/brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md`](../brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md)
|
||||
— the resolved-Q "First-run prompt = inline dismissable card at top
|
||||
of Messages on first entry" that this banner implements.
|
||||
|
||||
### Internal References
|
||||
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/OfflineBanner.kt`
|
||||
— structural template (AnimatedVisibility + Surface + Row).
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt:270`
|
||||
— the `SetPasswordDialog` to extract.
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt:82`
|
||||
— the insertion point.
|
||||
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt`
|
||||
— file for the `onUnlockSuccess()` leniency edit.
|
||||
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt:41,57`
|
||||
— `firstRunCardSeen` + setter already exist.
|
||||
|
||||
### Related Work
|
||||
|
||||
- Manual testing sheet at
|
||||
`docs/plans/2026-06-30-privacy-lock-manual-testing.md` — will need
|
||||
a Path J (banner discovery) added post-implementation.
|
||||
Binary file not shown.
Reference in New Issue
Block a user