diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-03 08:45:12 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-03 08:45:12 +0100 |
| commit | eff94a13a76e6f1f72e21848e6600bbbd731a660 (patch) | |
| tree | a12571c21fa8fade63aed30fde08bdfb2eaa79fb /app/src | |
| parent | 84d89854ce02a5833e88bfd9c150b8e90b51b4a5 (diff) | |
| download | HardenedPass-eff94a13a76e6f1f72e21848e6600bbbd731a660.tar.gz HardenedPass-eff94a13a76e6f1f72e21848e6600bbbd731a660.zip | |
feat(app): add the Compose editor screen and generator sheet
Stateless editor: name, folder breadcrumbs with Change, password with
reveal and generator, username, extra content and the OTP actions. The
generator sheet has random and diceware tabs with the old options; the
manual OTP dialog and the sheet are FLAG_SECURE.
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/editor/EditorScreen.kt | 314 | ||||
| -rw-r--r-- | app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt | 197 | ||||
| -rw-r--r-- | app/src/main/res/values-it/strings.xml | 11 | ||||
| -rw-r--r-- | app/src/main/res/values/strings.xml | 12 |
4 files changed, 534 insertions, 0 deletions
diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt new file mode 100644 index 00000000..bb8af01a --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt @@ -0,0 +1,314 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.editor + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +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.window.DialogProperties +import androidx.compose.ui.window.SecureFlagPolicy +import app.passwordstore.R +import app.passwordstore.ui.compose.BreadcrumbBar +import app.passwordstore.ui.compose.R as composeR +import app.passwordstore.ui.compose.crumbsFor +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium + +/** Which field takes focus when the editor first opens. */ +enum class EditorFocus { + None, + Name, + Username, +} + +/** + * The editor: name, folder (breadcrumbs; they and "Change" open the picker), password with reveal + * and generator, username, free-text extra content (the pass format as text) and the OTP actions, + * shown while the extras hold no TOTP secret ([showOtpActions]). [onScanQr] is null on a device + * without a camera. Only the reveal toggle and the menus keep state here. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditorScreen( + form: EditorForm, + editing: Boolean, + saving: Boolean, + showOtpActions: Boolean, + initiallyRevealed: Boolean, + focus: EditorFocus, + onFormChange: (EditorForm) -> Unit, + onClose: () -> Unit, + onSave: (copy: Boolean) -> Unit, + onChangeFolder: () -> Unit, + onGenerate: () -> Unit, + onScanQr: (() -> Unit)?, + onQrImage: () -> Unit, + onManualOtp: () -> Unit, + modifier: Modifier = Modifier, +) { + var revealed by remember { mutableStateOf(initiallyRevealed) } + var menu by remember { mutableStateOf(false) } + var qrMenu by remember { mutableStateOf(false) } + val nameFocus = remember { FocusRequester() } + val usernameFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { + when (focus) { + EditorFocus.Name -> nameFocus.requestFocus() + EditorFocus.Username -> usernameFocus.requestFocus() + EditorFocus.None -> Unit + } + } + val field = Modifier.fillMaxWidth().padding(horizontal = SpacingLarge) + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text( + stringResource(if (editing) R.string.editor_edit_title else R.string.editor_new_title) + ) + }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.Filled.Close, stringResource(R.string.editor_close)) + } + }, + actions = { + TextButton(onClick = { onSave(false) }, enabled = !saving) { + Text(stringResource(R.string.crypto_save)) + } + Box { + IconButton(onClick = { menu = true }) { + Icon(Icons.Filled.MoreVert, stringResource(R.string.list_more_options)) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.crypto_save_and_copy)) }, + onClick = { + menu = false + onSave(true) + }, + enabled = !saving, + ) + } + } + }, + ) + }, + ) { padding -> + Column( + Modifier.padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = SpacingLarge), + verticalArrangement = Arrangement.spacedBy(SpacingMedium), + ) { + OutlinedTextField( + value = form.name, + onValueChange = { onFormChange(form.copy(name = it)) }, + modifier = field.focusRequester(nameFocus), + label = { Text(stringResource(R.string.crypto_name_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next), + ) + Text( + stringResource(R.string.editor_folder), + field, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + BreadcrumbBar( + crumbsFor(form.dir), + stringResource(R.string.list_store_root), + onCrumbClick = { onChangeFolder() }, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onChangeFolder, modifier = Modifier.padding(end = SpacingMedium)) { + Text(stringResource(R.string.editor_change_folder)) + } + } + OutlinedTextField( + value = form.password, + onValueChange = { onFormChange(form.copy(password = it)) }, + modifier = field, + label = { Text(stringResource(R.string.crypto_pass_label)) }, + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace), + visualTransformation = + if (revealed) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ), + trailingIcon = { + Row { + IconButton(onClick = { revealed = !revealed }) { + Icon( + painterResource( + if (revealed) composeR.drawable.baseline_visibility_off_24 + else composeR.drawable.baseline_visibility_24 + ), + contentDescription = + stringResource( + if (revealed) R.string.entry_hide_password else R.string.entry_show_password + ), + ) + } + IconButton(onClick = onGenerate) { + Icon( + painterResource(R.drawable.ic_password_24px), + contentDescription = stringResource(R.string.pwgen_title), + ) + } + } + }, + ) + OutlinedTextField( + value = form.username, + onValueChange = { onFormChange(form.copy(username = it)) }, + modifier = field.focusRequester(usernameFocus), + label = { Text(stringResource(R.string.crypto_username_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next), + ) + OutlinedTextField( + value = form.extra, + onValueChange = { onFormChange(form.copy(extra = it)) }, + modifier = field, + label = { Text(stringResource(R.string.crypto_extra_label)) }, + minLines = 3, + // A password keyboard does not learn what is typed, as the old textVisiblePassword. + keyboardOptions = + KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Password), + ) + if (showOtpActions) + Row(field, horizontalArrangement = Arrangement.spacedBy(SpacingMedium)) { + Box { + OutlinedButton(onClick = { qrMenu = true }) { + Icon(painterResource(R.drawable.ic_qr_code_scanner), contentDescription = null) + Text(stringResource(R.string.editor_otp_qr), Modifier.padding(start = SpacingMedium)) + } + DropdownMenu(expanded = qrMenu, onDismissRequest = { qrMenu = false }) { + if (onScanQr != null) + DropdownMenuItem( + text = { Text(stringResource(R.string.otp_import_qr_code)) }, + onClick = { + qrMenu = false + onScanQr() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.otp_import_from_file)) }, + onClick = { + qrMenu = false + onQrImage() + }, + ) + } + } + OutlinedButton(onClick = onManualOtp) { Text(stringResource(R.string.editor_otp_manual)) } + } + } + } +} + +/** + * Asks for a TOTP secret and an optional account (the URI's issuer), as the old manual OTP dialog + * did. Both live in [remember] only, and the window is FLAG_SECURE. + */ +@Composable +fun OtpDialog( + onAdd: (secret: String, account: String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var secret by remember { mutableStateOf("") } + var account by remember { mutableStateOf("") } + val focus = remember { FocusRequester() } + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + title = { Text(stringResource(R.string.add_otp)) }, + text = { + Column { + OutlinedTextField( + value = secret, + onValueChange = { secret = it }, + modifier = Modifier.focusRequester(focus), + label = { Text(stringResource(R.string.otp_import_manual_hint_secret)) }, + singleLine = true, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ), + ) + OutlinedTextField( + value = account, + onValueChange = { account = it }, + label = { Text(stringResource(R.string.otp_import_manual_hint_account)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false), + ) + } + LaunchedEffect(Unit) { focus.requestFocus() } + }, + confirmButton = { + TextButton(onClick = { onAdd(secret, account) }, enabled = secret.isNotBlank()) { + Text(stringResource(R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt b/app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt new file mode 100644 index 00000000..67a4963a --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.editor + +import androidx.annotation.StringRes +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.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.window.SecureFlagPolicy +import app.passwordstore.R +import app.passwordstore.passgen.random.PasswordGenerator +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.ui.compose.theme.SpacingSmall +import com.github.michaelbull.result.Result + +private const val RANDOM_TAB = 0 +private const val DICEWARE_TAB = 1 + +/** + * The password generator as a bottom sheet: a random tab and a diceware tab with the old dialogs' + * options, starting from [random] and [diceware]. [classicFirst] opens the random tab (the + * "Password generator" setting). Every change generates again; [onUse] takes the password shown. + * The password lives in [remember] only, and the sheet's window is FLAG_SECURE. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GeneratorSheet( + classicFirst: Boolean, + random: RandomSpec, + diceware: DicewareSpec, + onRandom: suspend (RandomSpec) -> Result<String, Int>, + onDiceware: suspend (DicewareSpec) -> String, + onUse: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var tab by remember { mutableIntStateOf(if (classicFirst) RANDOM_TAB else DICEWARE_TAB) } + var randomSpec by remember { mutableStateOf(random) } + var dicewareSpec by remember { mutableStateOf(diceware) } + var length by remember { mutableStateOf(random.length.toString()) } + var words by remember { mutableStateOf(diceware.words.toString()) } + var separator by remember { mutableStateOf(diceware.separator.toString()) } + var rolls by remember { mutableIntStateOf(0) } + var password by remember { mutableStateOf("") } + var problem by remember { mutableStateOf<Int?>(null) } + LaunchedEffect(tab, randomSpec, dicewareSpec, rolls) { + if (tab == RANDOM_TAB) { + val result = onRandom(randomSpec) + password = if (result.isOk) result.value else "" + problem = if (result.isOk) null else result.error + } else { + password = onDiceware(dicewareSpec) + problem = null + } + } + ModalBottomSheet( + onDismissRequest = onDismiss, + modifier = modifier, + properties = ModalBottomSheetProperties(securePolicy = SecureFlagPolicy.SecureOn), + ) { + PrimaryTabRow(selectedTabIndex = tab) { + Tab( + selected = tab == RANDOM_TAB, + onClick = { tab = RANDOM_TAB }, + text = { Text(stringResource(R.string.generator_random)) }, + ) + Tab( + selected = tab == DICEWARE_TAB, + onClick = { tab = DICEWARE_TAB }, + text = { Text(stringResource(R.string.generator_diceware)) }, + ) + } + Column( + Modifier.verticalScroll(rememberScrollState()).padding(SpacingLarge), + verticalArrangement = Arrangement.spacedBy(SpacingSmall), + ) { + val shown = problem + Text( + if (shown == null) password else stringResource(shown), + style = MaterialTheme.typography.titleMedium.copy(fontFamily = FontFamily.Monospace), + color = + if (shown == null) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.error, + ) + if (tab == RANDOM_TAB) { + CheckRow(R.string.pwgen_numerals, randomSpec.numerals) { + randomSpec = randomSpec.copy(numerals = it) + } + CheckRow(R.string.pwgen_symbols, randomSpec.symbols) { + randomSpec = randomSpec.copy(symbols = it) + } + CheckRow(R.string.pwgen_uppercase, randomSpec.uppercase) { + randomSpec = randomSpec.copy(uppercase = it) + } + CheckRow(R.string.pwgen_lowercase, randomSpec.lowercase) { + randomSpec = randomSpec.copy(lowercase = it) + } + CheckRow(R.string.pwgen_pronounceable, randomSpec.pronounceable) { + randomSpec = randomSpec.copy(pronounceable = it) + } + CheckRow(R.string.pwgen_ambiguous, randomSpec.ambiguous) { + randomSpec = randomSpec.copy(ambiguous = it) + } + OutlinedTextField( + value = length, + onValueChange = { text -> + // ponytail: at most 999 characters; the generator has no upper bound of its own. + val digits = text.filter(Char::isDigit).take(3) + length = digits + randomSpec = + randomSpec.copy(length = digits.toIntOrNull() ?: PasswordGenerator.DEFAULT_LENGTH) + }, + label = { Text(stringResource(R.string.pwgen_length)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } else { + OutlinedTextField( + value = words, + onValueChange = { text -> + // ponytail: at most 99 words. + val digits = text.filter(Char::isDigit).take(2) + words = digits + dicewareSpec = dicewareSpec.copy(words = digits.toIntOrNull()?.takeIf { it > 0 } ?: 5) + }, + label = { Text(stringResource(R.string.pwgen_length)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + OutlinedTextField( + value = separator, + onValueChange = { text -> + separator = text.take(1) + dicewareSpec = dicewareSpec.copy(separator = separator.firstOrNull() ?: '-') + }, + label = { Text(stringResource(R.string.pwgen_separator)) }, + singleLine = true, + ) + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(SpacingMedium, Alignment.End), + ) { + TextButton(onClick = { rolls++ }) { Text(stringResource(R.string.pwgen_generate)) } + Button(onClick = { onUse(password) }, enabled = password.isNotEmpty() && problem == null) { + Text(stringResource(R.string.generator_use)) + } + } + } + } +} + +@Composable +private fun CheckRow(@StringRes label: Int, checked: Boolean, onChange: (Boolean) -> Unit) { + Row( + Modifier.fillMaxWidth() + .toggleable(value = checked, role = Role.Checkbox, onValueChange = onChange), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = checked, onCheckedChange = null) + Text(stringResource(label), Modifier.padding(start = SpacingMedium)) + } +} diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 100b0b7c..91cd28aa 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -363,4 +363,15 @@ <string name="folder_picker_title">Scegli una cartella</string> <string name="folder_creation_err_failed">Impossibile creare la cartella</string> <string name="folder_picker_new">Nuova cartella</string> + + <!-- Compose editor --> + <string name="editor_new_title">Nuova voce</string> + <string name="editor_edit_title">Modifica voce</string> + <string name="editor_close">Chiudi</string> + <string name="editor_folder">Cartella</string> + <string name="editor_change_folder">Cambia</string> + <string name="editor_otp_qr">OTP da QR</string> + <string name="editor_otp_manual">OTP manuale</string> + <string name="generator_random">Casuale</string> + <string name="generator_use">Usa</string> </resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6d9dbba..34b11662 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -397,4 +397,16 @@ <string name="folder_picker_title">Choose a folder</string> <string name="folder_creation_err_failed">Could not create the folder</string> <string name="folder_picker_new">New folder</string> + + <!-- Compose editor --> + <string name="editor_new_title">New entry</string> + <string name="editor_edit_title">Edit entry</string> + <string name="editor_close">Close</string> + <string name="editor_folder">Folder</string> + <string name="editor_change_folder">Change</string> + <string name="editor_otp_qr">OTP from QR</string> + <string name="editor_otp_manual">Manual OTP</string> + <string name="generator_random">Random</string> + <string name="generator_diceware" translatable="false">Diceware</string> + <string name="generator_use">Use</string> </resources> |
