aboutsummaryrefslogtreecommitdiffstats
path: root/app/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-03 18:37:46 +0100
committerDanilo M. <danix@danix.xyz>2026-11-03 18:37:46 +0100
commitb814da3b2328a05b2fe94a5ae653cc3288f68fff (patch)
treee901b8f0e3e1f256ca57fe2935d8b0a1f6dbefd0 /app/src
parentcc880cf62ea8391f693f034858578cd4b693043b (diff)
downloadHardenedPass-b814da3b2328a05b2fe94a5ae653cc3288f68fff.tar.gz
HardenedPass-b814da3b2328a05b2fe94a5ae653cc3288f68fff.zip
feat(app): add Compose preference rows and the settings state
Switch, single choice, text and action rows over the existing preference keys, and a ViewModel that reads every displayed value, follows preference changes, deletes the store without following symlinks, and clears saved credentials and the passphrase cache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt216
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt226
2 files changed, 442 insertions, 0 deletions
diff --git a/app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt b/app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt
new file mode 100644
index 00000000..f40db8b0
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt
@@ -0,0 +1,216 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import androidx.compose.foundation.clickable
+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.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemColors
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.input.KeyboardType
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import kotlinx.collections.immutable.ImmutableList
+
+private const val DISABLED_ALPHA = 0.38f
+
+/** List item colors for rows inside a grouped surface: the surface shows through. */
+@Composable
+internal fun rowColors(): ListItemColors =
+ ListItemDefaults.colors(containerColor = Color.Transparent)
+
+private fun Modifier.dimmed(enabled: Boolean) = if (enabled) this else alpha(DISABLED_ALPHA)
+
+/** A row that runs [onClick]: opens a screen or a dialog. */
+@Composable
+fun PreferenceAction(
+ title: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ summary: String? = null,
+ enabled: Boolean = true,
+) {
+ ListItem(
+ headlineContent = { Text(title) },
+ modifier = modifier.clickable(enabled = enabled, onClick = onClick).dimmed(enabled),
+ supportingContent =
+ if (summary != null) {
+ { Text(summary) }
+ } else null,
+ colors = rowColors(),
+ )
+}
+
+/**
+ * A switch row showing [checked]. [onCheckedChange] gets the requested value; the caller decides
+ * whether it is stored (a screen lock prompt may come first), and the row shows what was stored.
+ */
+@Composable
+fun PreferenceSwitch(
+ title: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ summary: String? = null,
+ enabled: Boolean = true,
+) {
+ ListItem(
+ headlineContent = { Text(title) },
+ modifier =
+ modifier
+ .toggleable(
+ value = checked,
+ enabled = enabled,
+ role = Role.Switch,
+ onValueChange = onCheckedChange,
+ )
+ .dimmed(enabled),
+ supportingContent =
+ if (summary != null) {
+ { Text(summary) }
+ } else null,
+ trailingContent = { Switch(checked = checked, onCheckedChange = null, enabled = enabled) },
+ colors = rowColors(),
+ )
+}
+
+/**
+ * A single choice among [values], shown by their [labels] (same order); the summary is the selected
+ * label. Tapping opens a dialog of radio buttons; choosing one closes it.
+ */
+@Composable
+fun PreferenceChoice(
+ title: String,
+ values: ImmutableList<String>,
+ labels: ImmutableList<String>,
+ selected: String,
+ onSelect: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var open by rememberSaveable { mutableStateOf(false) }
+ PreferenceAction(
+ title = title,
+ onClick = { open = true },
+ modifier = modifier,
+ summary = labels.getOrNull(values.indexOf(selected)),
+ )
+ if (open)
+ AlertDialog(
+ onDismissRequest = { open = false },
+ title = { Text(title) },
+ text = {
+ Column(Modifier.selectableGroup()) {
+ values.forEachIndexed { index, value ->
+ Row(
+ Modifier.fillMaxWidth()
+ .selectable(
+ selected = value == selected,
+ role = Role.RadioButton,
+ onClick = {
+ open = false
+ onSelect(value)
+ },
+ )
+ .padding(vertical = SpacingMedium),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected = value == selected, onClick = null)
+ Text(labels[index], Modifier.padding(start = SpacingLarge))
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { open = false }) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
+
+/**
+ * A text value; tapping opens a dialog to edit it. The draft is kept across recreation in saved
+ * state: never use this row for a secret. [KeyboardType.Number] keeps digits only.
+ */
+@Composable
+fun PreferenceText(
+ title: String,
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ summary: String? = null,
+ hint: String? = null,
+ singleLine: Boolean = true,
+ keyboardType: KeyboardType = KeyboardType.Text,
+ enabled: Boolean = true,
+) {
+ var draft by rememberSaveable { mutableStateOf<String?>(null) }
+ PreferenceAction(
+ title = title,
+ onClick = { draft = value },
+ modifier = modifier,
+ summary = summary,
+ enabled = enabled,
+ )
+ draft?.let { text ->
+ AlertDialog(
+ onDismissRequest = { draft = null },
+ title = { Text(title) },
+ text = {
+ OutlinedTextField(
+ value = text,
+ onValueChange = { new ->
+ draft = if (keyboardType == KeyboardType.Number) new.filter(Char::isDigit) else new
+ },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder =
+ if (hint != null) {
+ { Text(hint) }
+ } else null,
+ singleLine = singleLine,
+ minLines = if (singleLine) 1 else 3,
+ keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ draft = null
+ onValueChange(text)
+ }
+ ) {
+ Text(stringResource(R.string.dialog_ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { draft = null }) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt
new file mode 100644
index 00000000..ef604f1f
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt
@@ -0,0 +1,226 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.app.Application
+import android.content.SharedPreferences
+import android.content.pm.ShortcutManager
+import androidx.annotation.StringRes
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.content.edit
+import androidx.core.content.getSystemService
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.R
+import app.passwordstore.data.crypto.PGPPassphraseCache
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.injection.prefs.GitPreferences
+import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.ui.list.deleteWithoutFollowingLinks
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.features.Feature
+import app.passwordstore.util.git.sshj.SshKey
+import app.passwordstore.util.settings.DirectoryStructure
+import app.passwordstore.util.settings.GitSettings
+import app.passwordstore.util.settings.PasswordSortOrder
+import app.passwordstore.util.settings.PreferenceKeys
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.nio.file.Files
+import java.nio.file.LinkOption
+import javax.inject.Inject
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** What the settings sections show: stored values, and the state rows depend on. */
+data class SettingsState(
+ val theme: String,
+ val directoryStructure: String,
+ val sortOrder: String,
+ val disableSync: Boolean,
+ val filterRecursively: Boolean,
+ val searchOnStart: Boolean,
+ val showHidden: Boolean,
+ val biometric: Boolean,
+ val defaultUsername: String,
+ val customSuffixes: String,
+ val pwgenType: String,
+ val clipboardTimeout: String,
+ val showPassword: Boolean,
+ val copyOnDecrypt: Boolean,
+ val rebaseOnPull: Boolean,
+ val isGitRepo: Boolean,
+ val httpsRemote: Boolean,
+ val canShowSshKey: Boolean,
+ /** Title of the "clear saved credential" row, or null when none is saved. */
+ @StringRes val savedSecret: Int?,
+ val clearClipboardHistory: Boolean,
+ val debugLogging: Boolean,
+ val asciiArmor: Boolean,
+ val passphraseCache: Boolean,
+ val autoClearCache: Boolean,
+)
+
+/**
+ * One settings section's state over the existing preference keys. [state] is re-read on every
+ * change of the settings and on [refresh] (the destination calls it on resume, so changes made on
+ * other screens, such as a new SSH key, show at once).
+ */
+@HiltViewModel
+class SettingsViewModel
+@Inject
+constructor(
+ application: Application,
+ @SettingsPreferences private val prefs: SharedPreferences,
+ @GitPreferences private val gitPrefs: SharedPreferences,
+ private val gitSettings: GitSettings,
+ private val passphraseCache: PGPPassphraseCache,
+ private val dispatcherProvider: DispatcherProvider,
+) : AndroidViewModel(application) {
+
+ var state by mutableStateOf(read())
+ private set
+
+ /** True while "Delete repository" runs. */
+ var deleting by mutableStateOf(false)
+ private set
+
+ /** Null until "Delete repository" ran; then whether the whole store folder was deleted. */
+ var deleted by mutableStateOf<Boolean?>(null)
+ private set
+
+ // A field: SharedPreferences holds its listeners weakly.
+ private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> refresh() }
+
+ init {
+ prefs.registerOnSharedPreferenceChangeListener(listener)
+ }
+
+ override fun onCleared() {
+ prefs.unregisterOnSharedPreferenceChangeListener(listener)
+ }
+
+ fun refresh() {
+ state = read()
+ }
+
+ fun putBoolean(key: String, value: Boolean) {
+ prefs.edit { putBoolean(key, value) }
+ }
+
+ /** Stores [value] under [key]; an empty value removes the key, so readers use their default. */
+ fun putString(key: String, value: String) {
+ prefs.edit { if (value.isEmpty()) remove(key) else putString(key, value) }
+ }
+
+ /** Turns the passphrase cache on or off (after the screen lock prompt); it starts empty. */
+ fun setPassphraseCache(enabled: Boolean) {
+ prefs.edit {
+ putBoolean(Feature.EnablePGPPassphraseCache.configKey, enabled)
+ if (!enabled) remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE)
+ }
+ clearCachedPassphrases()
+ }
+
+ /** Stops clearing the cache when the screen goes off; the cache is emptied once now. */
+ fun disableAutoClear() {
+ putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, false)
+ clearCachedPassphrases()
+ }
+
+ /** Back to the default: the cache is cleared when the screen goes off. */
+ fun resetAutoClear() {
+ prefs.edit { remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) }
+ }
+
+ /** Forgets the credential the "clear" row names: the HTTPS password, else the SSH passphrase. */
+ fun clearSavedSecret() {
+ val key =
+ if (gitPrefs.contains(PreferenceKeys.HTTPS_PASSWORD)) PreferenceKeys.HTTPS_PASSWORD
+ else PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE
+ gitPrefs.edit { remove(key) }
+ refresh()
+ }
+
+ /**
+ * Empties the store folder and marks setup as not done, then sets [deleted]. In [viewModelScope]
+ * and [NonCancellable], so neither a recreation nor the ViewModel's end can stop it half way (a
+ * half-deleted store still marked as set up). Never follows a symlink: `deleteRecursively()`
+ * would, and a `store/link -> ..` link would take the PGP and SSH keys with it.
+ */
+ fun deleteRepository() {
+ if (deleting) return
+ deleting = true
+ viewModelScope.launch {
+ withContext(NonCancellable) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ val gone =
+ withContext(dispatcherProvider.io()) {
+ PasswordRepository.closeRepository()
+ val ok =
+ Files.notExists(root.toPath(), LinkOption.NOFOLLOW_LINKS) ||
+ root.deleteWithoutFollowingLinks()
+ root.mkdirs()
+ ok
+ }
+ getApplication<Application>()
+ .getSystemService<ShortcutManager>()
+ ?.removeAllDynamicShortcuts()
+ prefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
+ deleted = gone
+ }
+ }
+ }
+
+ // NonCancellable: leaving the screen right after the switch must not skip the clearing.
+ private fun clearCachedPassphrases() {
+ viewModelScope.launch(NonCancellable) {
+ passphraseCache.clearAllCachedPassphrases(getApplication())
+ }
+ }
+
+ private fun read(): SettingsState {
+ val app = getApplication<Application>()
+ return SettingsState(
+ theme = prefs.getString(PreferenceKeys.APP_THEME) ?: app.getString(R.string.app_theme_def),
+ directoryStructure =
+ prefs.getString(PreferenceKeys.DIRECTORY_STRUCTURE) ?: DirectoryStructure.DEFAULT.value,
+ sortOrder = PasswordSortOrder.getSortOrder(prefs).name,
+ disableSync = prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false),
+ filterRecursively = prefs.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true),
+ searchOnStart = prefs.getBoolean(PreferenceKeys.SEARCH_ON_START, false),
+ showHidden = prefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false),
+ biometric = prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, false),
+ defaultUsername = prefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME).orEmpty(),
+ customSuffixes =
+ prefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES).orEmpty(),
+ pwgenType = prefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: "diceware",
+ clipboardTimeout = prefs.getString(PreferenceKeys.GENERAL_SHOW_TIME).orEmpty(),
+ showPassword = prefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true),
+ copyOnDecrypt = prefs.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false),
+ rebaseOnPull = gitSettings.rebaseOnPull,
+ isGitRepo = PasswordRepository.isGitRepo(),
+ httpsRemote = gitSettings.url?.startsWith("https") == true,
+ canShowSshKey = SshKey.canShowSshPublicKey,
+ // contains, never getString: the secret is not decrypted to decide whether to show a row.
+ savedSecret =
+ when {
+ gitPrefs.contains(PreferenceKeys.HTTPS_PASSWORD) -> R.string.clear_saved_passphrase_https
+ gitPrefs.contains(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) ->
+ R.string.clear_saved_passphrase_ssh
+ else -> null
+ },
+ clearClipboardHistory = prefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false),
+ debugLogging = prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false),
+ asciiArmor = prefs.getBoolean(PreferenceKeys.ASCII_ARMOR, false),
+ passphraseCache = prefs.getBoolean(Feature.EnablePGPPassphraseCache.configKey, false),
+ autoClearCache = prefs.getBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, true),
+ )
+ }
+}