mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
Merge pull request #3432 from nrobi144/feat/desktop-privacy-lock
feat(desktop): Privacy lock for Messages column
This commit is contained in:
+42
@@ -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
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
+32
@@ -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
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the session as authenticated. Transitions from either
|
||||
* [LockState.Locked] (normal unlock path) or [LockState.Disabled]
|
||||
* (first-run banner path — enabling the lock while the user is
|
||||
* actively in Messages should NOT flash the lock screen).
|
||||
* No-op if already [LockState.Unlocked]. Starts the idle timer.
|
||||
*/
|
||||
fun onUnlockSuccess() {
|
||||
if (mutableState.value !is LockState.Unlocked) {
|
||||
mutableState.value = LockState.Unlocked
|
||||
restartIdleTimer()
|
||||
}
|
||||
// Always clear failed-attempt state on any authenticated flow — even
|
||||
// when transitioning from Disabled (banner "enable" path).
|
||||
onUnlockAttemptResetToZero()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed unlock attempt. Applies exponential backoff after
|
||||
* [PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES] failures: base 30 s,
|
||||
* doubling each further failure, capped at 5 min.
|
||||
*
|
||||
* @param nowMs current epoch millis (injected for testability).
|
||||
* @return the new [PrivacyLockSettings.lockedUntilEpochMs] value, or
|
||||
* null when no lockout yet applies.
|
||||
*/
|
||||
fun onFailedUnlockAttempt(nowMs: Long): Long? {
|
||||
val next = settings.failedUnlockAttempts.value + 1
|
||||
settings.setFailedUnlockAttempts(next)
|
||||
val overshoot = next - PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES
|
||||
if (overshoot < 0) {
|
||||
settings.setLockedUntilEpochMs(null)
|
||||
return null
|
||||
}
|
||||
val duration =
|
||||
(PrivacyLockSettings.LOCKOUT_BASE_MS shl overshoot)
|
||||
.coerceAtMost(PrivacyLockSettings.LOCKOUT_MAX_MS)
|
||||
val until = nowMs + duration
|
||||
settings.setLockedUntilEpochMs(until)
|
||||
return until
|
||||
}
|
||||
|
||||
/** Clear the failed-attempt counter and any active lockout. */
|
||||
fun onUnlockAttemptResetToZero() {
|
||||
settings.setFailedUnlockAttempts(0)
|
||||
settings.setLockedUntilEpochMs(null)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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>
|
||||
|
||||
/**
|
||||
* Non-null when the user has set a password on this device. Value is
|
||||
* either `salt$hash` (legacy, 100k PBKDF2 iterations) or `v1$salt$hash`
|
||||
* (current, 600k iterations) — never a raw password.
|
||||
* Platforms may use this differently: Android does not use it today
|
||||
* (biometric is authoritative); Desktop uses it as the unlock gate.
|
||||
*/
|
||||
val passwordHashed: StateFlow<String?>
|
||||
|
||||
/**
|
||||
* Number of consecutive failed unlock attempts. Reset to 0 on successful
|
||||
* unlock. Persisted across app restarts so a reboot cannot reset the
|
||||
* exponential backoff (see [lockedUntilEpochMs]).
|
||||
*/
|
||||
val failedUnlockAttempts: StateFlow<Int>
|
||||
|
||||
/**
|
||||
* Epoch millis until which the user is locked out from attempting to
|
||||
* unlock. `null` when no lockout is active. Persists across app
|
||||
* restarts. Trip point is 5 consecutive failures; schedule doubles
|
||||
* each subsequent failure and caps at 5 minutes.
|
||||
*/
|
||||
val lockedUntilEpochMs: StateFlow<Long?>
|
||||
|
||||
fun setLockEnabled(enabled: Boolean)
|
||||
|
||||
fun setInactivityTimer(timer: InactivityTimer)
|
||||
|
||||
fun setRedactionLevel(level: DmRedactionLevel)
|
||||
|
||||
fun setFirstRunCardSeen(seen: Boolean)
|
||||
|
||||
/** Store a versioned or legacy hash string; pass `null` to clear. */
|
||||
fun setPasswordHashed(saltAndHash: String?)
|
||||
|
||||
fun setFailedUnlockAttempts(count: Int)
|
||||
|
||||
fun setLockedUntilEpochMs(millis: Long?)
|
||||
|
||||
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"
|
||||
const val KEY_PASSWORD_HASHED = "password_hashed"
|
||||
const val KEY_FAILED_UNLOCK_ATTEMPTS = "failed_unlock_attempts"
|
||||
const val KEY_LOCKED_UNTIL_EPOCH_MS = "locked_until_epoch_ms"
|
||||
|
||||
/** Threshold at which exponential backoff begins. */
|
||||
const val LOCKOUT_TRIP_AFTER_FAILURES = 5
|
||||
|
||||
/** Base lockout on the [LOCKOUT_TRIP_AFTER_FAILURES]th failure. Doubles each subsequent failure. */
|
||||
const val LOCKOUT_BASE_MS = 30_000L
|
||||
|
||||
/** Max lockout duration after repeated failures. */
|
||||
const val LOCKOUT_MAX_MS = 5L * 60_000L
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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")
|
||||
}
|
||||
+46
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* 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)
|
||||
private val mutablePasswordHashed = MutableStateFlow<String?>(null)
|
||||
private val mutableFailedAttempts = MutableStateFlow(0)
|
||||
private val mutableLockedUntil = MutableStateFlow<Long?>(null)
|
||||
|
||||
override val lockEnabled: StateFlow<Boolean> = mutableLockEnabled.asStateFlow()
|
||||
override val inactivityTimer: StateFlow<InactivityTimer> = mutableTimer.asStateFlow()
|
||||
override val redactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
|
||||
override val firstRunCardSeen: StateFlow<Boolean> = mutableFirstRunSeen.asStateFlow()
|
||||
override val passwordHashed: StateFlow<String?> = mutablePasswordHashed.asStateFlow()
|
||||
override val failedUnlockAttempts: StateFlow<Int> = mutableFailedAttempts.asStateFlow()
|
||||
override val lockedUntilEpochMs: StateFlow<Long?> = mutableLockedUntil.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
|
||||
}
|
||||
|
||||
override fun setPasswordHashed(saltAndHash: String?) {
|
||||
mutablePasswordHashed.value = saltAndHash
|
||||
}
|
||||
|
||||
override fun setFailedUnlockAttempts(count: Int) {
|
||||
mutableFailedAttempts.value = count
|
||||
}
|
||||
|
||||
override fun setLockedUntilEpochMs(millis: Long?) {
|
||||
mutableLockedUntil.value = millis
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unlock_success_from_disabled_transitions_to_unlocked() =
|
||||
runTest {
|
||||
// First-run banner path: user enables lock + sets password while
|
||||
// already viewing Messages. State is Disabled at that moment, and
|
||||
// we want to stay Unlocked so the user isn't kicked to the lock
|
||||
// screen right after enabling.
|
||||
val settings = FakeSettings(lockEnabled = false)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
assertEquals(LockState.Disabled, state.state.value)
|
||||
state.onUnlockSuccess()
|
||||
assertEquals(LockState.Unlocked, state.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failed_attempts_below_threshold_do_not_trip_lockout() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES - 1) {
|
||||
assertEquals(null, state.onFailedUnlockAttempt(now))
|
||||
}
|
||||
assertEquals(null, settings.lockedUntilEpochMs.value)
|
||||
assertEquals(
|
||||
PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES - 1,
|
||||
settings.failedUnlockAttempts.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fifth_failure_trips_base_lockout() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) {
|
||||
state.onFailedUnlockAttempt(now)
|
||||
}
|
||||
val expected = now + PrivacyLockSettings.LOCKOUT_BASE_MS
|
||||
assertEquals(expected, settings.lockedUntilEpochMs.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lockout_doubles_and_caps_at_maximum() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
// 5th failure → base (30s)
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) }
|
||||
val base = settings.lockedUntilEpochMs.value!! - now
|
||||
assertEquals(PrivacyLockSettings.LOCKOUT_BASE_MS, base)
|
||||
// 6th → doubles to 60s
|
||||
state.onFailedUnlockAttempt(now)
|
||||
assertEquals(PrivacyLockSettings.LOCKOUT_BASE_MS * 2, settings.lockedUntilEpochMs.value!! - now)
|
||||
// Enough further failures to hit the cap
|
||||
repeat(20) { state.onFailedUnlockAttempt(now) }
|
||||
assertEquals(PrivacyLockSettings.LOCKOUT_MAX_MS, settings.lockedUntilEpochMs.value!! - now)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unlock_success_clears_backoff_state() =
|
||||
runTest {
|
||||
val settings = FakeSettings(lockEnabled = true)
|
||||
val state = MessagesLockState(settings, backgroundScope)
|
||||
val now = 1_000_000L
|
||||
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) }
|
||||
assertTrue(settings.lockedUntilEpochMs.value != null)
|
||||
assertTrue(settings.failedUnlockAttempts.value > 0)
|
||||
state.onUnlockSuccess()
|
||||
assertEquals(null, settings.lockedUntilEpochMs.value)
|
||||
assertEquals(0, settings.failedUnlockAttempts.value)
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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_FAILED_UNLOCK_ATTEMPTS
|
||||
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_LOCKED_UNTIL_EPOCH_MS
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_LOCK_ENABLED
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_PASSWORD_HASHED
|
||||
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))
|
||||
private val mutablePasswordHashed = MutableStateFlow<String?>(prefs.get(KEY_PASSWORD_HASHED, null))
|
||||
private val mutableFailedAttempts = MutableStateFlow(prefs.getInt(KEY_FAILED_UNLOCK_ATTEMPTS, 0))
|
||||
private val mutableLockedUntil =
|
||||
MutableStateFlow<Long?>(
|
||||
prefs.getLong(KEY_LOCKED_UNTIL_EPOCH_MS, -1L).takeIf { it > 0 },
|
||||
)
|
||||
|
||||
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 val passwordHashed: StateFlow<String?> = mutablePasswordHashed.asStateFlow()
|
||||
override val failedUnlockAttempts: StateFlow<Int> = mutableFailedAttempts.asStateFlow()
|
||||
override val lockedUntilEpochMs: StateFlow<Long?> = mutableLockedUntil.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)
|
||||
}
|
||||
|
||||
override fun setPasswordHashed(saltAndHash: String?) {
|
||||
mutablePasswordHashed.value = saltAndHash
|
||||
if (saltAndHash == null) prefs.remove(KEY_PASSWORD_HASHED) else prefs.put(KEY_PASSWORD_HASHED, saltAndHash)
|
||||
}
|
||||
|
||||
override fun setFailedUnlockAttempts(count: Int) {
|
||||
mutableFailedAttempts.value = count
|
||||
prefs.putInt(KEY_FAILED_UNLOCK_ATTEMPTS, count)
|
||||
}
|
||||
|
||||
override fun setLockedUntilEpochMs(millis: Long?) {
|
||||
mutableLockedUntil.value = millis
|
||||
if (millis == null) prefs.remove(KEY_LOCKED_UNTIL_EPOCH_MS) else prefs.putLong(KEY_LOCKED_UNTIL_EPOCH_MS, millis)
|
||||
}
|
||||
}
|
||||
@@ -294,6 +294,10 @@ fun main() {
|
||||
// Callback set by App() for single pane navigation from MenuBar
|
||||
var navigateToScreen by remember { mutableStateOf<((DeckColumnType) -> Unit)?>(null) }
|
||||
|
||||
// Messages privacy lock CompositionLocals are provided inside App()
|
||||
// itself (see App() around line ~700) so tests that call App()
|
||||
// directly — bypassing this Main.kt Window shell — still get them.
|
||||
|
||||
// Window title-bar / taskbar thumbnail icon. On macOS the source logo
|
||||
// is wrapped in a squircle so it matches every other dock icon; on
|
||||
// other platforms the raw transparent logo is used as-is.
|
||||
@@ -682,6 +686,85 @@ fun App(
|
||||
val singlePaneState = remember { SinglePaneState() }
|
||||
val pinnedNavBarState = remember { PinnedNavBarState(workspaceManager).also { it.loadFromWorkspace() } }
|
||||
|
||||
// Messages privacy lock — app-global settings + state holder, scoped to
|
||||
// App() so they survive appRestartKey rebuilds but rebuild on genuine app
|
||||
// restart. Provided as CompositionLocals right here so both production
|
||||
// (called from application { Window { App() } }) and tests (which call
|
||||
// App() directly, bypassing outer providers) see them.
|
||||
val appScope = rememberCoroutineScope()
|
||||
val privacyLockSettings =
|
||||
remember {
|
||||
com.vitorpamplona.amethyst.commons.privacylock
|
||||
.PreferencesPrivacyLockSettings()
|
||||
}
|
||||
val messagesLockState =
|
||||
remember(privacyLockSettings) {
|
||||
com.vitorpamplona.amethyst.commons.privacylock
|
||||
.MessagesLockState(privacyLockSettings, appScope)
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState provides messagesLockState,
|
||||
com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings provides privacyLockSettings,
|
||||
) {
|
||||
AppInner(
|
||||
layoutMode = layoutMode,
|
||||
onLayoutModeChange = onLayoutModeChange,
|
||||
deckState = deckState,
|
||||
workspaceManager = workspaceManager,
|
||||
accountManager = accountManager,
|
||||
showComposeDialog = showComposeDialog,
|
||||
showAppDrawer = showAppDrawer,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onDismissComposeDialog = onDismissComposeDialog,
|
||||
onDismissAppDrawer = onDismissAppDrawer,
|
||||
onShowAppDrawer = onShowAppDrawer,
|
||||
replyToNote = replyToNote,
|
||||
showImportFollowListDialog = showImportFollowListDialog,
|
||||
onShowImportFollowListDialog = onShowImportFollowListDialog,
|
||||
onDismissImportFollowListDialog = onDismissImportFollowListDialog,
|
||||
onRestartApp = onRestartApp,
|
||||
torManager = torManager,
|
||||
torTypeFlow = torTypeFlow,
|
||||
externalPortFlow = externalPortFlow,
|
||||
initialTorSettings = initialTorSettings,
|
||||
onNavigateToScreen = onNavigateToScreen,
|
||||
testOverrides = testOverrides,
|
||||
singlePaneState = singlePaneState,
|
||||
pinnedNavBarState = pinnedNavBarState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppInner(
|
||||
layoutMode: LayoutMode,
|
||||
onLayoutModeChange: (LayoutMode) -> Unit,
|
||||
deckState: DeckState,
|
||||
workspaceManager: WorkspaceManager,
|
||||
accountManager: AccountManager,
|
||||
showComposeDialog: Boolean,
|
||||
showAppDrawer: Boolean,
|
||||
onShowComposeDialog: () -> Unit,
|
||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||
onDismissComposeDialog: () -> Unit,
|
||||
onDismissAppDrawer: () -> Unit,
|
||||
onShowAppDrawer: () -> Unit,
|
||||
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
|
||||
showImportFollowListDialog: Boolean,
|
||||
onShowImportFollowListDialog: () -> Unit,
|
||||
onDismissImportFollowListDialog: () -> Unit,
|
||||
onRestartApp: () -> Unit,
|
||||
torManager: com.vitorpamplona.amethyst.commons.tor.ITorManager,
|
||||
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
|
||||
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
|
||||
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
|
||||
onNavigateToScreen: ((DeckColumnType) -> Unit) -> Unit,
|
||||
testOverrides: LaunchTestOverrides?,
|
||||
singlePaneState: SinglePaneState,
|
||||
pinnedNavBarState: PinnedNavBarState,
|
||||
) {
|
||||
// Register single pane navigation callback for MenuBar shortcuts
|
||||
LaunchedEffect(singlePaneState) {
|
||||
onNavigateToScreen { screen -> singlePaneState.navigate(screen) }
|
||||
@@ -2088,6 +2171,13 @@ fun RelaySettingsScreen(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Privacy lock section
|
||||
com.vitorpamplona.amethyst.desktop.ui.settings
|
||||
.PrivacyLockSettingsScreen()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Content Filters section — hashtag-spam filter and future
|
||||
// content-moderation toggles.
|
||||
Text(
|
||||
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.security
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockState
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Desktop equivalent of `MessagesLockGate`. Uses password verification
|
||||
* synchronously — no async CredentialPrompter round-trip needed.
|
||||
*
|
||||
* Renders content when Disabled / Unlocked; renders an inline password
|
||||
* input when Locked. If no password has been set, prompts the user to set
|
||||
* one first (this fires from the settings toggle in normal flow, so the
|
||||
* fallback exists only as a safety net).
|
||||
*
|
||||
* Branch selection is SYNCHRONOUS in composition — no LaunchedEffect
|
||||
* guard — closing the deep-link race (plan §Security Hardening H1).
|
||||
*
|
||||
* Enforces exponential backoff after repeated failed attempts (5 fails →
|
||||
* 30 s, doubling, capped at 5 min). Backoff state persists across restarts.
|
||||
*/
|
||||
@Composable
|
||||
fun DesktopMessagesLockGate(content: @Composable () -> Unit) {
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val current by lockState.state.collectAsState()
|
||||
|
||||
DisposableEffect(lockState) {
|
||||
onDispose { lockState.onLeaveRoute() }
|
||||
}
|
||||
|
||||
when (current) {
|
||||
is LockState.Locked -> DesktopLockScreen()
|
||||
else -> content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopLockScreen() {
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val settings = LocalPrivacyLockSettings.current
|
||||
val stored by settings.passwordHashed.collectAsState()
|
||||
val lockedUntil by settings.lockedUntilEpochMs.collectAsState()
|
||||
|
||||
var input by remember { mutableStateOf("") }
|
||||
var showError by remember { mutableStateOf(false) }
|
||||
var remainingMs by remember { mutableStateOf(lockoutRemaining(lockedUntil)) }
|
||||
|
||||
LaunchedEffect(lockedUntil) {
|
||||
while (true) {
|
||||
val r = lockoutRemaining(lockedUntil)
|
||||
remainingMs = r
|
||||
if (r <= 0) break
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
val submit: () -> Unit = {
|
||||
if (remainingMs <= 0) {
|
||||
val ok = stored?.let { PasswordHasher.verify(input.toCharArray(), it) } == true
|
||||
if (ok) {
|
||||
input = ""
|
||||
showError = false
|
||||
lockState.onUnlockSuccess()
|
||||
} else {
|
||||
showError = true
|
||||
lockState.onFailedUnlockAttempt(System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Box(modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = "Messages locked",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Box(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "Enter your privacy-lock password to view messages",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(max = 320.dp),
|
||||
)
|
||||
Box(modifier = Modifier.size(24.dp))
|
||||
if (stored == null) {
|
||||
Text(
|
||||
text = "No password is set yet. Open Settings → Privacy lock to set one.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(max = 320.dp),
|
||||
)
|
||||
Box(modifier = Modifier.size(16.dp))
|
||||
Button(onClick = { lockState.onCredentialUnavailable() }) {
|
||||
Text("Disable lock")
|
||||
}
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = {
|
||||
input = it
|
||||
showError = false
|
||||
},
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
enabled = remainingMs <= 0,
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||
isError = showError,
|
||||
supportingText =
|
||||
when {
|
||||
remainingMs > 0 -> {
|
||||
{ Text("Too many attempts. Try again in ${formatCountdown(remainingMs)}.") }
|
||||
}
|
||||
showError -> {
|
||||
{ Text("Wrong password") }
|
||||
}
|
||||
else -> null
|
||||
},
|
||||
modifier = Modifier.widthIn(max = 320.dp),
|
||||
)
|
||||
Box(modifier = Modifier.size(16.dp))
|
||||
Button(
|
||||
onClick = submit,
|
||||
enabled = input.isNotEmpty() && remainingMs <= 0,
|
||||
) {
|
||||
Text("Unlock")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun lockoutRemaining(untilEpochMs: Long?): Long {
|
||||
val until = untilEpochMs ?: return 0
|
||||
val diff = until - System.currentTimeMillis()
|
||||
return if (diff > 0) diff else 0
|
||||
}
|
||||
|
||||
private fun formatCountdown(millis: Long): String {
|
||||
val totalSeconds = (millis + 999) / 1000
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return if (minutes > 0) "${minutes}m ${seconds}s" else "${seconds}s"
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.security
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings
|
||||
|
||||
/** Provided once at the Desktop App root alongside LocalMessagesLockState. */
|
||||
val LocalPrivacyLockSettings =
|
||||
compositionLocalOf<PrivacyLockSettings> {
|
||||
error("LocalPrivacyLockSettings not provided — wrap App() with CompositionLocalProvider")
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.security
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState
|
||||
|
||||
/**
|
||||
* One-time discovery banner at the top of the Desktop Messages column.
|
||||
* Nudges users who haven't enabled the privacy lock yet. Modeled on
|
||||
* `OfflineBanner.kt` (AnimatedVisibility + Surface + Row).
|
||||
*
|
||||
* Visibility: `!lockEnabled && !firstRunCardSeen`. Dismissal is sticky
|
||||
* per the `firstRunCardSeen` flag — the banner does NOT reappear if
|
||||
* the user later disables the lock.
|
||||
*
|
||||
* Renders nothing when the gate is Locked (implicit — the gate replaces
|
||||
* content, so this composable never composes in that case).
|
||||
*/
|
||||
@Composable
|
||||
fun MessagesFirstRunBanner(onSaved: (String) -> Unit = {}) {
|
||||
val settings = LocalPrivacyLockSettings.current
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val enabled by settings.lockEnabled.collectAsState()
|
||||
val seen by settings.firstRunCardSeen.collectAsState()
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !enabled && !seen,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Lock the Messages tab?",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = "Require a password before Messages shows. Feed and profile stay open.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { settings.setFirstRunCardSeen(true) }) {
|
||||
Text("Not now")
|
||||
}
|
||||
Button(onClick = { showDialog = true }) {
|
||||
Text("Enable")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
SetPasswordDialog(
|
||||
existingHash = null,
|
||||
onDismiss = { showDialog = false },
|
||||
onConfirm = { newHash ->
|
||||
settings.setPasswordHashed(newHash)
|
||||
settings.setLockEnabled(true)
|
||||
settings.setFirstRunCardSeen(true)
|
||||
// Keep the user Unlocked — don't kick them to the lock screen
|
||||
// right after they just entered the password.
|
||||
lockState.onUnlockSuccess()
|
||||
showDialog = false
|
||||
onSaved("Privacy lock enabled")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.security
|
||||
|
||||
import java.security.SecureRandom
|
||||
import java.util.Base64
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
|
||||
/**
|
||||
* PBKDF2 password hashing for the Desktop privacy-lock password.
|
||||
*
|
||||
* Same primitive family as [com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage]
|
||||
* uses for its master-password key derivation.
|
||||
*
|
||||
* ## Versioned hash format
|
||||
*
|
||||
* Stored form is `<version>$<saltBase64>$<hashBase64>`:
|
||||
*
|
||||
* | Version | KDF | Iterations | Notes |
|
||||
* |---|---|---|---|
|
||||
* | (no prefix) | PBKDF2-HmacSHA256 | 100_000 | Legacy — verify-only for backward compat with hashes created before the OWASP 2023 bump. |
|
||||
* | `v1` | PBKDF2-HmacSHA256 | 600_000 | Current — matches OWASP Password Storage Cheat Sheet (2023). |
|
||||
*
|
||||
* [hash] always produces `v1$…` format. [verify] handles both legacy
|
||||
* bare-format and versioned inputs. Callers who want to opportunistically
|
||||
* upgrade an old hash should re-call [hash] after a successful verify and
|
||||
* persist the result.
|
||||
*
|
||||
* ## Cost tuning
|
||||
*
|
||||
* v1 at 600k iterations takes ~250 ms on a modern laptop (M1/M2 or
|
||||
* Intel 12th gen). Users unlock a handful of times per session, so 250 ms
|
||||
* is well within the tolerable range. At 20 attempts/second (verify
|
||||
* throughput) an attacker with the on-disk hash needs ~150M attempts
|
||||
* to exhaust a 6-char mixed-alpha space — orders of magnitude slower
|
||||
* than the legacy 100k value.
|
||||
*/
|
||||
object PasswordHasher {
|
||||
private const val ALGORITHM = "PBKDF2WithHmacSHA256"
|
||||
private const val KEY_LENGTH_BITS = 256
|
||||
private const val SALT_LENGTH_BYTES = 16
|
||||
|
||||
private const val V1_PREFIX = "v1"
|
||||
private const val V1_ITERATIONS = 600_000
|
||||
private const val LEGACY_ITERATIONS = 100_000
|
||||
|
||||
private const val SEP = '$'
|
||||
|
||||
fun hash(password: CharArray): String {
|
||||
val salt = ByteArray(SALT_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }
|
||||
val hash = pbkdf2(password, salt, V1_ITERATIONS)
|
||||
val encoder = Base64.getEncoder()
|
||||
return "$V1_PREFIX$SEP${encoder.encodeToString(salt)}$SEP${encoder.encodeToString(hash)}"
|
||||
}
|
||||
|
||||
fun verify(
|
||||
password: CharArray,
|
||||
stored: String,
|
||||
): Boolean {
|
||||
val (iterations, saltB64, hashB64) = parse(stored) ?: return false
|
||||
val decoder = Base64.getDecoder()
|
||||
val salt = runCatching { decoder.decode(saltB64) }.getOrNull() ?: return false
|
||||
val expected = runCatching { decoder.decode(hashB64) }.getOrNull() ?: return false
|
||||
val computed = pbkdf2(password, salt, iterations)
|
||||
return constantTimeEquals(computed, expected)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [stored] is in the legacy pre-v1 format. Callers may opt to
|
||||
* re-hash and persist after a successful [verify] to migrate the user
|
||||
* to v1 transparently.
|
||||
*/
|
||||
fun isLegacyFormat(stored: String): Boolean {
|
||||
val parts = stored.split(SEP)
|
||||
return parts.size == 2
|
||||
}
|
||||
|
||||
private data class ParsedHash(
|
||||
val iterations: Int,
|
||||
val saltB64: String,
|
||||
val hashB64: String,
|
||||
)
|
||||
|
||||
private fun parse(stored: String): ParsedHash? {
|
||||
val parts = stored.split(SEP)
|
||||
return when (parts.size) {
|
||||
2 -> ParsedHash(LEGACY_ITERATIONS, parts[0], parts[1])
|
||||
3 -> {
|
||||
if (parts[0] != V1_PREFIX) return null
|
||||
ParsedHash(V1_ITERATIONS, parts[1], parts[2])
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun pbkdf2(
|
||||
password: CharArray,
|
||||
salt: ByteArray,
|
||||
iterations: Int,
|
||||
): ByteArray {
|
||||
val spec = PBEKeySpec(password, salt, iterations, KEY_LENGTH_BITS)
|
||||
try {
|
||||
return SecretKeyFactory.getInstance(ALGORITHM).generateSecret(spec).encoded
|
||||
} finally {
|
||||
spec.clearPassword()
|
||||
}
|
||||
}
|
||||
|
||||
private fun constantTimeEquals(
|
||||
a: ByteArray,
|
||||
b: ByteArray,
|
||||
): Boolean {
|
||||
if (a.size != b.size) return false
|
||||
var diff = 0
|
||||
for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt())
|
||||
return diff == 0
|
||||
}
|
||||
}
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.security
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/** Enforced minimum length for a new/rotated password. */
|
||||
const val PRIVACY_LOCK_MIN_PASSWORD_LENGTH = 6
|
||||
|
||||
/**
|
||||
* Set or change the privacy-lock password.
|
||||
*
|
||||
* Pass [existingHash] = null when the user hasn't set a password yet
|
||||
* (first-run banner path, or a fresh Settings toggle). In that case the
|
||||
* dialog shows a single New password field with a reveal toggle. Pass a
|
||||
* real hash to force verification of the current password before letting
|
||||
* the user rotate — the dialog then shows a Current password field
|
||||
* followed by the New password field, each with its own reveal toggle.
|
||||
*
|
||||
* On successful validation, [onConfirm] is invoked with a fresh
|
||||
* `salt$hash` string ready for [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.setPasswordHashed].
|
||||
*
|
||||
* UX: auto-focus the first field on open, Enter submits, Escape cancels.
|
||||
* Real-time checklist under the New password field shows a green ✓ once
|
||||
* the length threshold is reached. Reveal toggles are per-field
|
||||
* independent so revealing Current does not reveal New.
|
||||
*/
|
||||
@Composable
|
||||
fun SetPasswordDialog(
|
||||
existingHash: String?,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit,
|
||||
) {
|
||||
val isChange = existingHash != null
|
||||
var current by remember { mutableStateOf("") }
|
||||
var new by remember { mutableStateOf("") }
|
||||
var currentError by remember { mutableStateOf<String?>(null) }
|
||||
var newError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val firstFieldFocus = remember { FocusRequester() }
|
||||
|
||||
val submit: () -> Unit = {
|
||||
val currentOk =
|
||||
!isChange ||
|
||||
(existingHash != null && PasswordHasher.verify(current.toCharArray(), existingHash))
|
||||
when {
|
||||
!currentOk -> {
|
||||
currentError = "Wrong password"
|
||||
newError = null
|
||||
}
|
||||
new.length < PRIVACY_LOCK_MIN_PASSWORD_LENGTH -> {
|
||||
currentError = null
|
||||
newError = "Must be at least $PRIVACY_LOCK_MIN_PASSWORD_LENGTH characters"
|
||||
}
|
||||
else -> {
|
||||
onConfirm(PasswordHasher.hash(new.toCharArray()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
firstFieldFocus.requestFocus()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false),
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
modifier = Modifier.width(440.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
DialogHeader(title = if (isChange) "Change password" else "Set a password")
|
||||
|
||||
if (!isChange) {
|
||||
Text(
|
||||
text = "Choose a password to lock the Messages tab. You'll enter it to unlock later.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (isChange) {
|
||||
PasswordField(
|
||||
value = current,
|
||||
onValueChange = {
|
||||
current = it
|
||||
currentError = null
|
||||
},
|
||||
label = "Current password",
|
||||
errorMessage = currentError,
|
||||
modifier = Modifier.focusRequester(firstFieldFocus),
|
||||
imeAction = ImeAction.Next,
|
||||
onImeAction = { /* Tab handled by focus system */ },
|
||||
)
|
||||
}
|
||||
|
||||
PasswordField(
|
||||
value = new,
|
||||
onValueChange = {
|
||||
new = it
|
||||
newError = null
|
||||
},
|
||||
label = "New password",
|
||||
errorMessage = newError,
|
||||
modifier =
|
||||
if (isChange) Modifier else Modifier.focusRequester(firstFieldFocus),
|
||||
imeAction = ImeAction.Done,
|
||||
onImeAction = { submit() },
|
||||
)
|
||||
|
||||
RequirementChecklistRow(satisfied = new.length >= PRIVACY_LOCK_MIN_PASSWORD_LENGTH)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
Button(
|
||||
onClick = submit,
|
||||
enabled = new.length >= PRIVACY_LOCK_MIN_PASSWORD_LENGTH,
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DialogHeader(title: String) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the user's current password before removing the privacy lock.
|
||||
*
|
||||
* On successful verification, [onConfirm] is invoked with no arguments.
|
||||
* The caller is responsible for clearing `passwordHashed` and disabling
|
||||
* `lockEnabled` — this dialog only proves possession of the current
|
||||
* password.
|
||||
*
|
||||
* Deliberate design choice: users cannot disable the lock without
|
||||
* demonstrating they know the password, matching the security posture
|
||||
* of Signal PIN, WhatsApp Chat Lock, and macOS FileVault disable.
|
||||
*/
|
||||
@Composable
|
||||
fun RemovePasswordDialog(
|
||||
existingHash: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val settings = LocalPrivacyLockSettings.current
|
||||
val lockedUntil by settings.lockedUntilEpochMs.collectAsState()
|
||||
|
||||
var current by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var remainingMs by remember { mutableStateOf(lockoutRemainingMs(lockedUntil)) }
|
||||
|
||||
val firstFieldFocus = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(lockedUntil) {
|
||||
while (true) {
|
||||
val r = lockoutRemainingMs(lockedUntil)
|
||||
remainingMs = r
|
||||
if (r <= 0) break
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
val submit: () -> Unit = {
|
||||
if (remainingMs <= 0) {
|
||||
if (PasswordHasher.verify(current.toCharArray(), existingHash)) {
|
||||
lockState.onUnlockSuccess() // clears failed-attempt state
|
||||
onConfirm()
|
||||
} else {
|
||||
error = "Wrong password"
|
||||
lockState.onFailedUnlockAttempt(System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
firstFieldFocus.requestFocus()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false),
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
modifier = Modifier.width(440.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
DialogHeader(title = "Remove password")
|
||||
|
||||
Text(
|
||||
text =
|
||||
"Enter your current password to remove the lock. " +
|
||||
"You'll need to set a new password if you turn the lock back on later.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
val effectiveError =
|
||||
when {
|
||||
remainingMs > 0 -> "Too many attempts. Try again in ${formatCountdownMs(remainingMs)}."
|
||||
else -> error
|
||||
}
|
||||
PasswordField(
|
||||
value = current,
|
||||
onValueChange = {
|
||||
current = it
|
||||
error = null
|
||||
},
|
||||
label = "Current password",
|
||||
errorMessage = effectiveError,
|
||||
modifier = Modifier.focusRequester(firstFieldFocus),
|
||||
imeAction = ImeAction.Done,
|
||||
onImeAction = { submit() },
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
Button(
|
||||
onClick = submit,
|
||||
enabled = current.isNotEmpty() && remainingMs <= 0,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) {
|
||||
Text("Remove")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun lockoutRemainingMs(untilEpochMs: Long?): Long {
|
||||
val until = untilEpochMs ?: return 0
|
||||
val diff = until - System.currentTimeMillis()
|
||||
return if (diff > 0) diff else 0
|
||||
}
|
||||
|
||||
private fun formatCountdownMs(millis: Long): String {
|
||||
val totalSeconds = (millis + 999) / 1000
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return if (minutes > 0) "${minutes}m ${seconds}s" else "${seconds}s"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
errorMessage: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
imeAction: ImeAction = ImeAction.Done,
|
||||
onImeAction: () -> Unit = {},
|
||||
) {
|
||||
var revealed by remember { mutableStateOf(false) }
|
||||
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && imeAction == ImeAction.Done) {
|
||||
onImeAction()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
visualTransformation = if (revealed) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = imeAction,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { onImeAction() }, onNext = { onImeAction() }),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { revealed = !revealed }) {
|
||||
Icon(
|
||||
symbol = if (revealed) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility,
|
||||
contentDescription = if (revealed) "Hide password" else "Show password",
|
||||
)
|
||||
}
|
||||
},
|
||||
isError = errorMessage != null,
|
||||
supportingText =
|
||||
errorMessage?.let {
|
||||
{
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequirementChecklistRow(satisfied: Boolean) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = if (satisfied) MaterialSymbols.CheckCircle else MaterialSymbols.Circle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint =
|
||||
if (satisfied) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = "Min $PRIVACY_LOCK_MIN_PASSWORD_LENGTH characters",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color =
|
||||
if (satisfied) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+47
-25
@@ -23,14 +23,18 @@ package com.vitorpamplona.amethyst.desktop.ui.chats
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -63,8 +67,10 @@ import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.security.MessagesFirstRunBanner
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.Cursor
|
||||
|
||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
@@ -126,31 +132,47 @@ fun DesktopMessagesScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (compactMode) {
|
||||
CompactMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
listState = listState,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
scope = scope,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
onShowRelayPicker = { showDmRelayPicker = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
} else {
|
||||
SplitMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
listState = listState,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
scope = scope,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
onShowRelayPicker = { showDmRelayPicker = true },
|
||||
keyHandler = keyHandler,
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val snackbarScope = rememberCoroutineScope()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MessagesFirstRunBanner(onSaved = { msg ->
|
||||
snackbarScope.launch { snackbarHostState.showSnackbar(msg) }
|
||||
})
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
if (compactMode) {
|
||||
CompactMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
listState = listState,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
scope = scope,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
onShowRelayPicker = { showDmRelayPicker = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
} else {
|
||||
SplitMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
listState = listState,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
scope = scope,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
onShowRelayPicker = { showDmRelayPicker = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+10
-8
@@ -361,14 +361,16 @@ internal fun RootContent(
|
||||
}
|
||||
|
||||
DeckColumnType.Messages -> {
|
||||
DesktopMessagesScreen(
|
||||
account = iAccount,
|
||||
cacheProvider = localCache,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
compactMode = compactMode,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
)
|
||||
com.vitorpamplona.amethyst.desktop.security.DesktopMessagesLockGate {
|
||||
DesktopMessagesScreen(
|
||||
account = iAccount,
|
||||
cacheProvider = localCache,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
compactMode = compactMode,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DeckColumnType.Search -> {
|
||||
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.DmRedactionLevel
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.InactivityTimer
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings
|
||||
import com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings
|
||||
import com.vitorpamplona.amethyst.desktop.security.RemovePasswordDialog
|
||||
import com.vitorpamplona.amethyst.desktop.security.SetPasswordDialog
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Desktop privacy-lock settings pane. Column + Card layout (no Scaffold) —
|
||||
* matches `LocalRelaySettingsScreen`.
|
||||
*/
|
||||
@Composable
|
||||
fun PrivacyLockSettingsScreen() {
|
||||
val settings = LocalPrivacyLockSettings.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
LockToggleCard(
|
||||
settings = settings,
|
||||
onSaved = { message ->
|
||||
scope.launch { snackbarHostState.showSnackbar(message) }
|
||||
},
|
||||
)
|
||||
InactivityCard(settings)
|
||||
RedactionCard(settings)
|
||||
LimitationsCard()
|
||||
}
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockToggleCard(
|
||||
settings: PrivacyLockSettings,
|
||||
onSaved: (String) -> Unit,
|
||||
) {
|
||||
val enabled by settings.lockEnabled.collectAsState()
|
||||
val stored by settings.passwordHashed.collectAsState()
|
||||
var showSetPassword by remember { mutableStateOf(false) }
|
||||
var showRemovePassword by remember { mutableStateOf(false) }
|
||||
var pendingEnable by remember { mutableStateOf(false) }
|
||||
|
||||
SettingsCard(title = "Lock the Messages tab") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
"Require a password before the Messages column shows. " +
|
||||
"The rest of the app stays open.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = { on ->
|
||||
if (on) {
|
||||
if (stored == null) {
|
||||
pendingEnable = true
|
||||
showSetPassword = true
|
||||
} else {
|
||||
settings.setLockEnabled(true)
|
||||
}
|
||||
} else {
|
||||
// Disabling requires the current password. If none is set
|
||||
// (corner case — user cleared prefs manually), just
|
||||
// disable directly.
|
||||
if (stored != null) {
|
||||
showRemovePassword = true
|
||||
} else {
|
||||
settings.setLockEnabled(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (enabled && stored != null) {
|
||||
Row {
|
||||
OutlinedButton(onClick = { showSetPassword = true }) {
|
||||
Text("Change password")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSetPassword) {
|
||||
val wasFirstSet = stored == null
|
||||
SetPasswordDialog(
|
||||
existingHash = stored,
|
||||
onDismiss = {
|
||||
showSetPassword = false
|
||||
pendingEnable = false
|
||||
},
|
||||
onConfirm = { newHash ->
|
||||
settings.setPasswordHashed(newHash)
|
||||
if (pendingEnable) settings.setLockEnabled(true)
|
||||
showSetPassword = false
|
||||
pendingEnable = false
|
||||
onSaved(if (wasFirstSet) "Privacy lock enabled" else "Password updated")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
stored?.let { hash ->
|
||||
if (showRemovePassword) {
|
||||
RemovePasswordDialog(
|
||||
existingHash = hash,
|
||||
onDismiss = { showRemovePassword = false },
|
||||
onConfirm = {
|
||||
settings.setLockEnabled(false)
|
||||
settings.setPasswordHashed(null)
|
||||
showRemovePassword = false
|
||||
onSaved("Privacy lock removed")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InactivityCard(settings: PrivacyLockSettings) {
|
||||
val enabled by settings.lockEnabled.collectAsState()
|
||||
val timer by settings.inactivityTimer.collectAsState()
|
||||
if (!enabled) return
|
||||
|
||||
SettingsCard(title = "Auto-lock after") {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Re-lock Messages after this much inactivity.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Button(onClick = { expanded = true }) {
|
||||
Text(timer.label())
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
InactivityTimer.entries.forEach { entry ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(entry.label()) },
|
||||
onClick = {
|
||||
settings.setInactivityTimer(entry)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RedactionCard(settings: PrivacyLockSettings) {
|
||||
val enabled by settings.lockEnabled.collectAsState()
|
||||
val level by settings.redactionLevel.collectAsState()
|
||||
if (!enabled) return
|
||||
|
||||
SettingsCard(title = "DM notification preview") {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
"When lock is on, DM notifications hide sender + message. " +
|
||||
"Change to Full to show them.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Button(onClick = { expanded = true }) {
|
||||
Text(level.label())
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DmRedactionLevel.entries.forEach { entry ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(entry.label()) },
|
||||
onClick = {
|
||||
settings.setRedactionLevel(entry)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LimitationsCard() {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "What this lock does not protect against",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
"This lock hides the Messages column on an unattended device. " +
|
||||
"It does NOT protect against: filesystem access, memory dumps, " +
|
||||
"attached debuggers, or screen-recording apps you've granted access. " +
|
||||
"Your Nostr private key is still stored as it is today.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsCard(
|
||||
title: String,
|
||||
content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
content(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun InactivityTimer.label(): String =
|
||||
when (this) {
|
||||
InactivityTimer.OneMin -> "1 min"
|
||||
InactivityTimer.FiveMin -> "5 min"
|
||||
InactivityTimer.FifteenMin -> "15 min"
|
||||
InactivityTimer.OneHour -> "1 hour"
|
||||
InactivityTimer.Never -> "Never"
|
||||
}
|
||||
|
||||
private fun DmRedactionLevel.label(): String =
|
||||
when (this) {
|
||||
DmRedactionLevel.Generic -> "Hidden"
|
||||
DmRedactionLevel.Full -> "Full"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,277 @@
|
||||
---
|
||||
title: Messaging Privacy Lock — Desktop Manual Testing Sheet
|
||||
type: test
|
||||
status: active
|
||||
date: 2026-06-30
|
||||
---
|
||||
|
||||
# Messaging Privacy Lock — Desktop Manual Testing Sheet
|
||||
|
||||
Companion to `2026-06-30-feat-messaging-privacy-lock-plan.md` and
|
||||
`2026-06-30-feat-messaging-privacy-lock-brainstorm.md`.
|
||||
|
||||
## What ships on the `worktree-brainstorm-messaging-privacy-lock` branch
|
||||
|
||||
- ✅ **Commons foundation** — headless cross-platform state machine,
|
||||
preferences with password-hashed field, gate composable primitives,
|
||||
idle-timer modifier, 8 unit tests all green
|
||||
(`./gradlew :commons:jvmTest --tests "*MessagesLockStateTest*"`).
|
||||
- ✅ **Desktop wiring** — `DesktopMessagesLockGate` wrapping the
|
||||
Messages deck column; PBKDF2 password unlock (100k iterations,
|
||||
16-byte salt); `PrivacyLockSettingsScreen` embedded in the Settings
|
||||
pane; app-global CompositionLocals provided in `Main.kt`.
|
||||
|
||||
## What is NOT in this branch
|
||||
|
||||
- **Android app module changes** — reverted. Foundation code in
|
||||
`commons/` still compiles for Android; wiring is a separate future
|
||||
workstream.
|
||||
- **macOS Touch ID / Windows Hello / Linux biometrics** — Desktop v1
|
||||
uses a PBKDF2 password gate. Native biometric shims (Swift
|
||||
`LAContext`, Windows Hello, polkit) remain in Future Considerations
|
||||
per the plan.
|
||||
- **Notification redaction call site** — the `DmRedactionLevel` policy
|
||||
exists in `commons/.../privacylock/`; the actual write into
|
||||
Android's `NotificationUtils.kt` or a desktop notification path is
|
||||
deferred (no desktop notifications wired today).
|
||||
- **Screen-capture window blocks** — `NSWindowSharingNone` /
|
||||
`SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)` deferred. macOS 15+
|
||||
broke `sharingType` anyway; Windows can revisit as a v1.1 patch.
|
||||
- **Blur-on-unfocus** — Desktop deferred to v1.1; the gate's
|
||||
onLeaveRoute-fires-on-column-navigation already covers the "left
|
||||
Messages" scenario.
|
||||
- **Marmot group chats** — Marmot UI is Android-only in this repo.
|
||||
Desktop only gates the primary Messages column.
|
||||
- **Security hardening H2–H6** — crash-report scrubber, DM-content
|
||||
log audit, MessagingStyle history flush, NotificationListener
|
||||
redaction of the main builder, bunker decrypt queue drain. Each is
|
||||
independent scope; see the plan's §Security Hardening Additions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- macOS host recommended (the primary Amethyst Desktop dev target).
|
||||
Also runs on Windows and Linux (untested for this feature).
|
||||
- Java 17+ (Compose Desktop bundles its own JBR).
|
||||
- A Nostr account with at least one DM conversation for meaningful
|
||||
gating.
|
||||
|
||||
## Automated verification
|
||||
|
||||
```bash
|
||||
# Unit tests for the state machine + settings interface
|
||||
./gradlew :commons:jvmTest --tests "com.vitorpamplona.amethyst.commons.privacylock.MessagesLockStateTest"
|
||||
|
||||
# Full commons + desktop compile
|
||||
./gradlew :commons:compileKotlinJvm
|
||||
./gradlew :desktopApp:compileKotlin
|
||||
|
||||
# Formatter
|
||||
./gradlew spotlessApply
|
||||
```
|
||||
|
||||
Expected: green.
|
||||
|
||||
## Manual testing paths
|
||||
|
||||
### Launch
|
||||
|
||||
```bash
|
||||
./gradlew :desktopApp:run
|
||||
```
|
||||
|
||||
Log in with an account that has one or more DM conversations.
|
||||
Add a Messages column to your deck via the "+" button → Messages, if
|
||||
not already present.
|
||||
|
||||
### Path A — Enable the lock
|
||||
|
||||
1. Click **Settings** in the sidebar.
|
||||
2. Scroll to the **"Lock the Messages tab"** card.
|
||||
- ✅ Card shows a description and a **Switch**.
|
||||
- ✅ Switch is OFF by default.
|
||||
3. Click the Switch to ON.
|
||||
- ✅ A **Set a password** dialog appears (because no password is
|
||||
set yet).
|
||||
- ✅ Dialog has: "New password" field, "Confirm new password"
|
||||
field, Cancel + Save buttons.
|
||||
4. Enter a password shorter than 4 characters, click Save.
|
||||
- ✅ Error: "New password must be at least 4 characters".
|
||||
5. Enter mismatched passwords, click Save.
|
||||
- ✅ Error: "Passwords don't match".
|
||||
6. Enter matching passwords ≥ 4 chars, click Save.
|
||||
- ✅ Dialog closes.
|
||||
- ✅ Switch is now ON.
|
||||
- ✅ Below the toggle: **"Change password"** button appears.
|
||||
- ✅ Two new cards appear: **"Auto-lock after"** (default: 5 min)
|
||||
and **"DM notification preview"** (default: Full).
|
||||
|
||||
### Path B — Lock behavior
|
||||
|
||||
1. Lock enabled with a password set. Navigate to the Messages deck
|
||||
column.
|
||||
- ✅ **Lock screen** renders with a padlock icon, "Messages
|
||||
locked" title, "Enter your privacy-lock password" subtitle, a
|
||||
password field, and an Unlock button.
|
||||
- ✅ The chat list is NOT visible behind the lock screen.
|
||||
2. Click Unlock without typing.
|
||||
- ✅ Button is disabled.
|
||||
3. Type a wrong password, press Enter (or click Unlock).
|
||||
- ✅ "Wrong password" error under the field.
|
||||
- ✅ Chat still not visible.
|
||||
4. Type the correct password, press Enter.
|
||||
- ✅ Lock screen disappears.
|
||||
- ✅ Full Messages column visible with conversations + chat pane.
|
||||
|
||||
### Path C — Auto re-lock triggers
|
||||
|
||||
1. Unlocked. Set inactivity timer to **1 min** via Settings →
|
||||
Privacy lock → Auto-lock after.
|
||||
2. Return to Messages, don't interact for 60 seconds.
|
||||
- ✅ Column re-locks; password field reappears.
|
||||
3. Unlock. Scroll a chat back and forth for 30 seconds.
|
||||
- ✅ Column stays unlocked (user interaction resets timer).
|
||||
4. Unlock. Navigate to Feed or Discover column.
|
||||
- ✅ Return to Messages → **re-locked immediately** (leave-route
|
||||
trigger via `DisposableEffect(onDispose)`).
|
||||
5. Set timer to **Never**.
|
||||
- ✅ Wait 2 minutes without input → column stays unlocked.
|
||||
- ✅ Navigating away still re-locks (leave-route independent).
|
||||
|
||||
### Path D — Deep-link race (security H1)
|
||||
|
||||
Not directly testable in this branch — Desktop deck columns navigate
|
||||
via the sidebar, so there's no true "cold-start-to-chatroom deep link"
|
||||
flow. However, the invariant still applies: the gate reads
|
||||
`MessagesLockState.state` synchronously in composition (no
|
||||
`LaunchedEffect` guard). Verify by:
|
||||
|
||||
1. Enable lock, quit the app.
|
||||
2. Cold-start (`./gradlew :desktopApp:run`).
|
||||
3. Add the Messages column immediately.
|
||||
- ✅ Lock screen appears **from the first frame** — never see chat
|
||||
content flash before the lock overlay paints.
|
||||
|
||||
### Path E — Change / clear password
|
||||
|
||||
1. Lock enabled. Settings → Privacy lock → **Change password**.
|
||||
2. Dialog opens with: **Current password** field, New password,
|
||||
Confirm.
|
||||
3. Enter wrong current password.
|
||||
- ✅ "Current password is wrong" error.
|
||||
4. Enter correct current, new, confirm → Save.
|
||||
- ✅ Old password no longer works on the lock screen.
|
||||
- ✅ New password unlocks.
|
||||
5. Toggle lock OFF, then back ON.
|
||||
- ✅ Password persists — you're NOT re-prompted to set one, since
|
||||
the hash is still stored (`passwordHashed` is not cleared on
|
||||
disable).
|
||||
|
||||
### Path F — Fallback (no password set)
|
||||
|
||||
Rare: user manually clears `passwordHashed` from
|
||||
`~/.java/.userPrefs/com/vitorpamplona/amethyst/privacylock/` while
|
||||
`lockEnabled = true`. To simulate:
|
||||
|
||||
1. Quit the app.
|
||||
2. Open the prefs file for the node and remove
|
||||
`password_hashed=<value>` while keeping `lock_enabled=true`.
|
||||
3. Restart.
|
||||
4. Navigate to Messages.
|
||||
- ✅ Lock screen shows: "No password is set yet. Open Settings →
|
||||
Privacy lock to set one." + a **Disable lock** button.
|
||||
5. Click **Disable lock**.
|
||||
- ✅ Lock is disabled globally; Messages column visible.
|
||||
|
||||
### Path G — Multi-account switch
|
||||
|
||||
1. Lock enabled, unlocked. In Messages, viewing a conversation.
|
||||
2. Switch account via the sidebar / account switcher.
|
||||
- ✅ Return to Messages → column re-locked (account switch counts
|
||||
as leave-route because the deck column composable is disposed
|
||||
and re-composed).
|
||||
|
||||
### Path H — Regression sweep
|
||||
|
||||
1. Feed / Discover / Wallet / Videos columns still function normally,
|
||||
no gate anywhere else.
|
||||
2. Sending a DM (after unlock) works — the compose pane is inside the
|
||||
gated content, so unlock allows send.
|
||||
3. Settings pane still shows all other sections (Media servers, Local
|
||||
Relay, Namecoin, Logout).
|
||||
4. Long-press-and-drag column reordering still works.
|
||||
|
||||
### Path J — First-run discovery banner
|
||||
|
||||
Added by `docs/plans/2026-07-01-feat-messages-first-run-lock-banner-plan.md`.
|
||||
|
||||
1. Fresh state — quit app, delete
|
||||
`~/.java/.userPrefs/com/vitorpamplona/amethyst/privacylock/prefs.xml`
|
||||
(or edit to clear `first_run_card_seen` + `lock_enabled`).
|
||||
2. Launch app, open Messages column.
|
||||
- ✅ Inline banner at top of Messages column: padlock icon +
|
||||
**"Lock the Messages tab?"** + description + **Not now** +
|
||||
**Enable** buttons.
|
||||
- ✅ Conversation list + chat pane still visible below (banner is
|
||||
~50dp, doesn't dominate).
|
||||
3. Click **Not now**.
|
||||
- ✅ Banner animates out (shrinkVertically + fadeOut).
|
||||
- ✅ Navigate away and back → banner does NOT return.
|
||||
- ✅ Quit + relaunch → banner still does not return.
|
||||
4. Reset state again. Click **Enable** on the banner.
|
||||
- ✅ **Set a password** dialog opens (same dialog as Settings).
|
||||
- ✅ Enter matching password ≥ 4 chars → Save.
|
||||
- ✅ Banner animates out.
|
||||
- ✅ Column STAYS interactive — **no lock-screen flash** after
|
||||
save (verifies the `onUnlockSuccess()` leniency).
|
||||
5. Continue browsing chats without unlock prompt.
|
||||
6. Navigate to Feed → back to Messages.
|
||||
- ✅ Lock screen appears (leave-route trigger fired after enable).
|
||||
- ✅ Unlock with the password you just set.
|
||||
7. Reset state. In Settings, enable the lock first (via the toggle
|
||||
card). Then open Messages.
|
||||
- ✅ Banner does NOT appear (`lockEnabled == true` suppresses it).
|
||||
8. Reset state. Show the banner. Dismiss with **Not now**. Now
|
||||
enable the lock via Settings. Later disable it via Settings.
|
||||
Return to Messages.
|
||||
- ✅ Banner does NOT reappear (dismissal is sticky — the
|
||||
`firstRunCardSeen` flag persists across enable/disable cycles).
|
||||
|
||||
### Path I — Cross-run persistence
|
||||
|
||||
1. Set a password, enable lock, set timer to 15 min, redaction to
|
||||
Hidden.
|
||||
2. Quit the app.
|
||||
3. Restart via `./gradlew :desktopApp:run`.
|
||||
- ✅ Lock is still enabled; navigating to Messages requires
|
||||
password.
|
||||
- ✅ Timer setting persists.
|
||||
- ✅ Redaction persists.
|
||||
|
||||
## Known-honest limitations
|
||||
|
||||
Copy in the Settings pane's **Limitations** card matches the actual
|
||||
threat model:
|
||||
|
||||
- Filesystem-level attacker can read the `java.util.prefs` node and
|
||||
see the *hash* — cannot recover the password without brute-force,
|
||||
but 4-char passwords are weak. Recommend ≥ 8 chars, but v1 min is 4.
|
||||
- Memory dumps expose the plaintext password briefly during
|
||||
verification. Not defended.
|
||||
- The Nostr private key (`nsec`) is stored via `SecureKeyStorage` as
|
||||
it is today — the lock does not touch it.
|
||||
|
||||
## Sign-off checklist
|
||||
|
||||
- [ ] Paths A–I executed on macOS
|
||||
- [ ] `./gradlew :commons:jvmTest` green
|
||||
- [ ] `./gradlew :desktopApp:compileKotlin` green
|
||||
- [ ] `./gradlew spotlessApply` clean
|
||||
- [ ] Follow-up issues filed for:
|
||||
- Windows / Linux manual QA
|
||||
- Screen-capture window blocks (macOS 15+ caveat)
|
||||
- Notification redaction call site (desktop notifications don't
|
||||
exist yet — deferred to when they do)
|
||||
- Native biometrics (Touch ID / Windows Hello / polkit)
|
||||
- Security hardening H2–H6 (crash scrub, log audit, MessagingStyle
|
||||
history, NotificationListener redaction, bunker queue drain)
|
||||
- Marmot group chat gating (once Marmot desktop UI lands)
|
||||
@@ -0,0 +1,381 @@
|
||||
---
|
||||
title: Messages First-Run Privacy Lock Banner (Desktop)
|
||||
type: feat
|
||||
status: completed
|
||||
date: 2026-07-01
|
||||
---
|
||||
|
||||
# Messages First-Run Privacy Lock Banner (Desktop)
|
||||
|
||||
## Overview
|
||||
|
||||
Inline discovery banner at the top of the Desktop Messages deck column
|
||||
that appears when:
|
||||
|
||||
1. The privacy lock is **not enabled**, AND
|
||||
2. The user has **not dismissed** it before (`firstRunCardSeen == false`).
|
||||
|
||||
Two actions: **Enable** (opens the existing password-set dialog inline)
|
||||
or **Not now** (sets `firstRunCardSeen = true` and hides forever). One
|
||||
composable, no new state, no new settings.
|
||||
|
||||
Small follow-up to the shipped privacy-lock feature at
|
||||
[`docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md`](2026-06-30-feat-messaging-privacy-lock-plan.md).
|
||||
|
||||
## Problem Statement / Motivation
|
||||
|
||||
The privacy lock exists and works (Desktop v1 password gate + auto
|
||||
re-lock + settings pane), but it has **zero in-app discovery**. A user
|
||||
who never opens Settings will never find it. Yet the Messages column
|
||||
is exactly the surface where the feature's value is felt — anyone who
|
||||
opens it is by definition a DM user.
|
||||
|
||||
Existing users are the sharper case: they can install this update and
|
||||
never learn the feature exists unless they read the release notes.
|
||||
A one-time banner solves that with negligible UX cost.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Add a `MessagesFirstRunBanner` composable rendered at the top of
|
||||
`DesktopMessagesScreen`, above the two-pane / single-pane layout. It
|
||||
sits inside the `DesktopMessagesLockGate` content lambda, so the
|
||||
banner is only ever visible in Unlocked / Disabled states — never
|
||||
over the lock screen.
|
||||
|
||||
Uses `AnimatedVisibility(expandVertically + fadeIn / shrinkVertically
|
||||
+ fadeOut)` for the show/hide transition — same pattern as
|
||||
`desktopApp/.../ui/components/OfflineBanner.kt:49-56` (the embedded
|
||||
local-relay work). Same visual treatment (Surface + Row + Icon + Text
|
||||
+ TextButton).
|
||||
|
||||
The dialog behind the **Enable** action is the existing
|
||||
`SetPasswordDialog` from `PrivacyLockSettingsScreen.kt:270`. That dialog
|
||||
is currently `private`. Extract it to a new shared file so both the
|
||||
Settings pane and the banner point at the same composable.
|
||||
|
||||
On successful password save from the banner:
|
||||
|
||||
1. `settings.setPasswordHashed(hash)`
|
||||
2. `settings.setLockEnabled(true)`
|
||||
3. `settings.setFirstRunCardSeen(true)` (dismiss banner)
|
||||
4. `messagesLockState.onUnlockSuccess()` — keep the user Unlocked so
|
||||
they don't immediately hit a lock screen after enabling.
|
||||
|
||||
The last call requires a tiny leniency change to `onUnlockSuccess()`
|
||||
so it works from `LockState.Disabled` as well as `Locked` (see
|
||||
Technical Considerations below).
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### Architecture
|
||||
|
||||
- **New file**: `desktopApp/.../security/MessagesFirstRunBanner.kt`
|
||||
— the banner composable + interaction logic.
|
||||
- **New file**: `desktopApp/.../security/SetPasswordDialog.kt` —
|
||||
extracted from `PrivacyLockSettingsScreen.kt`. Public visibility so
|
||||
the banner can reuse it.
|
||||
- **Edit**: `PrivacyLockSettingsScreen.kt` — delete the private
|
||||
`SetPasswordDialog` body, add an import for the new shared version.
|
||||
- **Edit**: `DesktopMessagesScreen.kt` — insert the banner as the
|
||||
first child inside the existing root `Column` (or wrap the current
|
||||
content in a Column if the top-level isn't already one). Passes
|
||||
through in single-pane and compact modes identically.
|
||||
- **Edit**: `MessagesLockState.kt` — relax `onUnlockSuccess()` to
|
||||
accept `Disabled` as a valid previous state (transitions to
|
||||
`Unlocked`).
|
||||
|
||||
### Visibility logic
|
||||
|
||||
```kotlin
|
||||
// MessagesFirstRunBanner.kt
|
||||
@Composable
|
||||
fun MessagesFirstRunBanner() {
|
||||
val settings = LocalPrivacyLockSettings.current
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val enabled by settings.lockEnabled.collectAsState()
|
||||
val seen by settings.firstRunCardSeen.collectAsState()
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !enabled && !seen,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Lock the Messages tab?",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = "Require a password before Messages shows. Feed and profile stay open.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { settings.setFirstRunCardSeen(true) }) {
|
||||
Text("Not now")
|
||||
}
|
||||
Button(onClick = { showDialog = true }) {
|
||||
Text("Enable")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
SetPasswordDialog(
|
||||
existingHash = null,
|
||||
onDismiss = { showDialog = false },
|
||||
onConfirm = { newHash ->
|
||||
settings.setPasswordHashed(newHash)
|
||||
settings.setLockEnabled(true)
|
||||
settings.setFirstRunCardSeen(true)
|
||||
lockState.onUnlockSuccess() // stay Unlocked; don't force user through the gate immediately
|
||||
showDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Placement inside DesktopMessagesScreen
|
||||
|
||||
The banner goes at the top of the composable's root layout. In
|
||||
`DesktopMessagesScreen.kt` the current `@Composable fun
|
||||
DesktopMessagesScreen(...)` returns either a two-pane or single-pane
|
||||
layout. Wrap in a Column:
|
||||
|
||||
```kotlin
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MessagesFirstRunBanner()
|
||||
// existing two-pane / single-pane content, weight(1f)
|
||||
}
|
||||
```
|
||||
|
||||
Both pane modes need `Modifier.weight(1f)` on the pane container so
|
||||
they consume the remaining space.
|
||||
|
||||
### State transitions
|
||||
|
||||
Enabling from the banner drives this sequence:
|
||||
|
||||
| Step | Action | LockState |
|
||||
|---|---|---|
|
||||
| Before click | User on Messages, lock off | `Disabled` |
|
||||
| `setPasswordHashed(hash)` | Persist | `Disabled` |
|
||||
| `setLockEnabled(true)` | Persist + StateFlow emit | `Disabled` → eventually `Locked` via init collector |
|
||||
| `setFirstRunCardSeen(true)` | Persist | (no state change) |
|
||||
| `onUnlockSuccess()` | Force state = `Unlocked` | `Unlocked` |
|
||||
|
||||
Race: the `settings.lockEnabled` collector on `windowScope` runs
|
||||
whenever it's next scheduled. If it fires between step 3 and step 4,
|
||||
state briefly is `Locked`. If it fires after step 4, state is
|
||||
`Unlocked` and the collector's `else if (state is Disabled)` branch
|
||||
skips (state isn't Disabled anymore). Either ordering ends at
|
||||
`Unlocked`. The gate re-reads `state` each frame, so no lock-screen
|
||||
flash — `Unlocked` reaches the UI within the same recomposition batch.
|
||||
|
||||
### `onUnlockSuccess()` leniency
|
||||
|
||||
Current signature only transitions from `Locked`:
|
||||
|
||||
```kotlin
|
||||
fun onUnlockSuccess() {
|
||||
if (mutableState.value is LockState.Locked) {
|
||||
mutableState.value = LockState.Unlocked
|
||||
restartIdleTimer()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Change to:
|
||||
|
||||
```kotlin
|
||||
fun onUnlockSuccess() {
|
||||
if (mutableState.value !is LockState.Unlocked) {
|
||||
mutableState.value = LockState.Unlocked
|
||||
restartIdleTimer()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
New semantics: "the caller has authenticated the user; go to Unlocked
|
||||
regardless of previous state." No behavior change for any existing
|
||||
call site (they only ever call this from `Locked`).
|
||||
|
||||
### Performance
|
||||
|
||||
- Banner is inside a `Column` that's already the root layout — one
|
||||
extra `AnimatedVisibility` composable.
|
||||
- Reads two `StateFlow<Boolean>` via `collectAsState`. Both are hot
|
||||
(backed by `MutableStateFlow`) so no cost when not changing.
|
||||
- When `firstRunCardSeen` flips true or `lockEnabled` flips true,
|
||||
`AnimatedVisibility` runs its exit animation (~300ms) and unmounts
|
||||
the banner. One-time cost per session.
|
||||
- No new coroutine, no new dispatcher.
|
||||
|
||||
### Accessibility
|
||||
|
||||
- Icon has `contentDescription = null` (decorative — title conveys
|
||||
the meaning).
|
||||
- Text uses semantic `titleSmall` / `bodySmall` styles for screen
|
||||
readers.
|
||||
- Buttons have explicit text labels.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph**: `MessagesFirstRunBanner` → reads
|
||||
`LocalPrivacyLockSettings` + `LocalMessagesLockState` → on Enable
|
||||
action → `SetPasswordDialog` → onConfirm → 4 sequential settings
|
||||
writes + `onUnlockSuccess()` → `MessagesLockState.state` transitions
|
||||
→ gate recomposes → user sees banner disappear + column stays
|
||||
interactive.
|
||||
- **Error propagation**: none new. Password validation stays inside
|
||||
`SetPasswordDialog` (existing errors: length, mismatch, wrong
|
||||
current). Password hash write to `java.util.prefs` is best-effort
|
||||
(the underlying `prefs.put(...)` swallows IO errors — matches the
|
||||
existing settings pane).
|
||||
- **State lifecycle risks**: none. The banner only writes; it never
|
||||
reads then re-writes. Dismissal is idempotent (`setFirstRunCardSeen(true)`
|
||||
is safe to call twice). Enabling from banner is atomic-ish (see
|
||||
race analysis above — end state is deterministic).
|
||||
- **API surface parity**: `SetPasswordDialog` becomes a shared
|
||||
composable. Verify both call sites (banner + settings pane) render
|
||||
the same behavior after extraction.
|
||||
- **Integration test scenarios** (manual — no test infra for this):
|
||||
1. Fresh install → open Messages → banner visible; enable → password
|
||||
dialog → set + save → banner disappears, column stays interactive,
|
||||
lock is on for next session.
|
||||
2. Fresh install → open Messages → banner visible; **Not now** →
|
||||
banner disappears, does not reappear on subsequent Messages entries
|
||||
even after restart.
|
||||
3. Existing user with lock already enabled from Settings pane →
|
||||
`!enabled` is false → banner never renders.
|
||||
4. User dismisses banner, later disables the lock from Settings →
|
||||
banner does NOT reappear (dismissal is sticky by design).
|
||||
5. Cold-start with lock enabled → gate takes over immediately →
|
||||
banner never renders.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `MessagesFirstRunBanner` composable exists at
|
||||
`desktopApp/.../security/MessagesFirstRunBanner.kt`
|
||||
- [x] `SetPasswordDialog` extracted to
|
||||
`desktopApp/.../security/SetPasswordDialog.kt` and reused by
|
||||
both `PrivacyLockSettingsScreen` and the new banner (single
|
||||
source of truth)
|
||||
- [x] `PrivacyLockSettingsScreen` still opens the same dialog after
|
||||
the extraction (visual + behavior identical)
|
||||
- [x] Banner appears at the top of the Desktop Messages column when
|
||||
`!lockEnabled && !firstRunCardSeen`
|
||||
- [x] Banner never appears when the gate is Locked (implicit — the
|
||||
gate replaces content)
|
||||
- [x] "Enable" button opens the password set dialog inline
|
||||
- [x] After successful password save from banner: lock is enabled,
|
||||
hash stored, banner dismissed, column stays Unlocked (no
|
||||
lock-screen flash)
|
||||
- [x] "Not now" dismisses the banner permanently across restarts
|
||||
- [x] Banner does NOT reappear after the user later disables the lock
|
||||
from Settings (dismissal is sticky per `firstRunCardSeen`
|
||||
semantics)
|
||||
- [x] `MessagesLockState.onUnlockSuccess()` accepts `Disabled` as a
|
||||
valid previous state (Unit-tested)
|
||||
- [x] `./gradlew :commons:jvmTest` green (all 8 existing tests +
|
||||
any new coverage for the leniency change)
|
||||
- [x] `./gradlew :desktopApp:compileKotlin` green
|
||||
- [x] `./gradlew spotlessApply` clean
|
||||
- [x] Manual testing sheet updated with a new Path (banner discovery)
|
||||
|
||||
## Alternative Approaches Considered
|
||||
|
||||
1. **Modal dialog on first Messages entry** — rejected in the original
|
||||
brainstorm as too intrusive. Same rejection applies here.
|
||||
2. **Sidebar/menu badge on the Messages icon** — subtle but obscure;
|
||||
users don't associate a lock-glyph badge with the concept "you can
|
||||
lock this." Also requires editing the sidebar composable, more
|
||||
surface area.
|
||||
3. **Toast/snackbar on Messages open** — dismissive; easily missed;
|
||||
auto-fades. Terrible for a discovery affordance.
|
||||
4. **Onboarding flow** — Amethyst has no first-run onboarding today
|
||||
and adding one for this feature is disproportionate.
|
||||
5. **Inline chip inside the empty-state view** — misses users who
|
||||
already have chats (skips the empty-state).
|
||||
6. **No banner, rely on release notes** — status quo. Fails the
|
||||
existing-user discovery case.
|
||||
|
||||
Chosen: **top-of-column banner** — visible to all Messages users,
|
||||
non-blocking, dismissable.
|
||||
|
||||
## Success Metrics
|
||||
|
||||
Amethyst has no telemetry. Qualitative signals only:
|
||||
|
||||
- Zero GitHub issues about "banner won't go away" within 30 days.
|
||||
- One or more community reports of users discovering + enabling the
|
||||
lock via the banner (Nostr threads, Reddit, Discord).
|
||||
- No reports of a lock-screen flash after enable (validates the
|
||||
`onUnlockSuccess()` leniency + state-transition ordering).
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
- **Depends on** the shipped privacy-lock feature (Phase 1 + Phase 5
|
||||
Desktop wiring already committed on this branch). Nothing new to
|
||||
import.
|
||||
- **Depends on** `firstRunCardSeen` field already in
|
||||
`PrivacyLockSettings` — no new persistence needed.
|
||||
- **Risk (low)**: extraction of `SetPasswordDialog` accidentally
|
||||
changes behavior in the Settings pane. Mitigation: extract as
|
||||
literal copy, replace call site, no logic change.
|
||||
- **Risk (low)**: `onUnlockSuccess()` leniency affects existing
|
||||
callers. Mitigation: only one existing caller
|
||||
(`DesktopLockScreen`) and it calls this from `Locked`, so the new
|
||||
semantics are backward-compatible.
|
||||
- **Risk (very low)**: state race between the `setLockEnabled` init
|
||||
collector and `onUnlockSuccess()`. Analysis above shows the end
|
||||
state is deterministic; no lock-screen flash reaches the user
|
||||
because `Unlocked` is set within the same UI recomposition batch.
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
|
||||
- **Parent plan**:
|
||||
[`docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md`](2026-06-30-feat-messaging-privacy-lock-plan.md)
|
||||
— the shipped feature this banner promotes.
|
||||
- **Parent brainstorm**:
|
||||
[`docs/brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md`](../brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md)
|
||||
— the resolved-Q "First-run prompt = inline dismissable card at top
|
||||
of Messages on first entry" that this banner implements.
|
||||
|
||||
### Internal References
|
||||
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/OfflineBanner.kt`
|
||||
— structural template (AnimatedVisibility + Surface + Row).
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt:270`
|
||||
— the `SetPasswordDialog` to extract.
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt:82`
|
||||
— the insertion point.
|
||||
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt`
|
||||
— file for the `onUnlockSuccess()` leniency edit.
|
||||
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt:41,57`
|
||||
— `firstRunCardSeen` + setter already exist.
|
||||
|
||||
### Related Work
|
||||
|
||||
- Manual testing sheet at
|
||||
`docs/plans/2026-06-30-privacy-lock-manual-testing.md` — will need
|
||||
a Path J (banner discovery) added post-implementation.
|
||||
Binary file not shown.
Reference in New Issue
Block a user