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.
This commit is contained in:
nrobi144
2026-07-01 11:35:51 +03:00
parent 15338dffcc
commit c4f647d01a
11 changed files with 1745 additions and 0 deletions
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.privacylock
/**
* Two-level redaction policy for DM notification surfaces.
*
* `Full` shows sender name + message preview. `Generic` collapses to
* "Amethyst: New message" and strips MessagingStyle history + inline-reply
* `RemoteInput` so notification-listener apps and lock-screen banners cannot
* exfiltrate DM bodies. Tied to the privacy lock toggle — when the lock is
* off, `Full` is forced regardless of any prior user choice.
*/
enum class DmRedactionLevel {
Generic,
Full,
;
companion object {
val DEFAULT = Full
fun fromOrdinal(ordinal: Int): DmRedactionLevel = entries.getOrNull(ordinal) ?: DEFAULT
}
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.privacylock
enum class InactivityTimer(
val millis: Long?,
) {
OneMin(60_000L),
FiveMin(5L * 60_000L),
FifteenMin(15L * 60_000L),
OneHour(60L * 60_000L),
Never(null),
;
companion object {
val DEFAULT = FiveMin
fun fromOrdinal(ordinal: Int): InactivityTimer = entries.getOrNull(ordinal) ?: DEFAULT
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.privacylock
sealed interface LockState {
/** Privacy lock is turned off entirely. Gate is transparent. */
data object Disabled : LockState
/** Lock is enabled and currently engaged. Gate intercepts Messages route. */
data object Locked : LockState
/** Lock is enabled and currently unlocked. Gate is transparent until next trigger. */
data object Unlocked : LockState
}
@@ -0,0 +1,129 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.privacylock
import androidx.compose.runtime.compositionLocalOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
/**
* App-global state holder for the Messages privacy lock.
*
* - Single instance per app, provided via [LocalMessagesLockState] at the
* App composition root.
* - Initial value is seeded synchronously from [settings.lockEnabled.value]
* so the first composition sees [LockState.Locked] without flashing
* content (deep-link race fix, plan §Security Hardening H1).
* - The underlying StateFlow is hot (`MutableStateFlow`); notification path
* can read `state.value` synchronously without subscribing.
*/
class MessagesLockState(
private val settings: PrivacyLockSettings,
private val scope: CoroutineScope,
) {
private val seed: LockState =
if (settings.lockEnabled.value) LockState.Locked else LockState.Disabled
private val mutableState = MutableStateFlow(seed)
val state: StateFlow<LockState> = mutableState.asStateFlow()
private var idleTimerJob: Job? = null
init {
settings.lockEnabled
.onEach { enabled ->
if (!enabled) {
cancelIdleTimer()
mutableState.value = LockState.Disabled
} else if (mutableState.value is LockState.Disabled) {
mutableState.value = LockState.Locked
}
}.launchIn(scope)
combine(settings.lockEnabled, settings.inactivityTimer) { enabled, timer -> enabled to timer }
.onEach { _ ->
if (mutableState.value is LockState.Unlocked) restartIdleTimer()
}.launchIn(scope)
}
/** Resets the inactivity timer. No-op unless currently Unlocked. */
fun onUserInteraction() {
if (mutableState.value !is LockState.Unlocked) return
restartIdleTimer()
}
/** Re-lock immediately on route exit or account switch. Idempotent. */
fun onLeaveRoute() {
if (mutableState.value is LockState.Unlocked) {
cancelIdleTimer()
mutableState.value = LockState.Locked
}
}
/** Transition Locked → Unlocked. Idempotent. Starts the idle timer. */
fun onUnlockSuccess() {
if (mutableState.value is LockState.Locked) {
mutableState.value = LockState.Unlocked
restartIdleTimer()
}
}
/**
* Triggered when biometric / OS credential is permanently unavailable.
* Auto-disables the lock so the user can keep accessing Messages.
*/
fun onCredentialUnavailable() {
cancelIdleTimer()
settings.setLockEnabled(false)
mutableState.value = LockState.Disabled
}
private fun restartIdleTimer() {
cancelIdleTimer()
val millis = settings.inactivityTimer.value.millis ?: return
idleTimerJob =
scope.launch {
delay(millis)
if (mutableState.value is LockState.Unlocked) {
mutableState.value = LockState.Locked
}
}
}
private fun cancelIdleTimer() {
idleTimerJob?.cancel()
idleTimerJob = null
}
}
/** Provided once at the App composition root. */
val LocalMessagesLockState =
compositionLocalOf<MessagesLockState> {
error("LocalMessagesLockState not provided — wrap App() with CompositionLocalProvider")
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.privacylock
import androidx.compose.runtime.Stable
import kotlinx.coroutines.flow.StateFlow
/**
* User-tunable settings for the Messages privacy lock.
*
* Device-global per the brainstorm decision (no NIP-78 sync, no per-account
* variants). Platform implementations back this with `java.util.prefs`
* (Desktop) or `SharedPreferences` (Android). Initial values must be read
* synchronously from storage in the constructor so the first `setContent { }`
* sees the seeded state — required to close the deep-link race fix (security
* hardening H1 in the plan).
*/
@Stable
interface PrivacyLockSettings {
val lockEnabled: StateFlow<Boolean>
val inactivityTimer: StateFlow<InactivityTimer>
val redactionLevel: StateFlow<DmRedactionLevel>
val firstRunCardSeen: StateFlow<Boolean>
fun setLockEnabled(enabled: Boolean)
fun setInactivityTimer(timer: InactivityTimer)
fun setRedactionLevel(level: DmRedactionLevel)
fun setFirstRunCardSeen(seen: Boolean)
companion object {
const val DEFAULT_LOCK_ENABLED = false
const val NODE_NAME = "com/vitorpamplona/amethyst/privacylock"
const val KEY_LOCK_ENABLED = "lock_enabled"
const val KEY_INACTIVITY_TIMER = "inactivity_timer_ordinal"
const val KEY_REDACTION_LEVEL = "redaction_level_ordinal"
const val KEY_FIRST_RUN_CARD_SEEN = "first_run_card_seen"
}
}
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.privacylock
import androidx.compose.runtime.compositionLocalOf
/**
* Platform-agnostic credential prompt for the Messages privacy lock.
*
* Implementations:
* - Android: BiometricPrompt(STRONG | DEVICE_CREDENTIAL).
* - macOS: Touch ID via libAmethystTouchID.dylib (with OS password fallback).
* - Windows: CredUIPromptForCredentials (OS password — Windows Hello deferred to v2).
* - Linux: disabled — `available` returns false; toggle is hidden in settings.
*/
interface CredentialPrompter {
/** True when the platform credential surface can be invoked. */
val available: Boolean
/**
* Prompt the user for a credential. Returns one of [PromptResult].
* Hardcoded localized reason on Kotlin side — never accept user-supplied
* strings (security finding #14: avoid OS-prompt spoofing surface).
*/
suspend fun prompt(): PromptResult
}
enum class PromptResult {
/** User authenticated successfully. */
Success,
/** User dismissed the prompt or pressed cancel. Stay locked, no toast. */
UserCanceled,
/** Temporary lockout (e.g. too many biometric attempts). */
TemporaryLockout,
/**
* Credential surface permanently unavailable on this device — caller
* should invoke [com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState.onCredentialUnavailable].
*/
Unavailable,
/** Wrong password / authentication failed. */
Failed,
}
val LocalCredentialPrompter =
compositionLocalOf<CredentialPrompter> {
error("LocalCredentialPrompter not provided — wrap App() with platform CredentialPrompter")
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.privacylock
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState
/**
* Observes pointer events on the Initial pass — does NOT consume them, so
* underlying scroll / click handlers behave normally. Every gesture resets
* the idle timer in [state]. Apply ONCE at the route root to avoid scattering
* reset calls across every child composable (per architecture review).
*
* Incoming DM events (background flow updates) DO NOT trigger this modifier
* since they're not user input — preserves the "walked-away-from-desk"
* protection per brainstorm resolved Q.
*/
fun Modifier.resetIdleOnInteraction(state: MessagesLockState): Modifier =
this.pointerInput(state) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial)
state.onUserInteraction()
}
}
}
@@ -0,0 +1,143 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.privacylock
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState
import com.vitorpamplona.amethyst.commons.privacylock.LockState
import kotlinx.coroutines.launch
/**
* Wraps the Messages route and gates entry behind the credential prompt.
*
* Branch selection happens SYNCHRONOUSLY in composition — no
* [LaunchedEffect] guard — so the chat content composable never enters
* composition while [LockState.Locked]. Closes the deep-link race
* (plan §Security Hardening H1).
*
* The gate is an overlay, NOT a wrapper that disposes content. While
* locked, the [content] lambda is not invoked at all; on unlock, the
* lambda is invoked fresh. This means TextField drafts that use
* `rememberSaveable` survive a lock cycle (SavedStateRegistry-backed).
* For plain `remember` state, drafts are cleared — accept this trade-off.
*
* The gate also fires [MessagesLockState.onLeaveRoute] from its
* [DisposableEffect.onDispose] block, so navigating away locks immediately.
*/
@Composable
fun MessagesLockGate(content: @Composable () -> Unit) {
val lockState = LocalMessagesLockState.current
val current by lockState.state.collectAsState()
DisposableEffect(lockState) {
onDispose { lockState.onLeaveRoute() }
}
when (current) {
is LockState.Locked -> LockScreen()
else -> content()
}
}
@Composable
private fun LockScreen() {
val lockState = LocalMessagesLockState.current
val prompter = LocalCredentialPrompter.current
val scope = rememberCoroutineScope()
LaunchedEffect(prompter) {
if (!prompter.available) {
lockState.onCredentialUnavailable()
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Box(modifier = Modifier.size(16.dp))
Text(
text = "Messages locked",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Box(modifier = Modifier.size(8.dp))
Text(
text = "Unlock to read or send messages",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 320.dp),
)
Box(modifier = Modifier.size(32.dp))
Button(
onClick = {
scope.launch {
when (prompter.prompt()) {
PromptResult.Success -> lockState.onUnlockSuccess()
PromptResult.Unavailable -> lockState.onCredentialUnavailable()
else -> Unit
}
}
},
enabled = prompter.available,
) {
Text(text = "Unlock")
}
}
}
}
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.privacylock
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class MessagesLockStateTest {
private class FakeSettings(
lockEnabled: Boolean = false,
timer: InactivityTimer = InactivityTimer.OneMin,
) : PrivacyLockSettings {
private val mutableLockEnabled = MutableStateFlow(lockEnabled)
private val mutableTimer = MutableStateFlow(timer)
private val mutableRedaction = MutableStateFlow(DmRedactionLevel.DEFAULT)
private val mutableFirstRunSeen = MutableStateFlow(false)
override val lockEnabled: StateFlow<Boolean> = mutableLockEnabled.asStateFlow()
override val inactivityTimer: StateFlow<InactivityTimer> = mutableTimer.asStateFlow()
override val redactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
override val firstRunCardSeen: StateFlow<Boolean> = mutableFirstRunSeen.asStateFlow()
override fun setLockEnabled(enabled: Boolean) {
mutableLockEnabled.value = enabled
}
override fun setInactivityTimer(timer: InactivityTimer) {
mutableTimer.value = timer
}
override fun setRedactionLevel(level: DmRedactionLevel) {
mutableRedaction.value = level
}
override fun setFirstRunCardSeen(seen: Boolean) {
mutableFirstRunSeen.value = seen
}
}
@Test
fun cold_start_with_lock_enabled_seeds_to_locked() =
runTest {
val settings = FakeSettings(lockEnabled = true)
val state = MessagesLockState(settings, backgroundScope)
assertEquals(LockState.Locked, state.state.value)
}
@Test
fun cold_start_with_lock_disabled_seeds_to_disabled() =
runTest {
val settings = FakeSettings(lockEnabled = false)
val state = MessagesLockState(settings, backgroundScope)
assertEquals(LockState.Disabled, state.state.value)
}
@Test
fun unlock_success_transitions_to_unlocked_and_idle_timer_fires() =
runTest {
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin)
val state = MessagesLockState(settings, backgroundScope)
state.onUnlockSuccess()
assertEquals(LockState.Unlocked, state.state.value)
advanceTimeBy(InactivityTimer.OneMin.millis!! + 1_000L)
assertEquals(LockState.Locked, state.state.value)
}
@Test
fun leave_route_locks_immediately() =
runTest {
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneHour)
val state = MessagesLockState(settings, backgroundScope)
state.onUnlockSuccess()
assertEquals(LockState.Unlocked, state.state.value)
state.onLeaveRoute()
assertEquals(LockState.Locked, state.state.value)
}
@Test
fun toggling_lock_off_transitions_to_disabled() =
runTest(UnconfinedTestDispatcher()) {
val settings = FakeSettings(lockEnabled = true)
val state = MessagesLockState(settings, backgroundScope)
state.onUnlockSuccess()
assertEquals(LockState.Unlocked, state.state.value)
settings.setLockEnabled(false)
assertEquals(LockState.Disabled, state.state.value)
}
@Test
fun never_timer_does_not_fire() =
runTest {
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.Never)
val state = MessagesLockState(settings, backgroundScope)
state.onUnlockSuccess()
advanceTimeBy(InactivityTimer.OneHour.millis!! * 2)
assertEquals(LockState.Unlocked, state.state.value)
}
@Test
fun user_interaction_resets_idle_timer() =
runTest {
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin)
val state = MessagesLockState(settings, backgroundScope)
state.onUnlockSuccess()
advanceTimeBy(InactivityTimer.OneMin.millis!! - 1_000L)
state.onUserInteraction()
advanceTimeBy(InactivityTimer.OneMin.millis!! - 1_000L)
assertTrue(state.state.value is LockState.Unlocked)
advanceTimeBy(2_000L)
assertEquals(LockState.Locked, state.state.value)
}
@Test
fun credential_unavailable_disables_lock() =
runTest {
val settings = FakeSettings(lockEnabled = true)
val state = MessagesLockState(settings, backgroundScope)
state.onCredentialUnavailable()
assertEquals(LockState.Disabled, state.state.value)
assertEquals(false, settings.lockEnabled.value)
}
}
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.privacylock
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.DEFAULT_LOCK_ENABLED
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_FIRST_RUN_CARD_SEEN
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_INACTIVITY_TIMER
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_LOCK_ENABLED
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_REDACTION_LEVEL
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.NODE_NAME
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.prefs.Preferences
/**
* Desktop JVM-platform implementation backed by [java.util.prefs.Preferences].
*
* Default node `com/vitorpamplona/amethyst/privacylock` is shared between
* the Desktop app and `amy` CLI running as the same OS user — both observe
* the same setting without extra plumbing. `Preferences` auto-flushes on
* shutdown and periodically; no explicit `flush()` calls needed.
*
* Initial values are read synchronously in the constructor — required so
* the first composition sees seeded state without flashing content.
*/
class PreferencesPrivacyLockSettings(
private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME),
) : PrivacyLockSettings {
private val mutableEnabled = MutableStateFlow(prefs.getBoolean(KEY_LOCK_ENABLED, DEFAULT_LOCK_ENABLED))
private val mutableTimer =
MutableStateFlow(InactivityTimer.fromOrdinal(prefs.getInt(KEY_INACTIVITY_TIMER, InactivityTimer.DEFAULT.ordinal)))
private val mutableRedaction =
MutableStateFlow(DmRedactionLevel.fromOrdinal(prefs.getInt(KEY_REDACTION_LEVEL, DmRedactionLevel.DEFAULT.ordinal)))
private val mutableFirstRunSeen = MutableStateFlow(prefs.getBoolean(KEY_FIRST_RUN_CARD_SEEN, false))
override val lockEnabled: StateFlow<Boolean> = mutableEnabled.asStateFlow()
override val inactivityTimer: StateFlow<InactivityTimer> = mutableTimer.asStateFlow()
override val redactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
override val firstRunCardSeen: StateFlow<Boolean> = mutableFirstRunSeen.asStateFlow()
override fun setLockEnabled(enabled: Boolean) {
mutableEnabled.value = enabled
prefs.putBoolean(KEY_LOCK_ENABLED, enabled)
// First time the user enables the lock, auto-bump redaction to Generic
// unless they've explicitly chosen Full (deepen review — closes the
// "locked UI / leaking notifications" anti-pattern).
if (enabled && mutableRedaction.value == DmRedactionLevel.Full) {
val userPickedFull = prefs.getBoolean("redaction_user_set", false)
if (!userPickedFull) setRedactionLevel(DmRedactionLevel.Generic)
}
}
override fun setInactivityTimer(timer: InactivityTimer) {
mutableTimer.value = timer
prefs.putInt(KEY_INACTIVITY_TIMER, timer.ordinal)
}
override fun setRedactionLevel(level: DmRedactionLevel) {
mutableRedaction.value = level
prefs.putInt(KEY_REDACTION_LEVEL, level.ordinal)
prefs.putBoolean("redaction_user_set", true)
}
override fun setFirstRunCardSeen(seen: Boolean) {
mutableFirstRunSeen.value = seen
prefs.putBoolean(KEY_FIRST_RUN_CARD_SEEN, seen)
}
}
@@ -0,0 +1,951 @@
---
title: Messaging Privacy Lock
type: feat
status: active
date: 2026-06-30
origin: 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 corrections** — `CompositionLocal` 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 cuts** — **Windows 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 aligned** — `PreferencesPrivacyLockSettings` (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
Immediate–Never) 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/jvmMain` — `PlatformInfo` 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_ENROLLED` → `state.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:
```swift
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.kt` —
`LocalWindowFocus: ProvidableCompositionLocal<StateFlow<Boolean>>`.
Sourced from `window.addWindowFocusListener` events emitted into
a `MutableStateFlow`.
- `desktopApp/.../platform/WindowCaptureBlock.kt` —
`fun 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 `lockEnabled` → `sendDMNotification(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 integration** — `amy 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`](../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
- macOS Touch ID via Swift shim: [classycodeoss/java-touchid](https://github.com/classycodeoss/java-touchid)
- Rococoa (Java↔Cocoa bridge, used by Cyberduck): [iterate-ch/rococoa](https://github.com/iterate-ch/rococoa)
- Apple `LAContext.evaluatePolicy`: [developer.apple.com](https://developer.apple.com/documentation/LocalAuthentication/LAContext/evaluatePolicy(_:localizedReason:reply:))
- Microsoft `UserConsentVerifier`: [learn.microsoft.com](https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.ui.userconsentverifier)
- `IUserConsentVerifierInterop` (HWND attach): [Microsoft sdk-api](https://github.com/MicrosoftDocs/sdk-api/blob/docs/sdk-api-src/content/userconsentverifierinterop/nn-userconsentverifierinterop-iuserconsentverifierinterop.md)
- Bitwarden Linux polkit pattern (rejected for v1): [bitwarden/clients PR #4586](https://github.com/bitwarden/clients/pull/4586)
- macOS 15+ `sharingType` regression: [Tauri #14200](https://github.com/tauri-apps/tauri/issues/14200)
- Windows `SetWindowDisplayAffinity`: [learn.microsoft.com](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowdisplayaffinity)
- NIST 800-63B session timeouts: [pages.nist.gov](https://pages.nist.gov/800-63-4/sp800-63b.html)
- WhatsApp Chat Lock: [about.fb.com](https://about.fb.com/news/2023/05/whatsapp-chat-lock/)
- Signal Screen Lock: [support.signal.org](https://support.signal.org/hc/en-us/articles/360007059572-Screen-Lock)
### Related Work
- 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.