aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-03 08:45:12 +0100
committerDanilo M. <danix@danix.xyz>2026-11-03 08:45:12 +0100
commiteff94a13a76e6f1f72e21848e6600bbbd731a660 (patch)
treea12571c21fa8fade63aed30fde08bdfb2eaa79fb /app/src/main
parent84d89854ce02a5833e88bfd9c150b8e90b51b4a5 (diff)
downloadHardenedPass-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/main')
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt314
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt197
-rw-r--r--app/src/main/res/values-it/strings.xml11
-rw-r--r--app/src/main/res/values/strings.xml12
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>