diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-03 18:37:46 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-03 18:37:46 +0100 |
| commit | b814da3b2328a05b2fe94a5ae653cc3288f68fff (patch) | |
| tree | e901b8f0e3e1f256ca57fe2935d8b0a1f6dbefd0 /app/src/main/java | |
| parent | cc880cf62ea8391f693f034858578cd4b693043b (diff) | |
| download | HardenedPass-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/main/java')
| -rw-r--r-- | app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt | 216 | ||||
| -rw-r--r-- | app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt | 226 |
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), + ) + } +} |
