Files
com.vitorpamplona.amethyst/docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md
nrobi144 c4f647d01a feat(privacylock): foundation — state holder, settings, gate composable
Phase 1 of the messaging privacy lock. Headless cross-platform spine in
commons; no UI wiring yet.

- LockState sealed interface (Disabled / Locked / Unlocked)
- InactivityTimer enum (1m / 5m / 15m / 1h / Never; default 5m)
- DmRedactionLevel enum (Generic / Full)
- PrivacyLockSettings interface (StateFlows + mutators)
- PreferencesPrivacyLockSettings backed by java.util.prefs in jvmAndroid
  (shared by Desktop + Android; node com/vitorpamplona/amethyst/privacylock)
- MessagesLockState — app-global state holder; initial value seeded
  synchronously from prefs to close the deep-link race; LocalMessagesLockState
  CompositionLocal provided at App root
- CredentialPrompter interface + PromptResult enum +
  LocalCredentialPrompter CompositionLocal
- MessagesLockGate composable — synchronous branch select (no
  LaunchedEffect guard); LockScreen with biometric button
- IdleTimerModifier — pointerInput Initial-pass, non-consuming
- 8 unit tests covering cold-start seed, idle expiry, leave-route,
  Never timer, user-interaction reset, settings cascade,
  credential-unavailable disable. All green.
2026-07-01 11:35:51 +03:00

56 KiB
Raw Permalink Blame History

title, type, status, date, origin
title type status date origin
Messaging Privacy Lock feat active 2026-06-30 docs/brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md

Messaging Privacy Lock

Route-scoped lock on the Messages destination + companion defenses (notification redaction, screen-capture block, blur-on-unfocus) that prevent an attentive snooper from reading DMs on an unattended-but-unlocked device. Industry baseline = Signal screen-lock + WhatsApp Chat Lock, collapsed to Messages route only, OS-credential-only (no Amethyst PIN), device-global, off-by-default with a one-time prompt.

All decisions traced back to the brainstorm (see brainstorm: docs/brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md).

Enhancement Summary

Deepened on: 2026-06-30 Reviewers: security-sentinel, architecture-strategist, code-simplicity-reviewer, performance-oracle, spec-flow-analyzer, pattern-recognition-specialist

Key changes incorporated

  1. Security hardening — six previously-missed content-leak paths added to scope: crash-report scrubbing, DM-content log scrub, MessagingStyle history flush on first enable, NotificationListener payload redaction (main builder, not just setPublicVersion), bunker decrypt queue drained on re-lock, deep-link race fixed by synchronous initial-state read.
  2. Architecture correctionsCompositionLocal instead of commons/jvmMain → desktopApp import (broke dependency arrow); PrivacyLockPreferences moved to commons/jvmAndroid source set (was breaking iOS); native binaries moved to canonical appResources/{macos,linux,windows}/; SecureScreenEffect relegated to Android-only (no JVM equivalent).
  3. Performance — gate uses state.map { it is Locked }.distinctUntilChanged() to keep chat subtree off the recomposition path; SharingStarted.Eagerly; Activity-level FLAG_SECURE so cold-resume thumbnail is blanked from frame 0; blur radius 16dp on overlay layer (not LazyColumn).
  4. Scope cutsWindows Hello native helper deferred (v1 uses CredUIPromptForCredentials = OS password, biometric in v2); Linux entirely deferred (lock toggle shows "Not available on Linux yet" banner in v1); 2-level notification (Generic / Full), no chooser dialog; first-run card buttons collapsed to Enable / Not now (no "Don't ask" persistence).
  5. Edge cases formalized as ACs — empty-Messages first-run card, account-switch = leave-route, BiometricPrompt-up + background cancellation, draft persistence via overlay (not unmount), media playback does NOT reset idle timer, in-app DM banner suppressed on Messages route while locked.
  6. Naming alignedPreferencesPrivacyLockSettings (mirrors PreferencesHashtagSpamSettings); JNA under desktopApp/.../service/security/ (mirrors service/media/).

Overview

Five user-visible deliverables, gated behind a single device-global toggle:

  1. Messages-route gate — biometric / OS-credential prompt before any DM UI renders. Re-locks on inactivity (default 5 min, configurable ImmediateNever) and on leaving the route.
  2. Notification preview redaction — 3-level (Full / Sender-only / Generic). Auto-bumps to Generic on first lock-enable with a chooser.
  3. Screen-capture block — Android FLAG_SECURE, macOS NSWindow.sharingType = .none, Windows SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE). Linux: not available; documented limitation.
  4. Desktop blur-on-unfocus — Messages content renders blurred while the window is unfocused; no PIN re-entry on focus regain.
  5. First-run inline card — top-of-Messages prompt offering Enable / Not now / Don't ask.

Problem Statement

Amethyst currently has zero protection against snooping on an unattended device. Anyone who walks past a logged-in install can read DM history, including NIP-04 + NIP-17 private chats + Marmot group chats. The existing SecurityFiltersScreen is a misnomer — it gates spam/content filters, not privacy. Notification banners on Android show the sender's name in the title even when the message body is generic. Recents thumbnails and screen-sharing tools capture chat content unrestricted.

The intended user is a person who:

  • Leaves their device unlocked for short periods (coffee shop, family living room, shared workstation).
  • Wants reasonable, inconvenient-but-tolerable friction guarding the Messages tab specifically.
  • Does not expect protection against a fully hostile device-possessor (rooted Android, attached debugger, filesystem-level access).

Proposed Solution

A device-global toggle (PrivacyLockPreferences.lockEnabled) drives a MessagesLockState StateFlow consumed by a shared MessagesLockGate composable. The gate wraps every Messages-route entry composable on both Android and Desktop. When state is Locked, the gate intercepts and shows a lock screen with a single "Unlock" affordance. Tapping the affordance invokes the platform-specific CredentialPrompter, which routes to:

Platform Primary Fallback
Android BiometricPrompt(STRONG | DEVICE_CREDENTIAL) Device PIN/Pattern (built into the prompt)
macOS LAContext.evaluatePolicy(.deviceOwnerAuthentication) via ~80-line Swift .dylib shim + JNA OS password (built into LAPolicy)
Windows CredUIPromptForCredentials via JNA (User32) → OS password only in v1 Same
Linux Lock disabled in v1 — settings shows "Not available on Linux yet" banner

Why Windows Hello + Linux were deferred (per deepen-plan simplicity review): no maintained JVM WinRT projection (would require a native .exe helper, code-signing, MSI bundling — heavy v1 cost for biometric vs. OS password); polkit Linux flow needs root-installed policy that breaks Flatpak distribution. Both tracked under Future Considerations.

On success the gate transitions to Unlocked; an idle timer drives the re-lock; navigation away from the route flips state to Locked immediately. Companion defenses (FLAG_SECURE, sharing block, blur-on-unfocus, notification redaction) are all conditioned on lockEnabled and applied automatically — no extra UI knobs in v1.

Technical Approach

Architecture

                  ┌─────────────────────────────────┐
                  │   PrivacyLockPreferences        │  java.util.prefs (jvm)
                  │   ─ lockEnabled: Boolean        │   + SharedPreferences (Android)
                  │   ─ inactivityMillis: Long      │
                  │   ─ notificationLevel: …        │
                  │   ─ firstRunPromptDismissed     │
                  │   ─ disabledByMissingCredential │
                  └────────────┬────────────────────┘
                               │
                  ┌────────────▼────────────────────┐
                  │   MessagesLockState              │  StateFlow<LockState>
                  │   ─ Disabled / Locked / Unlocked │
                  │   ─ onUserInteraction()          │
                  │   ─ onLeaveRoute()               │
                  │   ─ onUnlockSuccess()            │
                  │   ─ onCredentialUnavailable()    │
                  └────────────┬────────────────────┘
                               │
            ┌──────────────────┴──────────────────┐
            │                                     │
   ┌────────▼─────────┐                ┌──────────▼────────┐
   │  Android UI       │                │   Desktop UI       │
   │   (amethyst/)     │                │   (desktopApp/)    │
   ├───────────────────┤                ├────────────────────┤
   │  MessagesLockGate │                │  MessagesLockGate  │
   │  (commons/common) │                │  (commons/common)  │
   │  ↓                │                │  ↓                 │
   │  AndroidCredential│                │  Mac / Win / Linux │
   │  Prompter         │                │  CredentialPrompter│
   │  (expect/actual)  │                │  (expect/actual)   │
   │                   │                │                    │
   │  FLAG_SECURE      │                │  NSWindowSharingNone│
   │  on chat screens  │                │  / WDA_EXCLUDE…    │
   │                   │                │                    │
   │  Notification     │                │  WindowFocus → blur│
   │  redaction        │                │                    │
   └───────────────────┘                └────────────────────┘

Single state holder, two UIs subscribed. Mirrors the LocalRelayStore / LocalRelayMaintenance pattern already established in the embedded-relay work (see desktopApp/plans/2026-05-09-embedded-local-relay-plan.md).

Module map

Concern Module / Source set Path
PreferencesPrivacyLockSettings (mirrors PreferencesHashtagSpamSettings; node com/vitorpamplona/amethyst/privacylock) commons/jvmAndroid (shared by Android + Desktop, NOT iOS — per commons/ARCHITECTURE.md §3) commons/.../privacylock/PreferencesPrivacyLockSettings.kt
MessagesLockState (StateFlow + idle timer) commons/commonMain commons/.../privacylock/MessagesLockState.kt
DmRedactionLevel enum (Generic / Full) + redaction policy (CLI-safe data) commons/commonMain commons/.../privacylock/DmRedactionPolicy.kt
MessagesLockGate composable + lock-screen UI commons/commonMain commons/.../ui/privacylock/MessagesLockGate.kt
interface CredentialPrompter + LocalCredentialPrompter CompositionLocal (no expect/actual — supplied per platform at App root) commons/commonMain commons/.../ui/privacylock/CredentialPrompter.kt
Android BiometricCredentialPrompter amethyst/.../security/ BiometricCredentialPrompter.kt
macOS Touch ID Swift shim source desktopApp/src/jvmMain/native/macos/ TouchIDShim.swift
macOS Touch ID compiled binary desktopApp/src/jvmMain/appResources/macos/ (Compose canonical native-resource root, picked up automatically by nativeDistributions) libAmethystTouchID.dylib
Windows OS password prompter (v1 — no Hello) desktopApp/.../service/security/ WindowsPasswordPrompter.kt (JNA → User32.CredUIPromptForCredentials)
macOS Touch ID prompter (Kotlin side) desktopApp/.../service/security/ MacTouchIdPrompter.kt (JNA → libAmethystTouchID.dylib)
Desktop CredentialPrompter provider (selects Mac/Win/no-op) desktopApp/.../service/security/ DesktopCredentialPrompterFactory.kt (lives in desktopApp, NOT commons/jvmMainPlatformInfo ownership stays where it is)
FrameWindowScope.applyWindowCaptureBlock(enabled) desktopApp/.../platform/ WindowCaptureBlock.kt (alongside applyNativeWindowChrome in PlatformTheme.kt)
Desktop window-focus StateFlow + LocalWindowFocus desktopApp/.../platform/ WindowFocusOwner.kt
LocalMessagesLockState CompositionLocal (provided once at App root on both platforms) commons/commonMain commons/.../privacylock/MessagesLockState.kt (companion)
Android settings UI + first-run card amethyst/.../settings/ + amethyst/.../chats/ PrivacyLockSettingsScreen.kt, MessagesFirstRunCard.kt
Desktop settings UI (Column + Card pattern from LocalRelaySettingsScreen, NO Scaffold) + first-run card desktopApp/.../settings/ + desktopApp/.../chats/ PrivacyLockSettingsScreen.kt, MessagesFirstRunCard.kt
Notification redaction call site (Android-only — NotificationUtils.kt doesn't exist on Desktop) amethyst/.../service/notifications/ edit NotificationUtils.kt:370-510 to consume DmRedactionPolicy from commons
Idle-timer modifier (Modifier.resetIdleOnInteraction() — single root attach, not per-composable) commons/commonMain commons/.../ui/privacylock/IdleTimerModifier.kt
FLAG_SECURE wrapper (Android-only — no expect/actual; desktop uses window-level WindowCaptureBlock) amethyst/.../security/ SecureScreenEffect.kt
Crash-report DM scrubber (NEW — security hardening) amethyst/.../service/crashreports/ edit ReportAssembler.kt to strip Throwable.message when origin package is nip04Dm / nip17Dm / marmot
DM-content log audit (NEW — security hardening) amethyst/.../service/notifications/ + commons/.../bunker/ grep + edit: EventNotificationConsumer.kt:349,392,435,491,549,695, RemoteSignerManager.kt:53 — strip body fields from Log.d

commons/ARCHITECTURE.md governs package taxonomy — new package commons/.../privacylock/ for state + prefs + policy, commons/.../ui/privacylock/ for Compose surfaces.

Pattern note (new): LocalMessagesLockState + LocalCredentialPrompter introduce app-global CompositionLocals provided once at the App root. Mirrors the existing precedent LocalRelayStore-style provision in desktopApp/Main.kt (single instantiation site, app-global, not per-Window — required so multi-window desktop in the future doesn't bypass the lock).

Implementation Phases

Phase 1: Foundation (state + preferences + tests)

Lay the cross-platform spine before touching either UI. Output: a testable, headless state machine with unit-test coverage.

Critical requirement (per deepen perf finding): initial LockState value MUST be derived synchronously from prefs.lockEnabled BEFORE the first composition runs. setContent {} must see Locked as the seed value the first time it composes — never Unlocked followed by an async update. This is the foundation of the deep-link race fix (see Security Hardening below).

  • New files:

    • commons/src/commonMain/.../privacylock/MessagesLockState.kt
      • sealed class LockState { Disabled; Locked; Unlocked }
      • class MessagesLockState(prefs, clock, scope)
        • val state: StateFlow<LockState> — backed by MutableStateFlow(seedFromPrefsSynchronously())
        • Uses SharingStarted.Eagerly (NOT WhileSubscribed — needed because the notification path reads lockEnabled outside any UI collector, per deepen perf finding #4)
      • fun onUserInteraction() — idempotent
      • fun onLeaveRoute() — idempotent; transitions Unlocked→Locked
      • fun onUnlockSuccess() — transitions Locked→Unlocked, restarts idle timer
      • fun onCredentialUnavailable() — single-arg (no reason variants per simplicity review); transitions to Disabled, persists flag, no-op if already Disabled
      • companion object { val LocalMessagesLockState: ProvidableCompositionLocal<MessagesLockState> }
    • commons/src/commonMain/.../privacylock/InactivityTimer.kt
      • enum class InactivityTimer(val millis: Long?): OneMin, FiveMin, FifteenMin, OneHour, Never. Default FiveMin. Immediate dropped (per simplicity review — redundant with leave-route trigger.)
    • commons/src/commonMain/.../privacylock/DmRedactionPolicy.kt
      • enum class DmRedactionLevel { Generic; Full } (2 levels, not 3 — per simplicity review; "Sender only" was niche middle ground)
      • fun resolveLevel(lockEnabled: Boolean, userChoice: DmRedactionLevel?): DmRedactionLevel — when lockEnabled and user hasn't explicitly chosen, returns Generic. When !lockEnabled, returns Full.
    • commons/src/jvmAndroid/.../privacylock/PreferencesPrivacyLockSettings.kt
      • Mirrors PreferencesHashtagSpamSettings shape (class taking prefs, exposes StateFlow mutators).
      • Backing storage: java.util.prefs on Desktop / SharedPreferences on Android (each platform sets up its own Preferences instance and hands it in — jvmAndroid source set doesn't pick the storage backend).
      • Keys: lockEnabled, inactivityTimerOrdinal, redactionLevelOrdinal, firstRunCardSeen. Removed: disabledByMissingCredential (per simplicity review — re-check via BiometricManager.canAuthenticate() on settings render).
    • commons/src/commonTest/.../privacylock/MessagesLockStateTest.kt
      • 5 unit tests (down from 12 per simplicity review — start small):
        1. Idle expiry fires lock
        2. Leave-route locks immediately
        3. Cold-start with lockEnabled=true seeds to Locked
        4. Settings cascade — toggle off transitions Locked→Disabled
        5. Never timer doesn't fire
  • Acceptance:

    • ./gradlew :commons:jvmTest --tests "*MessagesLockStateTest*" green
    • State machine is idempotent on duplicate triggers
    • No new deps required; uses kotlinx.coroutines.flow only
    • Initial value seeded synchronously (no flash on cold start)

Phase 2: Android lock + UI + notification redaction + leak audit

Wire the gate, settings, and notification redaction on Android. This phase incorporates the security-hardening leak fixes surfaced during deepen review (crash, logs, MessagingStyle history, NotificationListener payload).

  • New files:

    • commons/.../ui/privacylock/MessagesLockGate.kt (commonMain) — gate hosts the lock overlay ONLY; content lambda passed in unchanged. State read uses state.map { it is Locked }.distinctUntilChanged().collectAsStateWithLifecycle(initialValue = state.value is Locked) so the chat subtree never recomposes on idle-tick non-flips. Gate selects branch SYNCHRONOUSLY in composition (when { locked -> LockOverlay(); else -> content() }) — no LaunchedEffect guard (per security finding #1).
    • commons/.../ui/privacylock/CredentialPrompter.kt (commonMain) — interface CredentialPrompter { suspend fun prompt(reason: String): PromptResult }, LocalCredentialPrompter CompositionLocal.
    • amethyst/.../security/BiometricCredentialPrompter.kt — wraps BiometricPrompt(STRONG | DEVICE_CREDENTIAL). Reuse pattern from UpdateZapAmountDialog.kt:428-479. Provided into LocalCredentialPrompter at the AmethystApp composition root. Error mapping (single-reason variant per simplicity review):
      • ERROR_USER_CANCELED → stay locked, no toast
      • ERROR_LOCKOUT → "Try again in 30 sec" toast, stay locked
      • ERROR_LOCKOUT_PERMANENT / ERROR_NO_HARDWARE / ERROR_NONE_ENROLLEDstate.onCredentialUnavailable()
    • amethyst/.../security/SecureScreenEffect.kt (Android-only — no expect/actual per architecture review). Toggles WindowManager.LayoutParams.FLAG_SECURE.
    • commons/.../ui/privacylock/IdleTimerModifier.kt (commonMain) — single Modifier.resetIdleOnInteraction(state) using Modifier.pointerInput(Unit) { awaitPointerEventScope { while(true){ awaitPointerEvent(PointerEventPass.Initial); state.onUserInteraction() } } }. Initial pass = observe without consuming. Per-keypress TextField reset wired via a separate onValueChange adapter at the compose sites that have text fields.
  • Edited files:

    • amethyst/.../MainActivity.kt — set Window.addFlags(FLAG_SECURE) early in onCreate (window-level, not per-screen DisposableEffect) when prefs.lockEnabled == true. This closes the recents cold-reattach thumbnail leak window (security finding #6 + perf finding #6). Per-screen SecureScreenEffect stays as belt-and-suspenders for when chat is visible but lock toggle was just flipped on.
    • amethyst/.../ui/screen/loggedIn/chats/rooms/MessagesScreen.kt — wrap top-level composable in MessagesLockGate; attach Modifier.resetIdleOnInteraction(state) once at the route root (per architecture review's "single root attach" recommendation).
    • amethyst/.../ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt — wrap content in SecureScreenEffect(enabled = lockEnabled); treated as inside-gate so deep-link from notification still transits the parent-route gate.
    • amethyst/.../ui/screen/loggedIn/chats/marmotGroup/MarmotGroupScreen.kt — same. Verify gate wraps at route level (not deep inside the composable tree), per architecture review.
    • amethyst/.../service/notifications/NotificationUtils.kt — consume DmRedactionPolicy from commons. Redact the MAIN builder, not just setPublicVersion (security finding #5 — NotificationListener apps see the main Notification.extras):
      • When level == Generic: setContentTitle("Amethyst"), setContentText("New message"), do NOT add MessagingStyle.addMessage(body, …) (which persists across updates and leaks pre-enable history).
      • Disable inline-reply RemoteInput when level=Generic (would otherwise let the system quote the original message — security finding #13).
      • On first lock-enable, call NotificationManagerCompat.cancel(DM_GROUP_KEY) to flush pre-enable MessagingStyle history (security finding #4).
      • Bypass Log.d("$content $title …") style logging in DM paths (security finding #3) — survey lines 349, 392, 435, 491, 549, 695 in EventNotificationConsumer.kt and strip body fields.
    • amethyst/.../service/crashreports/ReportAssembler.kt — NEW security hardening (finding #2). Strip Throwable.message to class-name-only when origin package is nip04Dm, nip17Dm, or marmot. Always-on (not gated on lockEnabled — DM plaintext in crash reports is never a feature).
    • commons/.../bunker/RemoteSignerManager.kt — on MessagesLockState transition Unlocked→Locked, drain the bunker decrypt response queue (security finding #7). Cancel in-flight Channel<Response> subscriptions for DM decrypt requests.
  • New settings:

    • amethyst/.../ui/screen/loggedIn/settings/PrivacyLockSettingsScreen.kt
      • Toggle: Lock Messages (calls BiometricManager.canAuthenticate() on render — if unavailable, toggle disabled + banner)
      • Spinner: Lock after [1 min / 5 min / 15 min / 1 hour / Never] (5 values, default 5 min — per simplicity review, Immediate dropped as redundant with leave-route)
      • Toggle: Hide notification preview (2-level: On = Generic, Off = Full — chooser dialog dropped per simplicity review)
      • Inline hint: "Notifications hide content while lock is on."
      • Banner (when canAuthenticate returns NO_HARDWARE / NONE_ENROLLED): "Lock disabled — no biometric or device credential available."
      • Copy: "Lock applies to Messages only. Other parts of the app stay open. Does NOT protect against rooted devices, debuggers, filesystem access, apps with notification-listener permission, or screen-recording apps you've granted access — your nsec is still stored as it is today." (Honest threat-model copy per security finding #15.)
    • Add link from existing SecurityFiltersScreen.kt to PrivacyLockSettingsScreen. Keep SecurityFiltersScreen name to minimize churn; add a "Privacy" sub-link clearly labeled.
  • First-run card:

    • amethyst/.../ui/screen/loggedIn/chats/rooms/MessagesFirstRunCard.kt — inline Card at top of MessagesScreen, structurally modeled on OfflineBanner.kt from the embedded-relay work (per pattern review). Two buttons (per simplicity review): Enable / Not now. Auto-suppress after firstRunCardSeen is set OR after lockEnabled flips to true. (No "Don't ask" plumbing.) Renders independently of DM list state — visible even on empty Messages (per spec-flow gap #1).
  • Acceptance:

    • ./gradlew :amethyst:assembleDebug succeeds
    • Manual: gate appears, biometric unlocks, idle timer locks, navigation away locks immediately
    • Recents-screen thumbnail blank when in Messages (verify on BOTH cold cold-start and warm resume — security finding #6)
    • Notification preview hidden by default after enable
    • First-enable flushes MessagingStyle history (security AC)
    • NotificationListenerService apps see redacted main builder when level=Generic (verify with a side-loaded NL app)
    • Crash report from an injected DM-decode error contains no plaintext (security AC)
    • logcat | grep -i 'dm\|chat' produces no plaintext while DMs flow (security AC)
    • First-run card displays on empty Messages tab
    • Deep-link from notification: chat content NEVER paints before gate (security AC — write Compose-test screenshot diff on the transition frame)

Phase 3: Desktop lock + UI + macOS Touch ID + Windows OS password

Scope cut per deepen simplicity review: Linux is fully deferred to v2 (toggle disabled with "Not available on Linux yet" banner); Windows ships with OS password only in v1 (Windows Hello deferred — no JVM-native WinRT projection). macOS gets Touch ID via the Swift shim — small enough to justify (~80 LOC).

  • New files:

    • desktopApp/.../service/security/DesktopCredentialPrompterFactory.kt — selects MacTouchIdPrompter on macOS, WindowsPasswordPrompter on Windows, returns a no-op disabled prompter on Linux. Provided into LocalCredentialPrompter at the App() root in Main.kt.
    • desktopApp/.../service/security/MacTouchIdPrompter.kt — JNA into libAmethystTouchID.dylib.amethyst_touchid_authenticate.
    • desktopApp/.../service/security/WindowsPasswordPrompter.kt — JNA into User32.CredUIPromptForCredentials. No Windows Hello in v1 — drops native helper + signing + MSI bundling cost. Documented in copy: "Windows uses your account password until we add Windows Hello support."
    • desktopApp/src/jvmMain/native/macos/TouchIDShim.swift — source, ~80 lines:
      import LocalAuthentication
      
      @_cdecl("amethyst_touchid_authenticate")
      public func authenticate(reason: UnsafePointer<CChar>) -> Int32 {
          let ctx = LAContext()
          var err: NSError?
          guard ctx.canEvaluatePolicy(.deviceOwnerAuthentication, error: &err)
          else { return -1 }
          let sem = DispatchSemaphore(value: 0)
          var ok = false
          ctx.evaluatePolicy(.deviceOwnerAuthentication,
              localizedReason: String(cString: reason)) { success, _ in
              ok = success
              sem.signal()
          }
          sem.wait()
          return ok ? 0 : 1
      }
      
      Hardcode reason string on the Kotlin side (security finding #14 — no user-controlled strings to the OS prompt). Compiled with swiftc -emit-library -o libAmethystTouchID.dylib on macOS hosts.
    • desktopApp/src/jvmMain/appResources/macos/libAmethystTouchID.dylib — committed pre-built artifact. Compose Desktop's appResourcesRootDir is the canonical location (already used by VLC bundles per desktopApp/build.gradle.kts:107,173-175), so nativeDistributions picks it up automatically. No new Gradle tasks required (per architecture review #8). Recompile recipe documented separately at desktopApp/plans/2026-06-30-privacy-lock-native-shims-recipe.md.
    • desktopApp/.../platform/WindowFocusOwner.ktLocalWindowFocus: ProvidableCompositionLocal<StateFlow<Boolean>>. Sourced from window.addWindowFocusListener events emitted into a MutableStateFlow.
    • desktopApp/.../platform/WindowCaptureBlock.ktfun FrameWindowScope.applyWindowCaptureBlock(enabled: StateFlow<Boolean>):
      • macOS: JNA into NSWindow.setSharingType(0) — extract JNA pattern from existing applyNativeWindowChrome() in PlatformTheme.kt:87. Apply via LaunchedEffect(enabled) { ... } so JNA call fires only on flip, not per recomposition (perf finding #5). Use DisposableEffect to restore sharingType = normal on disable.
      • Windows: JNA into User32.SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE). Same effect plumbing.
      • Linux: no-op.
  • Edited files:

    • desktopApp/.../Main.kt — inside Window { ... } block (line 316), invoke applyWindowCaptureBlock(lockEnabledFlow) next to existing applyNativeWindowChrome(). Construct the app-global MessagesLockState ONCE here and provide both LocalMessagesLockState and LocalCredentialPrompter at the App root. Multi-window correctness (architecture review #5).
    • desktopApp/.../ui/chats/DesktopMessagesScreen.kt, ChatPane.kt, ConversationListPane.kt — wrap in MessagesLockGate. Apply Modifier.resetIdleOnInteraction(state) at the route root only.
    • desktopApp/.../ui/chats/ChatPane.kt — apply Modifier.blur(16.dp, BlurredEdgeTreatment.Unbounded) on an OVERLAY layer (not on the LazyColumn), driven by LocalWindowFocus. Cap at 16dp (perf finding #2). On macOS 15+ and Linux, default blur radius even when focused (because the capture block is broken/absent — partial compensation per security findings #9 and #10).
    • desktopApp/build.gradle.kts — only addition is the macOS infoPlist block to add NSFaceIDUsageDescription. Native binaries auto-bundled via appResources/macos/.
    • desktopApp/.../ui/settings/PrivacyLockSettingsScreen.kt — follows LocalRelaySettingsScreen shape (Column + Card, NO Scaffold — pattern review). Same toggles/spinners as Android. On Linux, the whole settings group is replaced by a banner: "Not available on Linux yet." On macOS 15+, an info row: "Screen capture protection is limited on macOS 15+." On Windows, info row: "Windows uses your account password — Hello support coming later."
  • Acceptance:

    • ./gradlew :desktopApp:run on macOS: Touch ID prompt appears, success unlocks, cancel keeps locked
    • ./gradlew :desktopApp:run on Windows: OS-password prompt appears (CredUI dialog), success unlocks
    • ./gradlew :desktopApp:run on Linux: lock toggle in settings is disabled with banner; Messages route shows no gate
    • DMG / MSI builds succeed (no new MSI signing required since Hello helper is deferred)

Phase 4: Desktop companion defenses + blur + first-run

  • Edited files:

    • desktopApp/.../ui/chats/DesktopMessagesScreen.kt — apply Modifier.blur(...) driven by LocalWindowFocus.
    • desktopApp/.../ui/chats/MessagesFirstRunCard.kt — new inline card composable mirroring Android.
  • Window-focus wiring:

    • Main.kt Window block: emit window-focus events into LocalWindowFocus StateFlow via window.addWindowFocusListener { ... }.
  • macOS Sequoia/Tahoe caveat:

    • PrivacyLockSettingsScreen shows an info row when running on macOS 15+: "Screen sharing protection is limited on macOS 15+ due to a system-level change — relying on this for hostile environments is not advised."
  • Linux caveat:

    • On Linux, the same row reads: "Screen capture protection is not available on Linux. Lock + blur + notification redaction still apply."
  • Acceptance:

    • Alt-tab away from window → Messages content blurs
    • Alt-tab back → blur clears, no re-prompt
    • macOS: screenshot via ⌘+Shift+3 produces blank on the Messages window (best-effort on 15+ — verify and document)
    • Windows: PrtScn + clipboard paste shows blank Messages window
    • Linux: row says "not available"

Phase 5: Polish, l10n, docs, manual testing

  • Translation strings — add ~20 strings to Android amethyst/src/main/res/values/strings.xml and desktop desktopApp/.../resources/messages.properties:
    • lock_messages_title, lock_messages_subtitle, lock_after, immediate/1min/5min/15min/1hour/never, notification_preview, full/sender_only/hidden, lock_screen_title, lock_screen_unlock_button, first_run_card_title, first_run_card_enable, first_run_card_not_now, first_run_card_dont_ask, lock_disabled_no_credential_banner, capture_protection_linux_unavailable, capture_protection_macos15_caveat
  • Crowdin auto-sync handles propagation (PR #3142 just merged translations — verify pipeline)
  • Final ./gradlew spotlessApply
  • Update commons/ARCHITECTURE.md with new privacylock/ package
  • Update MEMORY.md index entry for this work
  • Hand-written manual testing sheet (post-implementation deliverable)

Security Hardening Additions (from deepen review)

Six previously-missed leak paths now in scope. Each maps to a concrete edit elsewhere in the plan; this section is the punch list.

# Leak path Severity Mitigation Status in plan
H1 Deep-link race — LaunchedEffect guard insufficient; chat content can flash 1 frame before gate paints HIGH Gate selects branch SYNCHRONOUSLY in composition; MessagesLockState.state seeded synchronously from prefs before setContent; Compose-test screenshot diff asserts no-flash invariant Phase 1 (seed) + Phase 2 (gate composable + AC)
H2 Crash reports serialize Throwable.message containing decrypted DM plaintext (Jackson parse errors echo input; NIP-44 errors echo bytes) HIGH ReportAssembler.kt strips Throwable.message to class-name-only when origin package is nip04Dm, nip17Dm, or marmot. Always-on. Phase 2 (new edit)
H3 Log.d calls in DM notification path echo decrypted content; Proguard does not reliably strip in release; visible via adb logcat and NotificationListener apps with logging plugins HIGH Audit lines 349, 392, 435, 491, 549, 695 in EventNotificationConsumer.kt and RemoteSignerManager.kt:53; strip body fields, keep only event ID + length Phase 2 (new edit)
H4 NotificationCompat.MessagingStyle.addMessage(...) persists pre-enable history in system NotificationManager; lockscreen redaction does not affect it HIGH On first lock-enable, call NotificationManagerCompat.cancel(DM_GROUP_KEY) to flush; do not call addMessage(body, …) when level=Generic Phase 2 (edit NotificationUtils.kt)
H5 NotificationListenerService apps see the full Notification payload (extras.text, MessagingStyle); setPublicVersion only affects lockscreen HIGH Redact the MAIN builder when level=Generic — setContentText("New message"), omit MessagingStyle.addMessage, disable inline-reply RemoteInput (which would otherwise let the system quote the original message — finding #13) Phase 2 (edit NotificationUtils.kt)
H6 NIP-46 bunker decrypt responses queued on a channel; on re-lock, in-flight responses can deliver plaintext to a recomposed (now-locked) chat scope MEDIUM On MessagesLockState Unlocked→Locked transition, cancel in-flight bunker decrypt request subscriptions and clear the response channel Phase 2 (edit RemoteSignerManager.kt)

These are NOT optional polish — without them the v1 lock fails to deliver even its narrow "cosmetic shoulder-surf" promise honestly.

Alternative Approaches Considered

  1. App-wide lock (not just Messages route). Rejected during brainstorm — user explicitly chose Messages-only. Higher friction for low gain.
  2. In-app Amethyst PIN. Rejected — OS device credentials are better UX and better security (no PIN reuse, no recovery to build).
  3. Per-conversation lock (WhatsApp Chat Lock-style). Rejected for v1 as scope creep. Could layer on later as a per-Chatroom flag.
  4. nsec encryption-at-rest with PIN-derived key. Recognized as the cryptographic upgrade that would close the "device-possessor" threat. Explicitly deferred to Phase 2 (separate brainstorm) until v1 ships and we have user feedback.
  5. Background / system-lock as trigger. User chose to skip — the inactivity timer (default 5 min) subsumes it within the same window.
  6. Rococoa for macOS Touch ID (vs Swift .dylib shim). Rejected — shim is smaller (~80 LOC) and avoids pulling in a 5MB framework dependency. JNA + a single .dylib matches the existing MacOsVlcDiscoverer.kt JNA pattern.

System-Wide Impact

Interaction Graph

User taps Messages tab → NavController.navigate("messages")MessagesScreen composes → MessagesLockGate consumes MessagesLockState.state

  • If Disabled or Unlocked → render content + apply SecureScreenEffect + (desktop) Modifier.blur(focusState)
  • If Locked → render lock screen with "Unlock" button → user taps → CredentialPrompter.prompt(...) → (Android) BiometricPrompt.authenticate(...) → (macOS) JNA → libAmethystTouchID.dylib.amethyst_touchid_authenticate(...)LAContext.evaluatePolicy(...) → callback success → MessagesLockState.onUnlockSuccess() → state flips to Unlocked → recomposition → content renders.

User receives DM while app foregrounded but on a different route → EventNotificationConsumer.consume(event)

  • If lockEnabledsendDMNotification(level = current)NotificationCompat.Builder.setPublicVersion(...) → user sees redacted preview on lock screen.

User taps DM notification → MainActivity.onNewIntent(intent) → NavController deep-link to chatroom → MessagesScreen route entered → gate intercepts (because state was Locked from a prior leave-route event) → unlock prompt → on success → chatroom shows.

Error Propagation

Origin Error Handled at Result
BiometricPrompt ERROR_USER_CANCELED CredentialPrompter.android.kt Swallowed; stay locked
BiometricPrompt ERROR_LOCKOUT same Toast "Try again in 30 sec"; stay locked
BiometricPrompt ERROR_LOCKOUT_PERMANENT same onCredentialUnavailable(LockoutPermanent) → state → Disabled + banner
BiometricPrompt ERROR_NO_HARDWARE same onCredentialUnavailable(NoCredential) → same
TouchIDShim canEvaluatePolicy = false MacTouchIdPrompter onCredentialUnavailable(NoCredential)
TouchIDShim evaluate → false (user cancel) same Stay locked, no toast
WindowsHello helper ProcessBuilder non-zero exit WindowsHelloPrompter If exit code = 2 (WinRT unavailable) → fall back to CredUIPromptForCredentials
Linux password wrong password LinuxPasswordPrompter Toast "Wrong password"; stay locked. Throttle: 5s lockout after 3 attempts.
MessagesLockState preferences write fails PrivacyLockPreferences Log + propagate (caller treats as best-effort)
Notification redaction level mismatched on platform change NotificationUtils Falls back to Generic — safe default

State Lifecycle Risks

Risk Mitigation
App killed mid-unlock leaves state inconsistent State is in-memory; cold start re-reads lockEnabled from prefs → if enabled, starts in Locked. Fail-safe by default.
Idle timer fires while user is typing a message Timer resets on keyboardCharType interaction events; types via Modifier.pointerInput + composing-text interaction.
Switching Nostr accounts mid-session Setting is device-global → state persists. Users expect this.
Settings change "disable lock" while screen is Locked State holder receives prefs flow update → transitions Locked → Disabled. Gate transparently shows content.
Deep-link from notification while locked Notification intent → MainActivity → NavController.handleDeepLink → MessagesScreen route → gate intercepts. Critical: ensure no chat content is rendered before gate paints (use LaunchedEffect initial composition guard).
Restoring app from recents (Android) while Locked Lock state persists in MessagesLockState. Recents thumbnail is FLAG_SECURE-blanked.
Window-focus oscillation (rapid alt-tab) Debounce blur transition by 100ms to avoid visual flicker.

API Surface Parity

Surface Affected? Notes
amy CLI No CLI has no Compose UI. PrivacyLockPreferences is exposed but CLI commands don't gate by it. Future: amy lock status for debugging.
Android Quick-Tile widgets No widgets today
Wear OS companion None today
Search (global) Yes — verify Audit MessagesSearchScreen / global search results: when locked, must NOT surface DM hits. Add filter isMessagesLocked to search result aggregation.
Notifications service Yes EventNotificationConsumer reads lockEnabled + redaction level.
Marmot group chats Yes Same gate via commons composable.

Integration Test Scenarios

  1. Lock + deep link: Tap DM notification while app cold, lock enabled. Expected: Messages route opens, gate intercepts BEFORE chatroom content visible, unlock → chatroom shows. Failure mode: chatroom flashes content during navigation transition.
  2. Lock + spec drift: User toggles lock → toggles inactivity to Never → leaves Messages → returns. Expected: Locked (because leave-route is a separate trigger). Failure: stays Unlocked because timer = Never.
  3. Lock + credential revocation: User removes fingerprint in Android settings while app open. Next gate entry expected: prompt appears, fails with NONE_ENROLLED → state → Disabled + banner shows on settings.
  4. Lock + multi-window (Android tablet): Lock active, Messages in left pane, Feed in right. Expected: only left pane shows lock.
  5. Lock + spam tab: Locked + user navigates Known→New tab inside Messages. Expected: no re-prompt within same Messages route session.
  6. Lock + send-receive parity: Lock active, user unlocks, sends a DM, recipient sees it normally. Outbound path unaffected.
  7. Notification preview level after lock disable: User enables lock → Generic notifications. Disables lock → notifications revert to Full (no sticky Generic).

Acceptance Criteria

Functional

  • Messages route gated by MessagesLockGate on Android + Desktop
  • Idle timer (default 5 min) auto-locks while in Messages
  • Navigation away from Messages triggers immediate re-lock
  • Account switch counts as leave-route (force re-lock — spec-flow #2)
  • Inactivity timer ignores incoming DMs (per resolved Q)
  • Inactivity timer resets on user input (scroll/tap/keypress) but NOT on media playback (spec-flow #13)
  • Marmot group chats inherit the gate (per resolved Q)
  • Android: BiometricPrompt(STRONG | DEVICE_CREDENTIAL) succeeds
  • macOS: Touch ID via Swift shim succeeds; password fallback works
  • Windows: CredUIPromptForCredentials (OS password) succeeds — no Windows Hello in v1
  • Linux: lock toggle disabled with "Not available on Linux yet" banner — lock entirely deferred in v1
  • Activity-level FLAG_SECURE applied at MainActivity.onCreate when lockEnabled = true (closes cold-resume recents window)
  • Per-screen FLAG_SECURE belt-and-suspenders via SecureScreenEffect
  • macOS NSWindow sharingType set when lock enabled (with macOS 15+ caveat documented in-app — both ScreenCaptureKit and screencapture(1) ignore it on Sequoia/Tahoe)
  • Windows SetWindowDisplayAffinity set when lock enabled
  • Desktop blur-on-unfocus active while Messages visible (16dp, overlay layer not LazyColumn)
  • macOS 15+ and Linux: blur applied even while focused as partial capture-block compensation
  • Notification preview redaction respects level (Generic / Full — 2 levels per simplicity review)
  • Notification main builder redacted when level=Generic (not just setPublicVersion) — security H5
  • Inline-reply RemoteInput disabled when level=Generic — security H13
  • MessagingStyle history flushed on first lock-enable — security H4
  • Crash reports strip Throwable.message for DM-origin packages — security H2
  • No Log.d calls echo decrypted DM content (verified by logcat | grep audit during testing) — security H3
  • Bunker decrypt response queue drained on re-lock — security H6
  • First-run inline card appears once; auto-suppresses after Enable or after the card is shown (2 buttons: Enable / Not now — no "Don't ask" persistence per simplicity review)
  • First-run card renders on empty Messages tab (spec-flow #1)
  • Fallback: credential-unavailable → toggle disabled + banner (no persisted flag — re-checked via BiometricManager.canAuthenticate())
  • Settings UI on both platforms (Android Scaffold, Desktop Column + Card per pattern review)
  • Global search does not surface DM content while locked (SearchBarViewModel + AdvancedSearchBarState + Desktop search panel — filter aggregation on kinds 4 / 14 / 1059 / 443)
  • In-app DM banner suppressed on Messages route while Locked (spec-flow #7)
  • Draft persistence — text TextFields keep their value across a lock cycle (gate overlays content, never disposes it — spec-flow #8)
  • BiometricPrompt dismissed + state reset on Lifecycle.STOP while prompt is up (spec-flow #4)
  • Disable-while-locked: toggle off in settings → state Locked → Disabled within one frame (spec-flow #9)
  • Concurrent triggers (leave-route + idle-timer same tick) are idempotent (spec-flow #5)
  • Deep-link from notification: chat content NEVER paints before gate — security H1; Compose-test asserts this

Non-Functional

  • No measurable startup-time regression (≤ +20ms cold start)
  • Gate composition adds ≤1 recomposition per state change
  • No new ANRs / dropped frames during lock transitions (verified with macrobenchmark on Android, frame-rate inspection on desktop)
  • FLAG_SECURE applied via DisposableEffect, removed on screen exit (no permanent secure-flag leak)
  • No credentials cached in memory beyond the unlock callback
  • All new code paths spotless-clean and covered where applicable

Quality Gates

  • ./gradlew :commons:jvmTest --tests "*privacylock*" green
  • ./gradlew :amethyst:assembleDebug green
  • ./gradlew :desktopApp:compileKotlin green
  • ./gradlew :desktopApp:packageDmg green on macOS host
  • ./gradlew :desktopApp:packageMsi green on Windows host
  • ./gradlew :desktopApp:packageDeb green on Linux host
  • ./gradlew spotlessApply clean
  • Manual testing sheet executed and signed off

Success Metrics

  • Adoption proxy: prefs node read counts (if telemetry exists; if not, GitHub issue volume tagged privacy-lock)
  • Stability proxy: zero "stuck-locked" support reports during the first 30 days post-release
  • Discoverability proxy: < 10% of users dismiss the first-run card with "Don't ask" without first trying Enable (intent: card is clear enough that 'Don't ask' is informed, not annoyance-driven)

Dependencies & Prerequisites

  • androidx.biometric.ktx 1.2.0-alpha05 (already bundled)
  • com.sun.jna:jna (already a transitive dep via existing JNA usage in MacOsVlcDiscoverer.kt)
  • Compose Multiplatform 1.11.0 — Modifier.blur supported
  • macOS dev host with swiftc (only when changing Touch ID shim source)
  • Windows dev host with dotnet SDK (only when changing Hello helper)
  • Pre-built .dylib / .exe committed to repo so non-mac/non-win devs can still build

Risk Analysis & Mitigation

Risk Likelihood Impact Mitigation
macOS 15+ NSWindowSharingNone is ignored by ScreenCaptureKit Verified Medium In-app caveat row; document in settings + release notes
Touch ID Swift shim breaks under future macOS update Low Medium Shim is tiny; fix is < 30 LOC. Fallback: password via LAPolicy.deviceOwnerAuthentication works without biometric
Windows Hello helper.exe gets flagged by AV Medium Medium Sign the helper with same code-sign cert as the main MSI; ship as AmethystHello.exe inside the app bundle (not standalone)
Linux distro permutations (Wayland vs X11) — no portable capture block Verified Low Documented limitation; lock + blur + notif redaction still work
Notification deep-link races the gate (chat flash) Medium High (privacy leak) LaunchedEffect initial-composition guard; integration test
BiometricPrompt + Compose lifecycle bug under config-change Low Medium Use existing pattern from UpdateZapAmountDialog.kt:428-479; add lifecycle-aware launch
User disables OS device credentials → permanently locked out Verified Low onCredentialUnavailable → disable + warn banner — no permanent lockout
Spotless config doesn't know about Swift / C# files Low Low Add file globs to .editorconfig only — spotless is Kotlin-scoped
ProGuard strips BiometricPrompt reflection Medium Medium Existing compose-rules.pro already has broad keepnames; add explicit -keep class androidx.biometric.** if needed
Marmot UI extraction blocked / delayed Low Low Gate wraps Marmot Android screen today; Desktop inherits when Marmot extracts. No coupling.

Future Considerations

  • Windows Hello in v2. Native helper .exe (C++/WinRT or C#) ~60 LOC over stdio, parented to the JVM HWND via IUserConsentVerifierInterop. Requires the helper to be signed with the same cert as the main MSI to avoid AV flags. Add once the v1 flow has user feedback. (See deepen-plan external research output.)
  • Linux lock in v2. Bitwarden's polkit pattern is the proven path but requires a root-installed policy file at /usr/share/polkit-1/actions/com.amethyst.privacy-lock.policy, which breaks Flatpak distribution. Alternative: in-app password prompt stored in libsecret. Decide at v2.
  • Phase 2 — nsec encryption-at-rest with PIN-derived key. Separate brainstorm. Closes the cosmetic-vs-cryptographic gap. Would re-encrypt accounts.json.enc with PRF(nsec, PIN-derived key) on lock; decrypt only at unlock. Compatible with NIP-46 bunker (signer never holds nsec). Compatible with NIP-55 (external signer).
  • Per-conversation lock (WhatsApp Chat Lock parity). Add Chatroom.locked: Boolean flag and a second gate over individual ChatroomScreen entries.
  • Wallet (NWC) gate — reuse the same MessagesLockGate plumbing to gate the Wallet deck column. Already on the feature backlog.
  • amy CLI integrationamy messages status could refuse to print DMs while lockEnabled = true to enforce parity even from the headless side. Useful when running amy on a shared machine.
  • Hide app from app-switcher entirely (Android) — instead of FLAG_SECURE blanking, fully omit the app from the recents list while Messages is foregrounded. More aggressive UX; opt-in.

Documentation Plan

  • commons/ARCHITECTURE.md — add privacylock/ package entry
  • desktopApp/CLAUDE.md (if exists) — mention Touch ID shim build step
  • New desktopApp/plans/2026-06-30-privacy-lock-native-shims-recipe.md capturing the macOS .dylib / Windows .exe build & signing procedure (so future contributors don't rediscover it)
  • Release notes section "Privacy & Security" highlighting the new toggle + caveats (macOS 15+, Linux capture)
  • docs/manual-testing-privacy-lock.md — the testing sheet (delivered as part of the final step)

Sources & References

Origin

  • Brainstorm document: docs/brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md — Key decisions carried forward: (a) Messages-only route gate (not app-wide), (b) OS device credentials only (no Amethyst PIN), (c) device-global setting, (d) off by default + inline first-run card, (e) inactivity-timer + leave-route as the two re-lock triggers, (f) auto-bump notification preview to Generic on first enable.

Internal References

  • Existing biometric pattern: amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt:428-479
  • DM notification path: amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt:370-510
  • Existing JNA usage: desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt
  • macOS window-chrome extension pattern: desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt:87
  • Desktop window creation: desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:316
  • Settings storage on desktop (java.util.prefs precedent): commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt
  • DM messaging UI (commons + Android + Desktop) — surveyed in brainstorm research.

External References

  • Embedded local relay plan (precedent for commons/jvmMain state holder + StateFlow + LocalXyzStore pattern): desktopApp/plans/2026-05-09-embedded-local-relay-plan.md
  • Account security hardening (concurrent, deals with nsec storage and forced logout): docs/plans/2026-05-14-fix-account-security-hardening-plan.md
  • Hashtag spam filter (precedent for global preferences node): docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md

Open Questions (post-brainstorm + post-deepen)

All brainstorm open questions are resolved. Original planning questions reordered + deepen-review additions integrated:

  1. macOS code-signing for Touch ID shim. Current desktop build's nativeDistributions { macOS { ... } } block has only bundleID and iconFile. No signing block, no notarization. Touch ID works on unsigned dev builds (research confirms), but for the release DMG we need a Developer ID + notarization. Verify whether the existing DMG release is signed elsewhere; if not, that's a separate workstream (out of this plan's scope but a release-day blocker).
  2. ProGuard rules. Compose Desktop release uses ProGuard. Verify compose-rules.pro keeps the new JNA classes, biometric reflection surfaces, and Compose composables. Add -keep class androidx.biometric.** { *; } if not already covered.
  3. Search audit — concrete files (refined from spec-flow review): SearchBarViewModel.kt (Android), AdvancedSearchBarState.kt (commons), AdvancedSearchPanel.kt (desktop). Filter aggregation on kinds 4 / 14 / 1059 / 443 when MessagesLockState.state == Locked. Phase 5 deliverable, gated by an acceptance criterion below.
  4. commons/jvmAndroid source set name — verify the exact name commons/ARCHITECTURE.md uses (could be jvmAndroidMain or desktopAndroidMain). Source set must exist already (used by shared StateFlow / persistence patterns); confirm during Phase 1.
  5. CredentialPrompter interface vs expect class — per pattern review the codebase has no precedent for @Composable expect fun, so the plan uses a plain interface + CompositionLocal. If a wider refactor ever wants expect class CredentialPrompter (matching SecureKeyStorage), follow that idiom — out of scope now.
  6. firstRunCardSeen scope — device-global (single flag, not per-npub) per spec-flow gap #2. Acceptable for v1.
  7. MessagesLockState.LocalMessagesLockState instantiation site on Android — needs a single owner equivalent to the Desktop Main.kt instantiation. Likely AmethystApp / Application class. Confirm during Phase 2 wiring.
  8. Telemetry? Amethyst has no telemetry today. Success metrics above are inferred from issue volume — keep as-is, no telemetry shipped.