feat(privacylock): Desktop wiring — gate, password unlock, settings

Phase 5 (Desktop-only). Wraps the Messages deck column behind a
PBKDF2-hashed password gate; drops the Android-app slice.

- PrivacyLockSettings gains passwordHashed field + setter (salt$hash,
  base64). Backed by java.util.prefs on desktop.
- PasswordHasher: PBKDF2-HmacSHA256, 100k iterations, 16-byte salt,
  256-bit key, constant-time compare. Same primitive family as
  SecureKeyStorage.
- DesktopMessagesLockGate: synchronous branch select in composition
  (no LaunchedEffect guard) — closes the deep-link race per plan
  §Security Hardening H1. Renders content when Disabled/Unlocked;
  renders inline password TextField when Locked. Fires
  MessagesLockState.onLeaveRoute() in DisposableEffect onDispose so
  navigating away from the Messages column re-locks immediately.
- DesktopMessagesLockGate handles the "no password set" edge case
  with a Disable-lock affordance.
- LocalPrivacyLockSettings CompositionLocal + LocalMessagesLockState
  (from commons) both provided once at the App composition root in
  Main.kt. Constructed with the existing windowScope so the state
  holder's idle timer coroutines are lifecycle-scoped to the Window.
- DeckColumnContainer: DesktopMessagesScreen wrapped in
  DesktopMessagesLockGate for the Messages column.
- Desktop PrivacyLockSettingsScreen: Column + Card layout matching
  LocalRelaySettingsScreen (no Scaffold). Toggle, "Change password"
  affordance with a full set/change dialog (old + new + confirm),
  inactivity timer dropdown (1m / 5m / 15m / 1h / Never), redaction
  level dropdown (Hidden / Full), honest limitations copy. Auto-opens
  the set-password dialog if user toggles ON with no password set.
- Slotted into the existing Settings pane in Main.kt right after
  LocalRelaySettings.
This commit is contained in:
nrobi144
2026-07-01 11:35:52 +03:00
parent c4f647d01a
commit 1c0141aba1
9 changed files with 715 additions and 8 deletions
@@ -40,6 +40,14 @@ interface PrivacyLockSettings {
val redactionLevel: StateFlow<DmRedactionLevel>
val firstRunCardSeen: StateFlow<Boolean>
/**
* Non-null when the user has set a password on this device. Value is
* `salt$hash` (both base64) — 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?>
fun setLockEnabled(enabled: Boolean)
fun setInactivityTimer(timer: InactivityTimer)
@@ -48,6 +56,9 @@ interface PrivacyLockSettings {
fun setFirstRunCardSeen(seen: Boolean)
/** Store a `salt$hash` combined string; pass `null` to clear. */
fun setPasswordHashed(saltAndHash: String?)
companion object {
const val DEFAULT_LOCK_ENABLED = false
const val NODE_NAME = "com/vitorpamplona/amethyst/privacylock"
@@ -55,5 +66,6 @@ interface PrivacyLockSettings {
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"
}
}
@@ -41,11 +41,13 @@ class MessagesLockStateTest {
private val mutableTimer = MutableStateFlow(timer)
private val mutableRedaction = MutableStateFlow(DmRedactionLevel.DEFAULT)
private val mutableFirstRunSeen = MutableStateFlow(false)
private val mutablePasswordHashed = MutableStateFlow<String?>(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 fun setLockEnabled(enabled: Boolean) {
mutableLockEnabled.value = enabled
@@ -62,6 +64,10 @@ class MessagesLockStateTest {
override fun setFirstRunCardSeen(seen: Boolean) {
mutableFirstRunSeen.value = seen
}
override fun setPasswordHashed(saltAndHash: String?) {
mutablePasswordHashed.value = saltAndHash
}
}
@Test
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Compan
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_FIRST_RUN_CARD_SEEN
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_INACTIVITY_TIMER
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_LOCK_ENABLED
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings.Companion.KEY_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
@@ -51,11 +52,13 @@ class PreferencesPrivacyLockSettings(
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))
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 fun setLockEnabled(enabled: Boolean) {
mutableEnabled.value = enabled
@@ -84,4 +87,9 @@ class PreferencesPrivacyLockSettings(
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)
}
}
@@ -291,6 +291,21 @@ fun main() {
// Callback set by App() for single pane navigation from MenuBar
var navigateToScreen by remember { mutableStateOf<((DeckColumnType) -> Unit)?>(null) }
// Messages privacy lock: app-global settings + state holder. Initial
// LockState is seeded synchronously inside MessagesLockState from
// the java.util.prefs value so the first composition sees the correct
// state (deep-link race fix, plan §Security Hardening H1).
val privacyLockSettings =
remember {
com.vitorpamplona.amethyst.commons.privacylock
.PreferencesPrivacyLockSettings()
}
val messagesLockState =
remember {
com.vitorpamplona.amethyst.commons.privacylock
.MessagesLockState(privacyLockSettings, windowScope)
}
// 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.
@@ -602,6 +617,8 @@ fun main() {
LocalWindowState provides windowState,
LocalAwtWindow provides window,
LocalIsImmersiveFullscreen provides immersiveFullscreenState,
com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState provides messagesLockState,
com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings provides privacyLockSettings,
) {
key(appRestartKey) {
CompositionLocalProvider(
@@ -2074,6 +2091,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))
val logoutScope = rememberCoroutineScope()
OutlinedButton(
onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } },
@@ -0,0 +1,177 @@
/*
* 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.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
/**
* 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).
*/
@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 = com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings.current
val stored by settings.passwordHashed.collectAsState()
var input by remember { mutableStateOf("") }
var showError by remember { mutableStateOf(false) }
val submit: () -> Unit = {
val ok = stored?.let { PasswordHasher.verify(input.toCharArray(), it) } == true
if (ok) {
input = ""
showError = false
lockState.onUnlockSuccess()
} else {
showError = true
}
}
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(),
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { submit() }),
isError = showError,
supportingText =
if (showError) {
{ Text("Wrong password") }
} else {
null
},
modifier = Modifier.widthIn(max = 320.dp),
)
Box(modifier = Modifier.size(16.dp))
Button(onClick = submit, enabled = input.isNotEmpty()) {
Text("Unlock")
}
}
}
}
}
@@ -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")
}
@@ -0,0 +1,88 @@
/*
* 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 PIN / password.
*
* Same primitive family as [com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage]
* uses for its master-password key derivation. Stored form is
* `saltBase64$hashBase64` — never plaintext.
*
* Iteration count chosen to be light-enough for an interactive unlock (~50ms
* on modern hardware) while providing meaningful throttling on brute-force
* attempts on the on-disk hash.
*/
object PasswordHasher {
private const val ALGORITHM = "PBKDF2WithHmacSHA256"
private const val ITERATIONS = 100_000
private const val KEY_LENGTH_BITS = 256
private const val SALT_LENGTH_BYTES = 16
fun hash(password: CharArray): String {
val salt = ByteArray(SALT_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }
val hash = pbkdf2(password, salt)
val encoder = Base64.getEncoder()
return "${encoder.encodeToString(salt)}\$${encoder.encodeToString(hash)}"
}
fun verify(
password: CharArray,
stored: String,
): Boolean {
val parts = stored.split("$")
if (parts.size != 2) return false
val decoder = Base64.getDecoder()
val salt =
runCatching { decoder.decode(parts[0]) }.getOrNull() ?: return false
val expected =
runCatching { decoder.decode(parts[1]) }.getOrNull() ?: return false
val computed = pbkdf2(password, salt)
return constantTimeEquals(computed, expected)
}
private fun pbkdf2(
password: CharArray,
salt: ByteArray,
): 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
}
}
@@ -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 -> {
@@ -0,0 +1,360 @@
/*
* 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.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.AlertDialog
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.OutlinedTextField
import androidx.compose.material3.Switch
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.text.input.PasswordVisualTransformation
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.PasswordHasher
/**
* Desktop privacy-lock settings pane. Column + Card layout (no Scaffold)
* matches `LocalRelaySettingsScreen`.
*/
@Composable
fun PrivacyLockSettingsScreen() {
val settings = LocalPrivacyLockSettings.current
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
LockToggleCard(settings)
InactivityCard(settings)
RedactionCard(settings)
LimitationsCard()
}
}
@Composable
private fun LockToggleCard(settings: PrivacyLockSettings) {
val enabled by settings.lockEnabled.collectAsState()
val stored by settings.passwordHashed.collectAsState()
var showSetPassword 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 {
settings.setLockEnabled(false)
}
},
)
}
if (enabled && stored != null) {
Row {
OutlinedButton(onClick = { showSetPassword = true }) {
Text("Change password")
}
}
}
}
if (showSetPassword) {
SetPasswordDialog(
existingHash = stored,
onDismiss = {
showSetPassword = false
pendingEnable = false
},
onConfirm = { newHash ->
settings.setPasswordHashed(newHash)
if (pendingEnable) settings.setLockEnabled(true)
showSetPassword = false
pendingEnable = false
},
)
}
}
@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)
}
}
}
@Composable
private fun SetPasswordDialog(
existingHash: String?,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var current by remember { mutableStateOf("") }
var new1 by remember { mutableStateOf("") }
var new2 by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
val submit: () -> Unit = {
val currentOk =
existingHash == null ||
PasswordHasher.verify(current.toCharArray(), existingHash)
when {
!currentOk -> error = "Current password is wrong"
new1.length < 4 -> error = "New password must be at least 4 characters"
new1 != new2 -> error = "Passwords don't match"
else -> onConfirm(PasswordHasher.hash(new1.toCharArray()))
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (existingHash == null) "Set a password" else "Change password") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (existingHash != null) {
OutlinedTextField(
value = current,
onValueChange = {
current = it
error = null
},
label = { Text("Current password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
)
}
OutlinedTextField(
value = new1,
onValueChange = {
new1 = it
error = null
},
label = { Text("New password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
)
OutlinedTextField(
value = new2,
onValueChange = {
new2 = it
error = null
},
label = { Text("Confirm new password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
)
error?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = {
TextButton(onClick = submit) { Text("Save") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
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"
}