From 52b0b86ce7430118ab988f784d6422ff8b780c93 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 20:46:29 +0100 Subject: feat(ui-compose): add the breadcrumb model Co-Authored-By: Claude Opus 5.5 --- .../kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt (limited to 'ui/compose/src/main') diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt new file mode 100644 index 00000000..d8bc02fe --- /dev/null +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt @@ -0,0 +1,16 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.compose + +/** One breadcrumb chip. The root crumb has an empty [label] and [path]. */ +public data class Crumb(val label: String, val path: String) + +/** Crumbs for a folder at [relativePath] inside the store: root first, then each level. */ +public fun crumbsFor(relativePath: String): List { + val parts = relativePath.split('/').filter { it.isNotEmpty() } + return listOf(Crumb("", "")) + + parts.indices.map { i -> Crumb(parts[i], parts.subList(0, i + 1).joinToString("/")) } +} -- cgit v1.2.3 From 940b3018b6fec2366257aac496da3335e8e4cbd7 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 20:47:58 +0100 Subject: feat(ui-compose): add breadcrumb bar, grouped surface, entry and field rows Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/compose/Breadcrumbs.kt | 66 ++++++++++ .../app/passwordstore/ui/compose/EntryRows.kt | 137 +++++++++++++++++++++ .../app/passwordstore/ui/compose/FieldRow.kt | 95 ++++++++++++++ .../app/passwordstore/ui/compose/GroupedSurface.kt | 32 +++++ ui/compose/src/main/res/drawable/ic_folder_24.xml | 9 ++ 5 files changed, 339 insertions(+) create mode 100644 ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt create mode 100644 ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt create mode 100644 ui/compose/src/main/kotlin/app/passwordstore/ui/compose/GroupedSurface.kt create mode 100644 ui/compose/src/main/res/drawable/ic_folder_24.xml (limited to 'ui/compose/src/main') diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt index d8bc02fe..3e51d1b4 100644 --- a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt @@ -5,6 +5,27 @@ package app.passwordstore.ui.compose +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.passwordstore.ui.compose.preview.ThemePreviews +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingSmall + /** One breadcrumb chip. The root crumb has an empty [label] and [path]. */ public data class Crumb(val label: String, val path: String) @@ -14,3 +35,48 @@ public fun crumbsFor(relativePath: String): List { return listOf(Crumb("", "")) + parts.indices.map { i -> Crumb(parts[i], parts.subList(0, i + 1).joinToString("/")) } } + +/** + * Folder location as chips. The last crumb is highlighted; tapping any crumb reports its path. + * [rootDescription] is the content description of the root (home) chip. + */ +@Composable +@Suppress("ComposeUnstableCollections") +public fun BreadcrumbBar( + crumbs: List, + rootDescription: String, + onCrumbClick: (path: String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.horizontalScroll(rememberScrollState()).padding(horizontal = SpacingLarge), + horizontalArrangement = Arrangement.spacedBy(SpacingSmall), + verticalAlignment = Alignment.CenterVertically, + ) { + crumbs.forEachIndexed { i, crumb -> + if (i > 0) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + val current = i == crumbs.lastIndex + AssistChip( + onClick = { onCrumbClick(crumb.path) }, + label = { + if (crumb.path.isEmpty()) Icon(Icons.Filled.Home, contentDescription = rootDescription) + else Text(crumb.label) + }, + colors = + if (current) + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + else AssistChipDefaults.assistChipColors(), + border = null, + ) + } + } +} + +@ThemePreviews +@Composable +private fun BreadcrumbBarPreview() { + APSTheme { BreadcrumbBar(crumbsFor("work/servers"), "Store root", onCrumbClick = {}) } +} diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt new file mode 100644 index 00000000..56ceb5b5 --- /dev/null +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.compose + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import app.passwordstore.ui.compose.preview.ThemePreviews +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium + +/** Circle with the first letter of [name]; the color pair is picked from the name's hash. */ +@Composable +public fun LetterAvatar(name: String, modifier: Modifier = Modifier) { + val scheme = MaterialTheme.colorScheme + val pairs = + listOf( + scheme.primaryContainer to scheme.onPrimaryContainer, + scheme.secondaryContainer to scheme.onSecondaryContainer, + scheme.tertiaryContainer to scheme.onTertiaryContainer, + ) + val (bg, fg) = pairs[Math.floorMod(name.hashCode(), pairs.size)] + Box(modifier.size(36.dp).background(bg, CircleShape), contentAlignment = Alignment.Center) { + Text(name.take(1).uppercase(), color = fg, style = MaterialTheme.typography.titleMedium) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun RowShell( + onClick: () -> Unit, + onLongClick: () -> Unit, + selected: Boolean, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .background( + if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent + ) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = SpacingLarge, vertical = SpacingMedium + 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + content() + } +} + +/** A folder row: icon, name, entry count. */ +@Composable +public fun FolderRow( + name: String, + count: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false, + onLongClick: () -> Unit = {}, +) { + RowShell(onClick, onLongClick, selected, modifier) { + Box( + Modifier.size(36.dp).background(MaterialTheme.colorScheme.secondaryContainer, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + painterResource(R.drawable.ic_folder_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + Text(name, Modifier.weight(1f).padding(start = SpacingLarge)) + Text( + count.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** An entry row: letter avatar, name, optional subtitle (username, "OTP"). */ +@Composable +public fun EntryRow( + name: String, + subtitle: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false, + onLongClick: () -> Unit = {}, +) { + RowShell(onClick, onLongClick, selected, modifier) { + LetterAvatar(name) + Column(Modifier.weight(1f).padding(start = SpacingLarge)) { + Text(name) + if (subtitle != null) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@ThemePreviews +@Composable +private fun RowsPreview() { + APSTheme { + GroupedSurface { + FolderRow("legacy", 2, onClick = {}) + EntryRow("git.example.org", "you", onClick = {}) + EntryRow("vpn", "you · OTP", onClick = {}, selected = true) + } + } +} diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt new file mode 100644 index 00000000..c49ec2bc --- /dev/null +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.compose + +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.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.res.painterResource +import app.passwordstore.ui.compose.preview.ThemePreviews +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium + +/** Content descriptions for [FieldRow]'s buttons, supplied by the app's translated strings. */ +public data class FieldRowLabels(val copy: String, val show: String, val hide: String) + +/** + * A labelled value with an optional copy button. When [masked] is true the value is hidden behind + * dots until the reveal toggle is pressed. + */ +@Composable +public fun FieldRow( + label: String, + value: String, + labels: FieldRowLabels, + modifier: Modifier = Modifier, + masked: Boolean = false, + onCopy: (() -> Unit)? = null, +) { + var revealed by rememberSaveable { mutableStateOf(false) } + Row( + modifier + .fillMaxWidth() + .padding( + start = SpacingLarge, + end = SpacingMedium, + top = SpacingMedium, + bottom = SpacingMedium, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + if (masked && !revealed) "•".repeat(10) else value, + style = MaterialTheme.typography.bodyLarge, + ) + } + if (masked) + IconButton(onClick = { revealed = !revealed }) { + Icon( + painterResource( + if (revealed) R.drawable.baseline_visibility_off_24 + else R.drawable.baseline_visibility_24 + ), + contentDescription = if (revealed) labels.hide else labels.show, + ) + } + if (onCopy != null) + IconButton(onClick = onCopy) { + Icon(painterResource(R.drawable.ic_content_copy), contentDescription = labels.copy) + } + } +} + +@ThemePreviews +@Composable +private fun FieldRowPreview() { + val labels = FieldRowLabels("Copy", "Show", "Hide") + APSTheme { + GroupedSurface { + FieldRow("Password", "hunter2", labels, masked = true, onCopy = {}) + FieldRow("Username", "you", labels, onCopy = {}) + } + } +} diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/GroupedSurface.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/GroupedSurface.kt new file mode 100644 index 00000000..5a86c549 --- /dev/null +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/GroupedSurface.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.passwordstore.ui.compose.theme.SpacingMedium + +/** Rounded container that groups related rows (folders, entries, fields). */ +@Composable +public fun GroupedSurface( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = modifier.padding(horizontal = SpacingMedium, vertical = SpacingMedium / 2), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column(content = content) + } +} diff --git a/ui/compose/src/main/res/drawable/ic_folder_24.xml b/ui/compose/src/main/res/drawable/ic_folder_24.xml new file mode 100644 index 00000000..747e21a6 --- /dev/null +++ b/ui/compose/src/main/res/drawable/ic_folder_24.xml @@ -0,0 +1,9 @@ + + + -- cgit v1.2.3 From 4a011721261bd46da3e84d443154d8e360494ece Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 20:49:28 +0100 Subject: fix(ui-compose): tighten row accessibility Co-Authored-By: Claude Opus 5.5 --- .../kotlin/app/passwordstore/ui/compose/EntryRows.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) (limited to 'ui/compose/src/main') diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt index 56ceb5b5..2cabaefd 100644 --- a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt @@ -24,6 +24,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import app.passwordstore.ui.compose.preview.ThemePreviews import app.passwordstore.ui.compose.theme.APSTheme @@ -41,7 +44,10 @@ public fun LetterAvatar(name: String, modifier: Modifier = Modifier) { scheme.tertiaryContainer to scheme.onTertiaryContainer, ) val (bg, fg) = pairs[Math.floorMod(name.hashCode(), pairs.size)] - Box(modifier.size(36.dp).background(bg, CircleShape), contentAlignment = Alignment.Center) { + Box( + modifier.size(36.dp).background(bg, CircleShape).clearAndSetSemantics {}, + contentAlignment = Alignment.Center, + ) { Text(name.take(1).uppercase(), color = fg, style = MaterialTheme.typography.titleMedium) } } @@ -50,7 +56,7 @@ public fun LetterAvatar(name: String, modifier: Modifier = Modifier) { @Composable private fun RowShell( onClick: () -> Unit, - onLongClick: () -> Unit, + onLongClick: (() -> Unit)?, selected: Boolean, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit, @@ -62,6 +68,7 @@ private fun RowShell( .background( if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent ) + .semantics { this.selected = selected } .combinedClickable(onClick = onClick, onLongClick = onLongClick) .padding(horizontal = SpacingLarge, vertical = SpacingMedium + 2.dp), verticalAlignment = Alignment.CenterVertically, @@ -78,11 +85,13 @@ public fun FolderRow( onClick: () -> Unit, modifier: Modifier = Modifier, selected: Boolean = false, - onLongClick: () -> Unit = {}, + onLongClick: (() -> Unit)? = null, ) { RowShell(onClick, onLongClick, selected, modifier) { Box( - Modifier.size(36.dp).background(MaterialTheme.colorScheme.secondaryContainer, CircleShape), + Modifier.size(36.dp) + .background(MaterialTheme.colorScheme.secondaryContainer, CircleShape) + .clearAndSetSemantics {}, contentAlignment = Alignment.Center, ) { Icon( @@ -108,7 +117,7 @@ public fun EntryRow( onClick: () -> Unit, modifier: Modifier = Modifier, selected: Boolean = false, - onLongClick: () -> Unit = {}, + onLongClick: (() -> Unit)? = null, ) { RowShell(onClick, onLongClick, selected, modifier) { LetterAvatar(name) -- cgit v1.2.3 From 22bb0aae958e8458274be994f3e679cb226d2241 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 23:30:37 +0100 Subject: feat(app): add the Compose entry screen Header with avatar, name and folder; password, username and one-time code with a countdown bar in the first group; extra fields in the second. FieldRow can start revealed (the "Show the password" setting) and shows masked values in JetBrains Mono, as the old screen did. Co-Authored-By: Claude Opus 5.5 --- .../java/app/passwordstore/ui/entry/EntryScreen.kt | 183 +++++++++++++++++++++ app/src/main/res/values-it/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + .../app/passwordstore/ui/compose/FieldRow.kt | 13 +- 4 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt (limited to 'ui/compose/src/main') diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt new file mode 100644 index 00000000..d4860819 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.entry + +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.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +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.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.painterResource +import androidx.compose.ui.res.stringResource +import app.passwordstore.R +import app.passwordstore.ui.compose.FieldRow +import app.passwordstore.ui.compose.FieldRowLabels +import app.passwordstore.ui.compose.GroupedSurface +import app.passwordstore.ui.compose.LetterAvatar +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium + +/** + * One entry: header (avatar, name, [folder] relative to the store root, "" at the root), then the + * password, username and one-time code, then the extra fields. [fields] is null until decrypted; + * [otp] is null when the entry has no TOTP. [onCopySecret] (password, one-time code) is separate + * from [onCopy] because only those copies start the clipboard clear timer. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EntryScreen( + name: String, + folder: String, + fields: EntryFields?, + otp: String?, + otpSecondsLeft: Long, + otpFractionLeft: Float, + showPassword: Boolean, + onBack: () -> Unit, + onEdit: () -> Unit, + onShare: () -> Unit, + onCopyName: () -> Unit, + onCopySecret: (String) -> Unit, + onCopy: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var menu by remember { mutableStateOf(false) } + val labels = + FieldRowLabels( + copy = stringResource(R.string.copy_label), + show = stringResource(R.string.entry_show_password), + hide = stringResource(R.string.entry_hide_password), + ) + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) + } + }, + actions = { + if (fields != null) + IconButton(onClick = onEdit) { + Icon( + painterResource(R.drawable.ic_edit_24dp), + contentDescription = stringResource(R.string.edit_password), + ) + } + 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.entry_copy_name)) }, + onClick = { + menu = false + onCopyName() + }, + ) + if (fields?.password != null) + DropdownMenuItem( + text = { Text(stringResource(R.string.share_as_plaintext)) }, + onClick = { + menu = false + onShare() + }, + leadingIcon = { + Icon(painterResource(R.drawable.ic_share_24dp), contentDescription = null) + }, + ) + } + } + }, + ) + }, + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) { + Row( + Modifier.fillMaxWidth().padding(horizontal = SpacingLarge, vertical = SpacingMedium), + verticalAlignment = Alignment.CenterVertically, + ) { + LetterAvatar(name) + Column(Modifier.weight(1f).padding(start = SpacingLarge)) { + Text(name, style = MaterialTheme.typography.titleLarge) + Text( + if (folder.isEmpty()) stringResource(R.string.list_store_root) else folder, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (fields != null) { + if (fields.password != null || fields.username != null || otp != null) + GroupedSurface(Modifier.fillMaxWidth()) { + if (fields.password != null) + FieldRow( + label = stringResource(R.string.password), + value = fields.password, + labels = labels.copy(copy = stringResource(R.string.copy_password)), + masked = true, + initiallyRevealed = showPassword, + onCopy = { onCopySecret(fields.password) }, + ) + if (fields.username != null) + FieldRow( + label = stringResource(R.string.username), + value = fields.username, + labels = labels, + onCopy = { onCopy(fields.username) }, + ) + if (otp != null) { + FieldRow( + label = stringResource(R.string.otp_label_format, otpSecondsLeft), + value = otp, + labels = labels, + onCopy = { onCopySecret(otp) }, + ) + LinearProgressIndicator( + progress = { otpFractionLeft }, + modifier = + Modifier.fillMaxWidth() + .padding(start = SpacingLarge, end = SpacingLarge, bottom = SpacingMedium), + ) + } + } + if (fields.extras.isNotEmpty()) + GroupedSurface(Modifier.fillMaxWidth()) { + fields.extras.forEach { (label, value) -> + key(label) { FieldRow(label, value, labels, onCopy = { onCopy(value) }) } + } + } + } + } + } +} diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 5ed24efb..31c34b4d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -352,4 +352,10 @@ Archivio password Altre opzioni Cancella ricerca + + + Indietro + Mostra password + Nascondi password + Copia nome diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e402f6b..2e208158 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -386,4 +386,10 @@ Password store More options Clear search + + + Back + Show password + Hide password + Copy name diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt index c49ec2bc..e632acc1 100644 --- a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt @@ -21,6 +21,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily import app.passwordstore.ui.compose.preview.ThemePreviews import app.passwordstore.ui.compose.theme.APSTheme import app.passwordstore.ui.compose.theme.SpacingLarge @@ -29,9 +31,12 @@ import app.passwordstore.ui.compose.theme.SpacingMedium /** Content descriptions for [FieldRow]'s buttons, supplied by the app's translated strings. */ public data class FieldRowLabels(val copy: String, val show: String, val hide: String) +/** Masked values (passwords) use a monospace face, so 0 and O, l and 1 stay apart. */ +private val Monospace = FontFamily(Font(R.font.jetbrainsmono_nl_regular)) + /** * A labelled value with an optional copy button. When [masked] is true the value is hidden behind - * dots until the reveal toggle is pressed. + * dots until the reveal toggle is pressed, unless [initiallyRevealed] shows it from the start. */ @Composable public fun FieldRow( @@ -40,9 +45,10 @@ public fun FieldRow( labels: FieldRowLabels, modifier: Modifier = Modifier, masked: Boolean = false, + initiallyRevealed: Boolean = false, onCopy: (() -> Unit)? = null, ) { - var revealed by rememberSaveable { mutableStateOf(false) } + var revealed by rememberSaveable { mutableStateOf(initiallyRevealed) } Row( modifier .fillMaxWidth() @@ -60,9 +66,10 @@ public fun FieldRow( style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + val style = MaterialTheme.typography.bodyLarge Text( if (masked && !revealed) "•".repeat(10) else value, - style = MaterialTheme.typography.bodyLarge, + style = if (masked) style.copy(fontFamily = Monospace) else style, ) } if (masked) -- cgit v1.2.3 From 7bdcb041de95c8d8d8d14f1dbd34a3380b326871 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 08:59:12 +0100 Subject: refactor(app): remove the legacy editor, generators and folder picker The Compose editor, generator sheet, OTP dialog and folder picker replace PasswordCreationActivity, the generator and manual OTP dialog fragments, SelectFolderActivity with its fragment and adapter, their layouts and menus, and upstream's unused EditPasswordScreen with the PasswordField it used. flowbinding and swiperefreshlayout had no other users. BasePGPActivity keeps what autofill decryption still needs. Also drops two stale SshKey baseline entries, the snackbar FAB anchor whose view is gone, and resources only the deleted files used. Co-Authored-By: Claude Opus 5.5 --- app/build.gradle.kts | 2 - app/lint-baseline.xml | 46 +- app/src/main/AndroidManifest.xml | 9 - .../injection/pwgen/DicewareModule.kt | 3 +- .../ui/adapters/PasswordItemRecyclerAdapter.kt | 99 ---- .../app/passwordstore/ui/crypto/BasePGPActivity.kt | 8 - .../passwordstore/ui/crypto/EditPasswordScreen.kt | 105 ----- .../ui/crypto/PasswordCreationActivity.kt | 510 --------------------- .../DicewarePasswordGeneratorDialogFragment.kt | 90 ---- .../ui/dialogs/OtpImportDialogFragment.kt | 47 -- .../ui/dialogs/PasswordGeneratorDialogFragment.kt | 148 ------ .../ui/folderselect/SelectFolderActivity.kt | 51 --- .../ui/folderselect/SelectFolderFragment.kt | 98 ---- .../util/extensions/AndroidExtensions.kt | 7 +- app/src/main/res/drawable/ic_done_24dp.xml | 15 - app/src/main/res/drawable/ic_save_24dp.xml | 15 - app/src/main/res/drawable/ic_save_copy_24dp.xml | 15 - .../main/res/layout/fragment_manual_otp_entry.xml | 53 --- app/src/main/res/layout/fragment_pwgen.xml | 115 ----- .../main/res/layout/fragment_pwgen_diceware.xml | 73 --- .../main/res/layout/password_creation_activity.xml | 147 ------ app/src/main/res/layout/password_recycler_view.xml | 38 -- app/src/main/res/layout/password_row_layout.xml | 50 -- app/src/main/res/layout/select_folder_layout.xml | 16 - app/src/main/res/menu/pgp_handler_new_password.xml | 20 - .../main/res/menu/pgp_handler_select_folder.xml | 15 - app/src/main/res/values-de/strings.xml | 8 - app/src/main/res/values-fr/strings.xml | 8 - app/src/main/res/values-gl/strings.xml | 8 - app/src/main/res/values-it/strings.xml | 8 - app/src/main/res/values-ko-rKR/strings.xml | 8 - app/src/main/res/values-pl-rPL/strings.xml | 8 - app/src/main/res/values-pt-rBR/strings.xml | 8 - app/src/main/res/values-ru/strings.xml | 8 - app/src/main/res/values-zh-rCN/strings.xml | 8 - app/src/main/res/values/strings.xml | 8 - .../specs/2026-11-02-compose-ui-overhaul-design.md | 8 +- gradle/libs.versions.toml | 3 - .../app/passwordstore/ui/compose/PasswordField.kt | 81 ---- 39 files changed, 9 insertions(+), 1958 deletions(-) delete mode 100644 app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt delete mode 100644 app/src/main/res/drawable/ic_done_24dp.xml delete mode 100644 app/src/main/res/drawable/ic_save_24dp.xml delete mode 100644 app/src/main/res/drawable/ic_save_copy_24dp.xml delete mode 100644 app/src/main/res/layout/fragment_manual_otp_entry.xml delete mode 100644 app/src/main/res/layout/fragment_pwgen.xml delete mode 100644 app/src/main/res/layout/fragment_pwgen_diceware.xml delete mode 100644 app/src/main/res/layout/password_creation_activity.xml delete mode 100644 app/src/main/res/layout/password_recycler_view.xml delete mode 100644 app/src/main/res/layout/password_row_layout.xml delete mode 100644 app/src/main/res/layout/select_folder_layout.xml delete mode 100644 app/src/main/res/menu/pgp_handler_new_password.xml delete mode 100644 app/src/main/res/menu/pgp_handler_select_folder.xml delete mode 100644 ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt (limited to 'ui/compose/src/main') diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cc10d21e..5f7a8ee8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -73,7 +73,6 @@ dependencies { implementation(libs.androidx.preference) implementation(libs.androidx.recyclerview) implementation(libs.androidx.recyclerviewSelection) - implementation(libs.androidx.swiperefreshlayout) implementation(libs.compose.ui.tooling) implementation(libs.dagger.hilt.android) @@ -86,7 +85,6 @@ dependencies { implementation(libs.aps.zxingAndroidEmbedded) implementation(libs.thirdparty.fastscroll) - implementation(libs.thirdparty.flowbinding.android) implementation(libs.thirdparty.jgit) { exclude(group = "org.apache.httpcomponents", module = "httpclient") } diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 20c4683f..a19d0ad9 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -36,29 +36,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> - - - - - - - - @@ -197,26 +175,4 @@ column="4"/> - - - - - - - - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b4859c0e..0429b2ac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -95,12 +95,6 @@ android:label="@string/action_settings" android:parentActivityName=".ui.main.MainActivity" /> - - - ( - R.layout.password_row_layout, - ::PasswordItemViewHolder, - coroutineScope, - dispatcherProvider, - PasswordItemViewHolder::bind, - ) { - - fun makeSelectable(recyclerView: RecyclerView) { - makeSelectable(recyclerView, ::PasswordItemDetailsLookup) - } - - override fun onItemClicked( - listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit - ): PasswordItemRecyclerAdapter { - return super.onItemClicked(listener) as PasswordItemRecyclerAdapter - } - - override fun onSelectionChanged( - listener: (selection: Selection) -> Unit - ): PasswordItemRecyclerAdapter { - return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter - } - - class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) { - - private val name: AppCompatTextView = itemView.findViewById(R.id.label) - private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count) - private val folderIndicator: AppCompatImageView = itemView.findViewById(R.id.folder_indicator) - var itemDetails: ItemDetailsLookup.ItemDetails? = null - - suspend fun bind(item: PasswordItem, dispatcherProvider: DispatcherProvider) { - val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "") - val source = - if (parentPath.isNotEmpty()) { - "$parentPath\n$item" - } else { - "$item" - } - val spannable = SpannableString(source) - spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0) - name.text = spannable - if (item.type == PasswordItem.TYPE_CATEGORY) { - folderIndicator.visibility = View.VISIBLE - val count = - withContext(dispatcherProvider.io()) { - item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size ?: 0 - } - childCount.visibility = if (count > 0) View.VISIBLE else View.GONE - childCount.text = "$count" - } else { - childCount.visibility = View.GONE - folderIndicator.visibility = View.GONE - } - itemDetails = - object : ItemDetailsLookup.ItemDetails() { - override fun getPosition() = absoluteAdapterPosition - - override fun getSelectionKey() = item.stableId - } - } - } - - class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : - ItemDetailsLookup() { - - override fun getItemDetails(event: MotionEvent): ItemDetails? { - val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null - return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt index ba49a62c..ccd3bf08 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt @@ -29,7 +29,6 @@ import app.passwordstore.util.extensions.clipboard import app.passwordstore.util.extensions.getString import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.extensions.snackbar -import app.passwordstore.util.extensions.unsafeLazy import app.passwordstore.util.services.ClipboardService import app.passwordstore.util.settings.Constants import app.passwordstore.util.settings.PreferenceKeys @@ -44,12 +43,6 @@ import kotlinx.coroutines.withContext @AndroidEntryPoint open class BasePGPActivity : AppCompatActivity() { - /** Full path to the repository */ - val repoPath by unsafeLazy { intent.getStringExtra("REPO_PATH")!! } - - /** Full path to the password file being worked on */ - val fullPath by unsafeLazy { intent.getStringExtra("FILE_PATH")!! } - /** Action to invoke if [keyImportAction] succeeds. */ private var onKeyImport: (() -> Unit)? = null private val keyImportAction = @@ -103,7 +96,6 @@ open class BasePGPActivity : AppCompatActivity() { companion object { const val EXTRA_FILE_PATH = "FILE_PATH" - const val EXTRA_REPO_PATH = "REPO_PATH" /** Gets the relative path to the repository */ fun getRelativePath(fullPath: String, repositoryPath: String): String = diff --git a/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt b/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt deleted file mode 100644 index d8610307..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import app.passwordstore.R -import app.passwordstore.data.passfile.PasswordEntry -import app.passwordstore.ui.APSAppBar -import app.passwordstore.ui.compose.PasswordField -import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.ui.compose.theme.SpacingLarge -import app.passwordstore.ui.compose.theme.SpacingMedium -import app.passwordstore.util.time.UserClock -import app.passwordstore.util.totp.UriTotpFinder - -/** Composable to show allow editing an existing [PasswordEntry]. */ -@Composable -fun EditPasswordScreen( - entryName: String, - entry: PasswordEntry, - onNavigateUp: () -> Unit, - @Suppress("UNUSED_PARAMETER") onSave: (PasswordEntry) -> Unit, - modifier: Modifier = Modifier, -) { - Scaffold( - topBar = { - APSAppBar( - title = entryName, - navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp), - onNavigationIconClick = onNavigateUp, - backgroundColor = MaterialTheme.colorScheme.surface, - ) - } - ) { paddingValues -> - Box(modifier = modifier.padding(paddingValues)) { - Column( - modifier = - Modifier.padding(vertical = SpacingMedium, horizontal = SpacingLarge).fillMaxSize() - ) { - if (entry.password != null) { - PasswordField( - value = entry.password!!, - label = stringResource(R.string.password), - initialVisibility = false, - modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(), - ) - } - ExtraContent(entry = entry) - } - } - } -} - -@Composable -private fun ExtraContent(entry: PasswordEntry, modifier: Modifier = Modifier) { - TextField( - value = entry.extraContentString, - onValueChange = {}, - label = { Text("Extra content") }, - modifier = modifier.fillMaxWidth(), - ) -} - -@Preview -@Composable -private fun EditPasswordScreenPreview() { - APSTheme { - EditPasswordScreen( - entryName = "Test Entry", - entry = createTestEntry(), - onNavigateUp = {}, - onSave = {}, - ) - } -} - -private fun createTestEntry() = - PasswordEntry( - UserClock(), - UriTotpFinder(), - """ - |My Password - |otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30 - |login: msfjarvis - |URL: example.com - """ - .trimMargin() - .encodeToByteArray(), - ) diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt deleted file mode 100644 index 7652c98e..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt +++ /dev/null @@ -1,510 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.graphics.ImageDecoder -import android.os.Build -import android.os.Bundle -import android.provider.MediaStore -import android.text.InputType -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.core.content.edit -import androidx.core.view.isVisible -import androidx.core.widget.doAfterTextChanged -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.passfile.PasswordEntry -import app.passwordstore.databinding.PasswordCreationActivityBinding -import app.passwordstore.ui.dialogs.DicewarePasswordGeneratorDialogFragment -import app.passwordstore.ui.dialogs.OtpImportDialogFragment -import app.passwordstore.ui.dialogs.PasswordGeneratorDialogFragment -import app.passwordstore.util.autofill.AutofillPreferences -import app.passwordstore.util.extensions.asLog -import app.passwordstore.util.extensions.base64 -import app.passwordstore.util.extensions.commitChange -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.extensions.isInsideRepository -import app.passwordstore.util.extensions.snackbar -import app.passwordstore.util.extensions.unsafeLazy -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.DirectoryStructure -import app.passwordstore.util.settings.PreferenceKeys -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.zxing.BinaryBitmap -import com.google.zxing.LuminanceSource -import com.google.zxing.RGBLuminanceSource -import com.google.zxing.common.HybridBinarizer -import com.google.zxing.integration.android.IntentIntegrator -import com.google.zxing.integration.android.IntentIntegrator.QR_CODE -import com.google.zxing.qrcode.QRCodeReader -import dagger.hilt.android.AndroidEntryPoint -import java.io.ByteArrayOutputStream -import java.io.IOException -import java.nio.file.Paths -import javax.inject.Inject -import kotlin.io.path.absolutePathString -import kotlin.io.path.createDirectories -import kotlin.io.path.deleteIfExists -import kotlin.io.path.exists -import kotlin.io.path.isSameFileAs -import kotlin.io.path.nameWithoutExtension -import kotlin.io.path.pathString -import kotlin.io.path.relativeTo -import kotlin.io.path.writeBytes -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -@AndroidEntryPoint -class PasswordCreationActivity : BasePGPActivity() { - - private val binding by viewBinding(PasswordCreationActivityBinding::inflate) - @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory - - private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } - private val suggestedUsername by unsafeLazy { intent.getStringExtra(EXTRA_USERNAME) } - private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) } - private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } - private val shouldGeneratePassword by unsafeLazy { - intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) - } - private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) } - private var oldCategory: String? = null - private var copy: Boolean = false - - private val otpImportAction = - registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - binding.otpImportButton.isVisible = false - val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data) - val contents = "${intentResult.contents}\n" - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$contents") - else binding.extraContent.append(contents) - snackbar(message = getString(R.string.otp_import_success)) - } else { - snackbar(message = getString(R.string.otp_import_failure_generic)) - } - } - - private val imageImportAction = - registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri -> - if (imageUri == null) { - snackbar(message = getString(R.string.otp_import_failure_no_selection)) - return@registerForActivityResult - } - val bitmap = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, imageUri)) - .copy(Bitmap.Config.ARGB_8888, true) - } else { - @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri) - } - val intArray = IntArray(bitmap.width * bitmap.height) - // copy pixel data from the Bitmap into the 'intArray' array - bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) - val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray) - val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) - - val reader = QRCodeReader() - runCatching { - val result = reader.decode(binaryBitmap) - val text = result.text - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$text") - else binding.extraContent.append(text) - snackbar(message = getString(R.string.otp_import_success)) - binding.otpImportButton.isVisible = false - } - .onFailure { snackbar(message = getString(R.string.otp_import_failure_generic)) } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - title = - if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title) - with(binding) { - setContentView(root) - generatePassword.setOnClickListener { generatePassword() } - otpImportButton.setOnClickListener { - supportFragmentManager.setFragmentResultListener( - OTP_RESULT_REQUEST_KEY, - this@PasswordCreationActivity, - ) { requestKey, bundle -> - if (requestKey == OTP_RESULT_REQUEST_KEY) { - val contents = bundle.getString(RESULT) - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$contents") - else binding.extraContent.append(contents) - } - } - val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true - if (hasCamera) { - val items = - arrayOf( - getString(R.string.otp_import_qr_code), - getString(R.string.otp_import_from_file), - getString(R.string.otp_import_manual_entry), - ) - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setItems(items) { _, index -> - when (index) { - 0 -> - otpImportAction.launch( - IntentIntegrator(this@PasswordCreationActivity) - .setOrientationLocked(false) - .setBeepEnabled(false) - .setDesiredBarcodeFormats(QR_CODE) - .createScanIntent() - ) - 1 -> imageImportAction.launch("image/*") - 2 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") - } - } - .show() - } else { - OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") - } - } - - directoryInputLayout.apply { - if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) { - isEnabled = true - } else { - setBackgroundColor(getColor(android.R.color.transparent)) - } - val path = getRelativePath(fullPath, repoPath) - // Keep empty path field visible if it is editable. - if (path.isEmpty() && !isEnabled) visibility = View.GONE - else { - directory.setText(path) - oldCategory = path - } - } - if (suggestedName != null) { - filename.setText(suggestedName) - } else { - filename.requestFocus() - } - - if ( - AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == - DirectoryStructure.EncryptedUsername || suggestedUsername != null - ) { - usernameInputLayout.visibility = View.VISIBLE - if (suggestedUsername != null) username.setText(suggestedUsername) - else if (suggestedName != null) username.requestFocus() - } - - // Allow the user to quickly switch between storing the username as the filename or - // in the encrypted extras. This only makes sense if the directory structure is - // FileBased. - if ( - suggestedName == null && - AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == - DirectoryStructure.FileBased - ) { - encryptUsername.apply { - visibility = View.VISIBLE - setOnClickListener { - if (isChecked) { - // User wants to enable username encryption, so we use the filename - // as username and insert it into the username input field. - val login = filename.text.toString() - filename.text?.clear() - username.setText(login) - usernameInputLayout.apply { visibility = View.VISIBLE } - } else { - // User wants to disable username encryption, so we take the username - // from the username text field and insert it into the filename input field. - val login = username.text.toString() - username.text?.clear() - filename.setText(login) - usernameInputLayout.apply { visibility = View.GONE } - } - } - } - } - suggestedPass?.let { - password.setText(it) - password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - suggestedExtra?.let { extraContent.setText(it) } - if (shouldGeneratePassword) { - generatePassword() - password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - } - listOf(binding.filename, binding.username, binding.extraContent).forEach { - it.doAfterTextChanged { updateViewState() } - } - updateViewState() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.pgp_handler_new_password, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - setResult(RESULT_CANCELED) - onBackPressedDispatcher.onBackPressed() - } - R.id.save_password -> { - copy = false - requireKeysExist { encrypt() } - } - R.id.save_and_copy_password -> { - copy = true - requireKeysExist { encrypt() } - } - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun generatePassword() { - supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { - requestKey, - bundle -> - if (requestKey == PASSWORD_RESULT_REQUEST_KEY) { - binding.password.setText(bundle.getString(RESULT)) - } - } - when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_DICEWARE) { - KEY_PWGEN_TYPE_CLASSIC -> - PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator") - KEY_PWGEN_TYPE_DICEWARE -> - DicewarePasswordGeneratorDialogFragment().show(supportFragmentManager, "generator") - } - } - - private fun updateViewState() = - with(binding) { - encryptUsername.apply { - if (visibility != View.VISIBLE) return@apply - val hasUsernameInFileName = filename.text.toString().isNotBlank() - val usernameIsEncrypted = username.text.toString().isNotEmpty() - isEnabled = hasUsernameInFileName xor usernameIsEncrypted - isChecked = usernameIsEncrypted - } - // Use PasswordEntry to parse extras for OTP - val entry = - passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".encodeToByteArray()) - otpImportButton.isVisible = !entry.hasTotp() - } - - /** Encrypts the password and the extra content */ - private fun encrypt() { - with(binding) { - val oldName = suggestedName - val editName = filename.text.toString().trim() - var editUsername = username.text.toString() - val editPass = password.text.toString() - val editExtra = extraContent.text.toString() - - if (editName.isEmpty()) { - snackbar(message = resources.getString(R.string.file_toast_text)) - return@with - } else if (editName.contains('/')) { - snackbar(message = resources.getString(R.string.invalid_filename_text)) - return@with - } - - if (!editUsername.isEmpty()) { - editUsername = "\nusername: $editUsername" - } - - if (editPass.isEmpty() && editExtra.isEmpty()) { - snackbar(message = resources.getString(R.string.empty_toast_text)) - return@with - } - - if (copy) { - copyPasswordToClipboard(editPass) - } - - // pass enters the key ID into `.gpg-id`. - val gpgIdentifiers = getPGPIdentifiers(directory.text.toString()) ?: return@with - val content = "$editPass$editUsername\n$editExtra" - val path = - when { - // If we allowed the user to edit the relative path, we have to consider it here - // instead of fullPath. - directoryInputLayout.isEnabled -> { - val editRelativePath = directory.text.toString().trim() - if (editRelativePath.isEmpty()) { - snackbar(message = resources.getString(R.string.path_toast_text)) - return - } - val passwordDirectory = Paths.get(repoPath, editRelativePath.trim('/')) - passwordDirectory.createDirectories() - if (!passwordDirectory.exists()) { - snackbar( - message = - "Failed to create directory ${passwordDirectory.relativeTo(Paths.get(repoPath)).pathString}" - ) - return - } - - "${passwordDirectory.pathString}/$editName.gpg" - } - else -> "$fullPath/$editName.gpg" - } - - lifecycleScope.launch(dispatcherProvider.main()) { - runCatching { - val result = - withContext(dispatcherProvider.io()) { - val outputStream = ByteArrayOutputStream() - repository.encrypt(gpgIdentifiers, content.byteInputStream(), outputStream) - } - .getOrElse { e -> - logcat(ERROR) { e.asLog("Failed to encrypt password file") } - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setTitle(R.string.password_creation_file_fail_title) - .setMessage( - getString(R.string.password_creation_encrypt_fail_message, e.message) - ) - .setPositiveButton(android.R.string.ok, null) - .show() - return@runCatching - } - val passwordFile = Paths.get(path) - // If we're not editing, this file should not already exist! - // Additionally, if we were editing and the incoming and outgoing - // filenames differ, it means we renamed. Ensure that the target - // doesn't already exist to prevent an accidental overwrite. - if ( - (!editing || (editing && suggestedName != passwordFile.nameWithoutExtension)) && - passwordFile.exists() - ) { - snackbar(message = getString(R.string.password_creation_duplicate_error)) - return@runCatching - } - - if (!passwordFile.toFile().isInsideRepository()) { - snackbar(message = getString(R.string.message_error_destination_outside_repo)) - return@runCatching - } - - withContext(dispatcherProvider.io()) { passwordFile.writeBytes(result.toByteArray()) } - - // associate the new password name with the last name's timestamp in history - val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) - val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$suggestedName.gpg".base64() - val timestamp = preference.getString(oldFilePathHash) - if (timestamp != null) { - preference.edit { - remove(oldFilePathHash) - putString(passwordFile.absolutePathString().base64(), timestamp) - } - } - - val returnIntent = Intent() - returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path) - returnIntent.putExtra(RETURN_EXTRA_NAME, editName) - returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName)) - - if (shouldGeneratePassword) { - val directoryStructure = AutofillPreferences.directoryStructure(applicationContext) - val entry = passwordEntryFactory.create(content.encodeToByteArray()) - returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) - val username = - entry.username ?: directoryStructure.getUsernameFor(passwordFile.toFile()) - returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) - } - - if ( - directoryInputLayout.isVisible && - directoryInputLayout.isEnabled && - oldName != editName - ) { - val oldPath = Paths.get(repoPath, oldCategory?.trim('/') ?: "", "$oldName.gpg") - if (!oldPath.isSameFileAs(passwordFile) && !oldPath.deleteIfExists()) { - setResult(RESULT_CANCELED) - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setTitle(R.string.password_creation_file_fail_title) - .setMessage( - getString(R.string.password_creation_file_delete_fail_message, oldName) - ) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .show() - return@runCatching - } - } - - val commitMessageRes = - if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text - lifecycleScope.launch { - commitChange( - resources.getString(commitMessageRes, getLongName(fullPath, repoPath, editName)) - ) - .onSuccess { - setResult(RESULT_OK, returnIntent) - finish() - } - } - } - .onFailure { e -> - if (e is IOException) { - logcat(ERROR) { e.asLog("Failed to write password file") } - setResult(RESULT_CANCELED) - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setTitle(getString(R.string.password_creation_file_fail_title)) - .setMessage(getString(R.string.password_creation_file_write_fail_message)) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .show() - } else { - logcat(ERROR) { e.asLog() } - } - } - } - } - } - - companion object { - - private const val KEY_PWGEN_TYPE_CLASSIC = "classic" - private const val KEY_PWGEN_TYPE_DICEWARE = "diceware" - const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR" - const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT" - const val RESULT = "RESULT" - const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE" - const val RETURN_EXTRA_NAME = "NAME" - const val RETURN_EXTRA_LONG_NAME = "LONG_NAME" - const val RETURN_EXTRA_USERNAME = "USERNAME" - const val RETURN_EXTRA_PASSWORD = "PASSWORD" - const val EXTRA_FILE_NAME = "FILENAME" - const val EXTRA_USERNAME = "USERNAME" - const val EXTRA_PASSWORD = "PASSWORD" - const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT" - const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD" - const val EXTRA_EDITING = "EDITING" - } -} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt deleted file mode 100644 index 2605e0ef..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.dialogs - -import android.app.AlertDialog -import android.app.Dialog -import android.content.SharedPreferences -import android.graphics.Typeface -import android.os.Bundle -import androidx.core.content.edit -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.databinding.FragmentPwgenDicewareBinding -import app.passwordstore.injection.prefs.PasswordGeneratorPreferences -import app.passwordstore.passgen.diceware.DicewarePassphraseGenerator -import app.passwordstore.ui.crypto.PasswordCreationActivity -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.settings.PreferenceKeys.DICEWARE_LENGTH -import app.passwordstore.util.settings.PreferenceKeys.DICEWARE_SEPARATOR -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.launch -import reactivecircus.flowbinding.android.widget.afterTextChanges - -@AndroidEntryPoint -class DicewarePasswordGeneratorDialogFragment : DialogFragment() { - - @Inject lateinit var dicewareGenerator: DicewarePassphraseGenerator - @Inject @PasswordGeneratorPreferences lateinit var prefs: SharedPreferences - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - - val binding = FragmentPwgenDicewareBinding.inflate(layoutInflater) - builder.setView(binding.root) - - binding.passwordSeparatorText.setText(prefs.getString(DICEWARE_SEPARATOR) ?: "-") - binding.passwordLengthText.setText(prefs.getInt(DICEWARE_LENGTH, 5).toString()) - binding.passwordText.typeface = Typeface.MONOSPACE - - lifecycleScope.launch { - merge( - binding.passwordLengthText.afterTextChanges(), - binding.passwordSeparatorText.afterTextChanges(), - ) - .collect { _ -> generatePassword(binding) } - } - return builder - .run { - setTitle(R.string.pwgen_title) - setPositiveButton(R.string.dialog_ok) { _, _ -> - setFragmentResult( - PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY, - bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}"), - ) - } - setNeutralButton(R.string.dialog_cancel) { _, _ -> } - setNegativeButton(R.string.pwgen_generate, null) - create() - } - .apply { - setOnShowListener { - generatePassword(binding) - getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { generatePassword(binding) } - } - } - } - - private fun generatePassword(binding: FragmentPwgenDicewareBinding) { - val length = binding.passwordLengthText.text?.toString()?.toIntOrNull() ?: 5 - val separator = binding.passwordSeparatorText.text?.toString()?.getOrNull(0) ?: '-' - setPreferences(length, separator) - binding.passwordText.text = dicewareGenerator.generatePassphrase(length, separator) - } - - private fun setPreferences(length: Int, separator: Char) { - prefs.edit { - putInt(DICEWARE_LENGTH, length) - putString(DICEWARE_SEPARATOR, separator.toString()) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt deleted file mode 100644 index 1a75175b..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.dialogs - -import android.app.Dialog -import android.net.Uri -import android.os.Bundle -import android.view.WindowManager -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import app.passwordstore.databinding.FragmentManualOtpEntryBinding -import app.passwordstore.ui.crypto.PasswordCreationActivity -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class OtpImportDialogFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater) - builder.setView(binding.root) - builder.setPositiveButton(android.R.string.ok) { _, _ -> - setFragmentResult( - PasswordCreationActivity.OTP_RESULT_REQUEST_KEY, - bundleOf(PasswordCreationActivity.RESULT to getTOTPUri(binding)), - ) - } - val dialog = builder.create() - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - return dialog - } - - private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String { - val secret = binding.secret.text.toString() - val account = binding.account.text.toString() - if (secret.isBlank()) return "" - val builder = Uri.Builder() - builder.scheme("otpauth") - builder.authority("totp") - builder.appendQueryParameter("secret", secret) - if (account.isNotBlank()) builder.appendQueryParameter("issuer", account) - return builder.build().toString() - } -} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt deleted file mode 100644 index d4a2372b..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.dialogs - -import android.app.AlertDialog -import android.app.Dialog -import android.content.Context -import android.graphics.Typeface -import android.os.Bundle -import android.widget.CheckBox -import android.widget.EditText -import android.widget.Toast -import androidx.annotation.IdRes -import androidx.appcompat.widget.AppCompatTextView -import androidx.core.content.edit -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.databinding.FragmentPwgenBinding -import app.passwordstore.passgen.random.MaxIterationsExceededException -import app.passwordstore.passgen.random.NoCharactersIncludedException -import app.passwordstore.passgen.random.PasswordGenerator -import app.passwordstore.passgen.random.PasswordLengthTooShortException -import app.passwordstore.passgen.random.PasswordOption -import app.passwordstore.ui.crypto.PasswordCreationActivity -import app.passwordstore.util.settings.PreferenceKeys -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.launch -import reactivecircus.flowbinding.android.widget.afterTextChanges -import reactivecircus.flowbinding.android.widget.checkedChanges - -class PasswordGeneratorDialogFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val prefs = requireContext().getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) - val builder = MaterialAlertDialogBuilder(requireContext()) - - val binding = FragmentPwgenBinding.inflate(layoutInflater) - builder.setView(binding.root) - - binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false) - binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false) - binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false) - binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false) - binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false) - binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true) - binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString()) - binding.passwordText.typeface = Typeface.MONOSPACE - - lifecycleScope.launch { - merge( - binding.numerals.checkedChanges().skipInitialValue(), - binding.symbols.checkedChanges().skipInitialValue(), - binding.uppercase.checkedChanges().skipInitialValue(), - binding.lowercase.checkedChanges().skipInitialValue(), - binding.ambiguous.checkedChanges().skipInitialValue(), - binding.pronounceable.checkedChanges().skipInitialValue(), - binding.lengthNumber.afterTextChanges().skipInitialValue(), - ) - .collect { generate(binding.passwordText) } - } - - return builder - .run { - setTitle(R.string.pwgen_title) - setPositiveButton(R.string.dialog_ok) { _, _ -> - setFragmentResult( - PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY, - bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}"), - ) - } - setNeutralButton(R.string.dialog_cancel) { _, _ -> } - setNegativeButton(R.string.pwgen_generate, null) - create() - } - .apply { - setOnShowListener { - generate(binding.passwordText) - getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { - generate(binding.passwordText) - } - } - } - } - - private fun generate(passwordField: AppCompatTextView) { - val passwordOptions = getSelectedOptions() - val passwordLength = getLength() - setPrefs(requireContext(), passwordOptions, passwordLength) - passwordField.text = - runCatching { PasswordGenerator.generate(passwordOptions, passwordLength) } - .getOrElse { exception -> - val errorText = - when (exception) { - is MaxIterationsExceededException -> - requireContext().getString(R.string.pwgen_max_iterations_exceeded) - is NoCharactersIncludedException -> - requireContext().getString(R.string.pwgen_no_chars_error) - is PasswordLengthTooShortException -> - requireContext().getString(R.string.pwgen_length_too_short_error) - else -> requireContext().getString(R.string.pwgen_some_error_occurred) - } - Toast.makeText(requireActivity(), errorText, Toast.LENGTH_SHORT).show() - "" - } - } - - private fun isChecked(@IdRes id: Int): Boolean { - return requireDialog().findViewById(id).isChecked - } - - private fun getSelectedOptions(): List { - return listOfNotNull( - PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) }, - PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) }, - PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) }, - PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) }, - PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) }, - PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) }, - ) - } - - private fun getLength(): Int { - val lengthText = requireDialog().findViewById(R.id.lengthNumber).text.toString() - return lengthText.toIntOrNull()?.takeIf { it >= 0 } ?: PasswordGenerator.DEFAULT_LENGTH - } - - /** - * Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for generated - * passwords. - */ - private fun setPrefs(ctx: Context, options: List, targetLength: Int): Boolean { - ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit { - for (possibleOption in PasswordOption.entries) { - putBoolean(possibleOption.key, possibleOption in options) - } - putInt("length", targetLength) - } - return true - } -} diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt deleted file mode 100644 index 2139d7d2..00000000 --- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.folderselect - -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.commit -import app.passwordstore.R -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) { - - private lateinit var passwordList: SelectFolderFragment - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - passwordList = SelectFolderFragment() - supportActionBar?.show() - supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - supportFragmentManager.commit { replace(R.id.pgp_handler_linearlayout, passwordList) } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.pgp_handler_select_folder, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - setResult(RESULT_CANCELED) - onBackPressedDispatcher.onBackPressed() - } - R.id.crypto_select -> selectFolder() - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun selectFolder() { - intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath) - setResult(RESULT_OK, intent) - finish() - } -} diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt deleted file mode 100644 index acf4b56d..00000000 --- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.folderselect - -import android.content.Context -import android.os.Bundle -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import app.passwordstore.R -import app.passwordstore.data.password.PasswordItem -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.databinding.PasswordRecyclerViewBinding -import app.passwordstore.ui.adapters.PasswordItemRecyclerAdapter -import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.viewmodel.ListMode -import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import dagger.hilt.android.AndroidEntryPoint -import java.io.File -import javax.inject.Inject -import kotlinx.coroutines.launch -import me.zhanghai.android.fastscroll.FastScrollerBuilder - -@AndroidEntryPoint -class SelectFolderFragment : Fragment(R.layout.password_recycler_view) { - - @Inject lateinit var dispatcherProvider: DispatcherProvider - private val binding by viewBinding(PasswordRecyclerViewBinding::bind) - private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter - private lateinit var listener: OnFragmentInteractionListener - - private val model: SearchableRepositoryViewModel by activityViewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.fab.hide() - recyclerAdapter = - PasswordItemRecyclerAdapter(lifecycleScope, dispatcherProvider).onItemClicked { _, item -> - listener.onFragmentInteraction(item) - } - binding.passRecycler.apply { - layoutManager = LinearLayoutManager(requireContext()) - itemAnimator = null - adapter = recyclerAdapter - } - - FastScrollerBuilder(binding.passRecycler).build() - registerForContextMenu(binding.passRecycler) - - model.navigateTo( - PasswordRepository.getRepositoryDirectory(), - listMode = ListMode.DirectoriesOnly, - pushPreviousLocation = false, - ) - lifecycleScope.launch { - model.searchResult.flowWithLifecycle(lifecycle).collect { result -> - recyclerAdapter.submitList(result.passwordItems) - } - } - } - - override fun onAttach(context: Context) { - super.onAttach(context) - runCatching { - listener = - object : OnFragmentInteractionListener { - override fun onFragmentInteraction(item: PasswordItem) { - if (item.type == PasswordItem.TYPE_CATEGORY) { - model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly) - (requireActivity() as AppCompatActivity) - .supportActionBar - ?.setDisplayHomeAsUpEnabled(true) - } - } - } - } - .onFailure { - throw ClassCastException("$context must implement OnFragmentInteractionListener") - } - } - - val currentDir: File - get() = model.currentDir.value - - interface OnFragmentInteractionListener { - - fun onFragmentInteraction(item: PasswordItem) - } -} diff --git a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt index a13484c6..bad55de4 100644 --- a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt +++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt @@ -95,18 +95,13 @@ fun FragmentActivity.isPermissionGranted(permission: String): Boolean { return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED } -/** - * Show a [Snackbar] in a [FragmentActivity] and correctly anchor it to a - * [com.google.android.material.floatingactionbutton.FloatingActionButton] if one exists in the - * [view] - */ +/** Show a [Snackbar] in a [FragmentActivity]. */ fun FragmentActivity.snackbar( view: View = findViewById(android.R.id.content), message: String, length: Int = Snackbar.LENGTH_SHORT, ): Snackbar { val snackbar = Snackbar.make(view, message, length) - snackbar.anchorView = findViewById(R.id.fab) snackbar.show() return snackbar } diff --git a/app/src/main/res/drawable/ic_done_24dp.xml b/app/src/main/res/drawable/ic_done_24dp.xml deleted file mode 100644 index f8b6cef6..00000000 --- a/app/src/main/res/drawable/ic_done_24dp.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_save_24dp.xml b/app/src/main/res/drawable/ic_save_24dp.xml deleted file mode 100644 index 8a4276c4..00000000 --- a/app/src/main/res/drawable/ic_save_24dp.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_save_copy_24dp.xml b/app/src/main/res/drawable/ic_save_copy_24dp.xml deleted file mode 100644 index 06419aac..00000000 --- a/app/src/main/res/drawable/ic_save_copy_24dp.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/fragment_manual_otp_entry.xml b/app/src/main/res/layout/fragment_manual_otp_entry.xml deleted file mode 100644 index 7d7ea832..00000000 --- a/app/src/main/res/layout/fragment_manual_otp_entry.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_pwgen.xml b/app/src/main/res/layout/fragment_pwgen.xml deleted file mode 100644 index 02eadff5..00000000 --- a/app/src/main/res/layout/fragment_pwgen.xml +++ /dev/null @@ -1,115 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_pwgen_diceware.xml b/app/src/main/res/layout/fragment_pwgen_diceware.xml deleted file mode 100644 index b8627670..00000000 --- a/app/src/main/res/layout/fragment_pwgen_diceware.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/password_creation_activity.xml b/app/src/main/res/layout/password_creation_activity.xml deleted file mode 100644 index c0692753..00000000 --- a/app/src/main/res/layout/password_creation_activity.xml +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/password_recycler_view.xml b/app/src/main/res/layout/password_recycler_view.xml deleted file mode 100644 index c2b5114f..00000000 --- a/app/src/main/res/layout/password_recycler_view.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/layout/password_row_layout.xml b/app/src/main/res/layout/password_row_layout.xml deleted file mode 100644 index 5c042839..00000000 --- a/app/src/main/res/layout/password_row_layout.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/layout/select_folder_layout.xml b/app/src/main/res/layout/select_folder_layout.xml deleted file mode 100644 index 78b03579..00000000 --- a/app/src/main/res/layout/select_folder_layout.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - diff --git a/app/src/main/res/menu/pgp_handler_new_password.xml b/app/src/main/res/menu/pgp_handler_new_password.xml deleted file mode 100644 index 6c61fb55..00000000 --- a/app/src/main/res/menu/pgp_handler_new_password.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - diff --git a/app/src/main/res/menu/pgp_handler_select_folder.xml b/app/src/main/res/menu/pgp_handler_select_folder.xml deleted file mode 100644 index d9505814..00000000 --- a/app/src/main/res/menu/pgp_handler_select_folder.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d13ab19f..a9fa4b00 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -39,7 +39,6 @@ In die Zwischenablage kopiert Bitte setzen Sie einen Dateinamen. - Bitte setzen Sie einen Pfad. Sie können kein leeres Passwort oder leere Extra-Angaben setzen. Es ist ein Fehler während der Git-Operation aufgetreten. @@ -63,7 +62,6 @@ Name Passwort Zusätzliche Angaben - Benutzername verschlüsseln Auswählen Speichern Speichern und kopieren @@ -125,7 +123,6 @@ Passwort erstellen Erstellen - Einschließen Länge Nummern Symbole @@ -173,7 +170,6 @@ Commit-Log anzeigen Zeige das Passwort. Soll das entschlüsselte Passwort sichtbar sein? Dies deaktiviert nicht das Kopieren. - Erstellen Passwort unverschlüsselt senden an… App-Icon @@ -207,7 +203,6 @@ Autofill-Unterstützung mit installierten Browsern: Der private SSH-Schlüssel konnte nicht geöffnet werden. Bitte überprüfen Sie, ob die Datei existiert. - Neues Passwort Gespeicherte Passphrase für lokalen SSH-Schlüssel löschen Gespeichertes HTTPS-Passwort löschen Schlüsselpasswort merken @@ -276,7 +271,6 @@ .gpg-id wurde gefunden, enthält jedoch ungültige Schlüssel-ID, Fingerabdruck oder Benutzer-ID. .gpg-id wurde gefunden, enthält jedoch eine kurze Hex-ID, die nicht unterstützt wird. Dateiname darf kein \'/\' enthalten; Verzeichnis oben setzen - Ordner Unbekannter Fehler Pull fehlgeschlagen. Sie befinden sich in einem abgelösten Head. Speichern Sie die Änderungen in \"Einstellungen > Lokale Git-Konfiguration & Dienstprogramme\" auf das Remote-Repository in einem neuen Branch und lösen Sie den Konflikt auf Ihrem Computer auf. @@ -319,11 +313,9 @@ Host-Schlüssel erfolgreich gelöscht! QR-Code scannen Wählen Sie ein Bild. - Manuell eingeben Schlüssel Benutzer-Konto Verknüpfung auf dem Home-Screen erstellen - Neues Passwort oder neuen Ordner erstellen Fehler beim Import des PGP-Schlüssels Ein bestehender Schlüssel mit dieser ID wurde gefunden. Möchten Sie ihn ersetzen? PGP-Schlüssel erfolgreich importiert diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 0cfb345c..d41048f8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -38,7 +38,6 @@ Copié dans le presse-papiers Veuillez fournir un nom de fichier - Veuillez fournir un chemin d\'accès au fichier Vous ne pouvez pas utiliser un mot de passe vide ou des données supplémentaires vide Une erreur c\'est produite lors d\'une opération Git @@ -62,7 +61,6 @@ Nom Mot de passe Extra - Chiffrer le nom d\'utilisateur Sélectionner Sauvegarder Enregistrer et copier @@ -113,7 +111,6 @@ Générer un mot de passe Générer - Inclure Longueur Chiffres Symboles @@ -161,7 +158,6 @@ Afficher l\'historique des commits Montrer le mot de passe Contrôle la visibilité des mots de passe une fois déchifrées, ceci n\'empêche pas de copier le mot de passe - Générer Envoyer le mot de passe en clair via… Icône de l\'application @@ -195,7 +191,6 @@ Prise en charge du remplissage automatique avec les navigateurs installés: Impossible d\'ouvrir la clef ssh, merci de vérifier que le ficher existe - Nouveau mot de passe Effacer le mot de passe enregistrée pour la clé SSH locale Effacer le mot de passe HTTPS enregistré Se rappeler de la phrase secrète dans la configuration de l\'application (peu sûr) @@ -261,7 +256,6 @@ Impossible d\'importer la configuration TOTP Exportation des mots de passe… Le nom du fichier ne doit pas contenir \'/\', définir le répertoire parent - Dossier Erreur inconnue Tirer a échoué, vous êtes dans un \'head\' détaché. En utilisant \"settings > git utils\", enregistrez vos modifications sur le dépôt distant dans une nouvelle branche et résolvez le conflit sur votre ordinateur. @@ -300,10 +294,8 @@ La clé d\'hôte a été effacée avec succès! Scanner le code QR Choisissez une image - Saisir manuellement Secret Compte - Créer un nouveau mot de passe ou dossier Impossible d\'importer la clé PGP Clé PGP importée avec succès La clé d\'identification de la clé importée est donné ci-dessous, veuillez la vérifier pour qu\'elle soit correcte :\n%1$s diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 589208ab..3fb321e7 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -39,7 +39,6 @@ Copiado ao portapapeis Debes proporcionar un nome de ficheiro - Por favor indica a ruta ao ficheiro Non podes deixar baleiro o contrasinal ou o contido extra Algo fallou na operación de Git @@ -63,7 +62,6 @@ Nome Contrasinal Contido extra - Cifrar identificador Seleccionar Gardar Gardar e copiar @@ -126,7 +124,6 @@ Crear contrasinal Crear - Incluír Lonxitude Números Símbolos @@ -174,7 +171,6 @@ Mostrar rexistro dos cambios Mostrar contrasinal Controlar a visibilidade do contrasinal unha vez descifrado, esto non desactiva o copiado do contrasinal - Crear Enviar contrasinal como texto plano usando… Icona da app @@ -209,7 +205,6 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid Soporte de autocompletado cos navegadores: Non se pode abrir a chave privada ssh, comproba que existe tal ficheiro - Novo contrasinal Baleirar a frase de paso gardada para a chave SSH local Baleirar contrasinal HTTPS gardado Lembrar frase de paso da chave @@ -278,7 +273,6 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid Atopouse .gpg-id, pero contén un ID de chave, impresión dixital ou ID de usuaria non válidos Atopouse .gpg-id, pero contén un ID hex curto, ao que non damos soporte O nome do ficheiro non pode conter \'/\', establece un directorio superior - Directorio Fallo descoñecido Fallou a acción pull, estás nun head diferente. Utiliza \"axustes > utilidades git\", garda os cambios no remoto nunha nova rama e resolve o conflicto nun ordenador. @@ -321,11 +315,9 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid Eliminouse correctamente a chave gardada do host! Escanear código QR Elixe unha imaxe - Escribir manualmente Segredo Conta Crear atallo na pantalla de inicio - Crear novo contrasinal ou cartafol Fallou a importación da chave PGP Atopouse unha chave existente con este ID, queres substituíla? Chave PGP importada correctamente diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 91cd28aa..c14efa33 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -38,7 +38,6 @@ Copiato negli appunti Inserisci un nome per il file - Inserisci il percorso del file Non puoi usare una password vuota o dei contenuti extra vuoti Si è verificato un errore durante un\'operazione Git @@ -62,7 +61,6 @@ Nome Password Contenuto extra - Crittografa nome utente Seleziona Salva Salva e Copia @@ -108,7 +106,6 @@ Genera Password Genera - Includi Lunghezza Numeri Simboli @@ -156,7 +153,6 @@ Mostra registro dei commit Mostra la password Controlla la visibilità delle password una volta decrittografate. Questo non disabilita la copia negli appunti. - Genera Invia password come testo semplice usando… Icona app @@ -190,7 +186,6 @@ Supporto alla compilazione automatica nei browser installati: Impossibile aprire la chiave privata SSH, controlla che il file esista - Nuova password Cancella la frase segreta salvata per la chiave SSH locale Cancella la password HTTPS salvata Ricorda la frase segreta della chiave @@ -256,7 +251,6 @@ Impossibile importare la configurazione di TOTP Esportazione delle password… Il nome del file non deve contenere \'/\', imposta la directory qui sopra - Directory Errore sconosciuto Pull non riuscito, sei in detached HEAD. Da \"Impostazioni > Utilità Git\" esegui il push delle modifiche sul remote in un nuovo branch e risolvi il conflitto dal computer. @@ -293,7 +287,6 @@ Cancella la chiave host salvata Chiave host cancellata con successo! Scegli un\'immagine - Crea nuova password o cartella Inizializza gli ID GPG in %1$s. Nome utente OTP (scade tra %ds) @@ -327,7 +320,6 @@ Il protocollo git di git-daemon non cifra il trasporto e non è adatto a operazioni sicure. Impostazioni proxy Scansiona codice QR - Inserisci manualmente Segreto Account Aggiungi scorciatoia alla schermata Home diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 7f63ad8b..3efbed68 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -37,7 +37,6 @@ 클립보드로 복사됨 파일 이름이 필요합니다 - 파일 경로가 필요합니다 비어있는 비밀번호나 추가 정보를 사용할 수 없습니다 Git 동작 중 문제가 발생하였습니다 @@ -61,7 +60,6 @@ 이름 비밀번호 추가 내용 - 암호화 계정 이름 선택 저장 저장 및 복사 @@ -121,7 +119,6 @@ 암호 생성 생성 - 포함 길이 숫자만 특수문자 @@ -169,7 +166,6 @@ 커밋 로그 보기 비밀번호 표시하기 암호가 해독된 후 비밀번호의 공개 여부를 제어합니다. 그렇다고 해서 클립보드 복사가 비활성화되지는 않습니다. - 생성 비밀번호를 일반 텍스트로 보내는 중… 앱 아이콘 @@ -203,7 +199,6 @@ 자동 완성 지원 하는 설치된 브라우저: SSH 개인 키를 열 수 없습니다. 파일이 존재하는지 확인하세요 - 새 암호 로컬 SSH 키의 저장된 비밀번호 지우기 저장된 HTTPS 비밀번호 지우기 키 암호 기억하기 @@ -274,7 +269,6 @@ username@example.com:… .gpg-id를 찾았지만 잘못된 키 ID, 지문 또는 사용자 ID가 포함되어 있습니다 .gpg-id를 찾았지만, 지원되지 않는 짧은 16진수 ID가 포함되어 있습니다. 파일 이름에 \'/\'가 포함되어서는 안됩니다. 위의 디렉터리를 설정하세요 - 디렉토리 알 수 없는 오류 풀이 실패하여 head가 분리된 상태입니다. \"설정 > git 기능\"을 사용하여 원격 저장소에 변경 내용을 새 브랜치에 저장하고 컴퓨터에서 충돌을 해결하세요. @@ -317,11 +311,9 @@ username@example.com:… 저장된 호스트 키를 성공적으로 지웠습니다! QR코드 스캔 이미지 선택 - 직접 입력 비밀 계정 홈 화면에 추가 - 새 비밀번호 또는 폴더 만들기 PGP 키를 가져오지 못했습니다 이 ID를 가진 기존 키가 발견되었습니다. 교체하시겠습니까? PGP 키를 성공적으로 가져왔습니다 diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index a2068e65..65f14ce5 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -37,7 +37,6 @@ Skopiowano do schowka Podaj nazwę pliku - Podaj ścieżkę do pliku Nie możesz użyć pustego hasła lub pustej dodatkowej zawartości Wystąpił błąd podczas operacji Git @@ -61,7 +60,6 @@ Nazwa Hasło Treść dodatkowa - Szyfruj nazwę użytkownika Wybierz Zapisz Zapisz i skopiuj @@ -122,7 +120,6 @@ Generuj hasło Wygeneruj - Załącz Długość Cyfry Symbole @@ -170,7 +167,6 @@ Pokaż dziennik commitów Pokaż hasło Kontroluj widoczność haseł po odszyfrowaniu. To nie wyłącza kopiowania do schowka. - Generuj Wyślij niezaszyfrowane hasło, używając… Ikona aplikacji @@ -202,7 +198,6 @@ Obsługa autouzupełniania w zainstalowanych przeglądarkach: Nie można otworzyć klucza prywatnego ssh, sprawdź czy plik istnieje - Nowe hasło Wyczyść zapisane hasło dla lokalnego klucza SSH Wyczyść zapisane hasło HTTPS Zapamiętaj hasło klucza @@ -268,7 +263,6 @@ Znaleziono .gpg-id, ale zawiera nieprawidłowy identyfikator klucza, odcisk palca lub identyfikator użytkownika Znaleziono .gpg-id, ale zawarty w nim skrócony identyfikator nie jest obsługiwany Nazwa pliku nie może zawierać \'/\', ustaw katalog powyżej - Katalog Nieznany błąd Zaciąganie zmian nie powiodło się, nie jesteś na żadnej gałęzi (detached head). Używając \"Ustawienia > narzędzia Git\", wyślij lokalne zmiany do zdalnego repozytorium na nowej gałęzi i rozwiąż konflikt na komputerze. @@ -309,11 +303,9 @@ Pomyślnie wyczyszczono zapisany klucz hosta! Skanuj kod QR Wybierz obraz - Wprowadź ręcznie Sekret Konto Utwórz skrót na pulpicie - Utwórz nowe hasło lub folder Nie udało się zaimportować klucza PGP Znaleziono istniejący klucz z tym identyfikatorem, czy chcesz go zastąpić? Pomyślnie zaimportowano klucz PGP diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 909fd33e..86ebfb24 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -39,7 +39,6 @@ Copiado para a área de transferência Por favor, informe um nome de arquivo - Por favor, forneça o caminho do arquivo Você não pode usar uma senha vazia ou conteúdo extra vazio Ocorreu um erro durante uma operação do Git @@ -63,7 +62,6 @@ Nome Senha Conteúdo extra - Criptografar nome de usuário Selecionar Salvar Salvar e Copiar @@ -125,7 +123,6 @@ Gerar Senha Gerar - Incluir Tamanho Numerais Símbolos @@ -173,7 +170,6 @@ Mostrar log de commit Mostrar a senha Controle a visibilidade das senhas quando descriptografadas. Isso não desativa a cópia para a área de transferência. - Gerar Enviar senha como texto simples usando… Ícone do aplicativo @@ -207,7 +203,6 @@ Suporte ao preenchimento automático com navegadores instalados: Não foi possível abrir a chave privada ssh, por favor verifique se o arquivo existe - Nova senha Limpar a frase secreta salva para chave SSH local Limpar senha HTTPS salva Lembrar senha da chave @@ -276,7 +271,6 @@ Encontrado .gpg-id, mas contém um ID de chave, fingerprint ou ID de usuário inválidos Encontrado .gpg-id, mas contém uma ID hexadecimal curta, que não é suportada Nome do arquivo não deve conter \'/\', defina o diretório acima - Diretório Erro desconhecido O pull falhou, você está em uma Head avulsa. Usando \"configurações > utils\" do git, salve suas alterações no remoto em uma nova branch e resolva o conflito no seu computador. @@ -319,11 +313,9 @@ Chave de host limpa com sucesso! Escanear QR code Selecione uma imagem - Inserir manualmente Segredo Conta Criar atalho na tela inicial - Criar nova senha ou pasta Falha ao importar a chave PGP Uma chave existente com esse ID foi encontrada, você deseja substituí-lo? Chave PGP importada com sucesso diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2ef59d70..8873b34f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -43,7 +43,6 @@ Скопировано в буфер обмена Пожалуйста, укажите имя файла - Пожалуйста, задайте путь к файлу Вы не можете использовать пустой пароль или пустое поле информации Произошла ошибка выполнения операции Git @@ -67,7 +66,6 @@ название Пароль Дополнительно - Зашифровать имя пользователя Выбрать Сохранить Сохранить и скопировать @@ -118,7 +116,6 @@ Сгенерировать пароль Сгенерировать - Включая Длина Цифры Символы @@ -166,7 +163,6 @@ Показать журнал изменений Показывать пароли Видимость расшифрованных паролей, не влияет на копирование в буфер. - Сгенерировать Поделиться паролем в открытом виде с помощью Иконка приложения @@ -200,7 +196,6 @@ Поддержка автозаполнения установленными браузерами: Невозможно открыть приватный ключ ssh, пожалуйста проверьте, что файл существует - Новый пароль Очистить сохраненную кодовую фразу для локального SSH ключа Удалить сохраненный пароль HTTPS Запомнить кодовую фразу @@ -268,7 +263,6 @@ Экспорт паролей… Найден .gpg-id, но он содержит неверный ID ключа, fingerprint или ID пользователя Имя файла не должно содержать \'/\', укажите директорию выше - Директория Неизвестная ошибка Не удалось получить изменения, вы находитесь в состоянии \"оторванной головы\". Используйте \"настройки > утилиты git\", сохраните ваши изменения в новую удаленную ветку и разрешите конфликты на своем компьютере. @@ -311,10 +305,8 @@ Сохраненный ключ хоста был успешно удален! Отсканировать QR-код Выберите изображение - Ввести вручную Учетная запись Разместить ярлык на рабочем столе - Создать новый пароль или папку Не удалось импортировать PGP-ключ PGP ключ успешно импортирован diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 55fbeefa..746b74d2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -38,7 +38,6 @@ 复制到剪切板 请提供文件名 - 请提供文件路径 您不能使用空密码或空额外内容 Git操作过程中发生错误 @@ -62,7 +61,6 @@ 名称 密码 附加内容 - 加密用户名 选择 保存 保存并复制 @@ -121,7 +119,6 @@ 生成密码 生成 - 包含 长度 数字 标识 @@ -174,7 +171,6 @@ 显示提交日志 显示密码 控制密码解密后的可见性,这不会禁用复制到剪贴板 - 生成 使用明文发送密码… 应用图标 @@ -209,7 +205,6 @@ 已安装支持自动填充的浏览器: 无法打开SSH私钥,请检查文件是否存在 - 新密码 清除本地已保存的SSH密钥密码 清除保存的HTTPS密码 记住密钥密码 @@ -278,7 +273,6 @@ personal.com 找到了 .gpg-id,但它包含无效的密钥ID、指纹或用户ID 找到 .gpg-id 但它包含一个不受支持的短十六进制ID。 文件名不能包含\'/\' - 目录 未知错误 合并失败,你处于冲突状态。 @@ -324,11 +318,9 @@ GPG 已成功清除已保存的主机密钥! 扫描二维码 选择图像 - 手动输入 安全码 (Secret) 账户 在主屏幕上放置快捷方式 - 创建新密码或文件夹 导入 PGP 密钥失败 发现了 ID 相同的现有密钥,您要替换它吗? 成功导入 PGP 密钥 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 34b11662..239cb219 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,7 +47,6 @@ Copied to clipboard Please provide a file name - Please provide a file path You cannot use an empty password or empty extra content @@ -79,7 +78,6 @@ Username Password Extra content - Encrypt username Select Save Save and Copy @@ -148,7 +146,6 @@ Generate Password Generate - Include Length Numerals Symbols @@ -201,7 +198,6 @@ Show commit log Show the password Control the visibility of the passwords once decrypted. This does not disable copying to clipboard. - Generate Send password as plaintext using… App icon @@ -239,7 +235,6 @@ Unable to open the ssh private key, please check that the file exists - New password Clear saved passphrase for local SSH key Clear saved HTTPS password Remember key passphrase @@ -311,7 +306,6 @@ Found .gpg-id, but it contains an invalid key ID, fingerprint or user ID Found .gpg-id, but it contains a short hex ID, which is not supported File name must not contain \'/\', set directory above - Directory Unknown error @@ -360,11 +354,9 @@ Successfully cleared saved host key! Scan QR code Choose an image - Enter manually Secret Account Place shortcut on home screen - Create new password or folder Failed to import PGP key An existing key with this ID was found, do you want to replace it? Successfully imported PGP key diff --git a/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md b/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md index 75a599fa..7eab1692 100644 --- a/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md +++ b/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md @@ -46,7 +46,10 @@ the maintainer's phone stays on 0.1.4 until then. - Password with reveal and a generator button; the generator is a bottom sheet with random and diceware tabs. - Username field, free-text extra content (pass format kept as text, nothing hidden). -- "OTP from QR" and "OTP manual" actions. +- "OTP from QR" (camera or image) and "OTP manual" actions. +- "Save and copy" in the overflow. Saving returns to the list; closing returns to where the + editor was opened. The folder picker is a dialog, shared with the list's move action. The + autofill save flow (`AutofillSaveActivity`) hosts the same editor. ## Architecture @@ -94,7 +97,8 @@ needs it. ## Security - `FLAG_SECURE` is set on the window while the entry view or editor destination is shown and - cleared when leaving it, so secrets stay out of screenshots and recents. + cleared when leaving it, so secrets stay out of screenshots and recents. It is counted, so + moving between the two never clears it. - `MainActivity` is not exported. Routes come only from `LaunchActivity` after its checks; an entry route is still validated with `isFileInRepo`. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9d8f4111..13d866e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,6 @@ agp = "9.5.0" androidxActivity = "1.9.2" bouncycastle = "1.86" coroutines = "1.9.0" -flowbinding = "1.2.0" hilt = "2.60.1" kotlin = "2.4.21" kotlinResult = "2.0.0" @@ -33,7 +32,6 @@ androidx-material = "com.google.android.material:material:1.12.0" androidx-preference = "androidx.preference:preference:1.2.1" androidx-recyclerview = "androidx.recyclerview:recyclerview:1.4.0" androidx-recyclerviewSelection = "androidx.recyclerview:recyclerview-selection:1.2.0" -androidx-swiperefreshlayout = "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0" aps-sublimeFuzzy = "com.github.android-password-store:sublime-fuzzy:2.3.4" aps-zxingAndroidEmbedded = "com.github.android-password-store:zxing-android-embedded:4.2.1" build-agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } @@ -77,7 +75,6 @@ thirdparty-bouncycastle-bcutil = { module = "org.bouncycastle:bcutil-jdk18on", v thirdparty-commons_codec = "commons-codec:commons-codec:1.14" thirdparty-compose-lints = "com.slack.lint.compose:compose-lint-checks:1.4.1" thirdparty-fastscroll = "me.zhanghai.android.fastscroll:library:1.3.0" -thirdparty-flowbinding-android = { module = "io.github.reactivecircus.flowbinding:flowbinding-android", version.ref = "flowbinding" } # JGit upgrades also raise its minimum Java runtime requirements that we cannot satisfy on Android # noinspection GradleDependency thirdparty-jgit = "org.eclipse.jgit:org.eclipse.jgit:5.13.5.202508271544-r" diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt deleted file mode 100644 index 066a839c..00000000 --- a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.compose - -import androidx.annotation.StringRes -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation - -@Composable -public fun PasswordField( - value: String, - label: String, - initialVisibility: Boolean, - modifier: Modifier = Modifier, - readOnly: Boolean = false, -) { - var visible by remember { mutableStateOf(initialVisibility) } - TextField( - value = value, - onValueChange = {}, - readOnly = readOnly, - label = { Text(label) }, - visualTransformation = - if (visible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - ToggleButton( - visible = visible, - contentDescription = "Toggle password visibility", - onButtonClick = { visible = !visible }, - ) - }, - modifier = modifier, - ) -} - -@Composable -private fun ToggleButton( - visible: Boolean, - contentDescription: String, - onButtonClick: () -> Unit, - modifier: Modifier = Modifier, -) { - IconButton(onClick = onButtonClick, modifier = modifier) { - val icon = - if (visible) painterResource(id = R.drawable.baseline_visibility_off_24) - else painterResource(id = R.drawable.baseline_visibility_24) - Icon(painter = icon, contentDescription = contentDescription) - } -} - -@Composable -public fun CopyButton( - textToCopy: String, - @StringRes buttonLabelRes: Int, - modifier: Modifier = Modifier, -) { - val clipboard = LocalClipboardManager.current - IconButton(onClick = { clipboard.setText(AnnotatedString(textToCopy)) }, modifier = modifier) { - Icon( - painter = painterResource(R.drawable.ic_content_copy), - contentDescription = stringResource(buttonLabelRes), - ) - } -} -- cgit v1.2.3 From 2911783653f699e41a07bb9536460d9d0a1c3b70 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 14:18:42 +0100 Subject: refactor(app): remove the legacy setup screens The Compose welcome, git server, SSH key, key generation and PGP keys screens replace OnboardingActivity with its fragments, GitServerConfigActivity with BasicBottomSheet, SshKeyImportActivity, SshKeyGenActivity, ShowSshKeyFragment, PGPKeyImportActivity and PGPKeyListActivity with KeyList and PGPKeyListViewModel, their layouts and manifest entries, and ui:compose's APSAppBar. Resources only they used are removed too. Co-Authored-By: Claude Opus 5.5 --- app/lint.xml | 6 - app/src/main/AndroidManifest.xml | 29 -- .../passwordstore/ui/dialogs/BasicBottomSheet.kt | 178 ------------ .../ui/git/config/GitServerConfigActivity.kt | 304 --------------------- .../ui/onboarding/activity/OnboardingActivity.kt | 27 -- .../ui/onboarding/fragments/CloneFragment.kt | 78 ------ .../onboarding/fragments/KeySelectionFragment.kt | 77 ------ .../ui/onboarding/fragments/WelcomeFragment.kt | 33 --- .../passwordstore/ui/pgp/PGPKeyImportActivity.kt | 109 -------- .../java/app/passwordstore/ui/pgp/PGPKeyList.kt | 169 ------------ .../app/passwordstore/ui/pgp/PGPKeyListActivity.kt | 94 ------- .../ui/sshkeygen/ShowSshKeyFragment.kt | 39 --- .../ui/sshkeygen/SshKeyGenActivity.kt | 173 ------------ .../ui/sshkeygen/SshKeyImportActivity.kt | 66 ----- .../util/viewmodel/PGPKeyListViewModel.kt | 48 ---- app/src/main/res/drawable/ic_add_48dp.xml | 15 - .../main/res/drawable/ic_arrow_back_black_24dp.xml | 15 - app/src/main/res/layout/activity_git_clone.xml | 94 ------- app/src/main/res/layout/activity_onboarding.xml | 11 - app/src/main/res/layout/activity_ssh_keygen.xml | 69 ----- app/src/main/res/layout/basic_bottom_sheet.xml | 62 ----- app/src/main/res/layout/fragment_clone.xml | 96 ------- app/src/main/res/layout/fragment_key_selection.xml | 80 ------ app/src/main/res/layout/fragment_welcome.xml | 67 ----- app/src/main/res/values-de/strings.xml | 5 - app/src/main/res/values-fr/strings.xml | 4 - app/src/main/res/values-gl/strings.xml | 5 - app/src/main/res/values-hdpi/dimens.xml | 10 - app/src/main/res/values-it/strings.xml | 5 - app/src/main/res/values-ko-rKR/strings.xml | 5 - app/src/main/res/values-pl-rPL/strings.xml | 4 - app/src/main/res/values-pt-rBR/strings.xml | 5 - app/src/main/res/values-ru/strings.xml | 5 - app/src/main/res/values-zh-rCN/strings.xml | 7 - app/src/main/res/values/dimens.xml | 4 - app/src/main/res/values/strings.xml | 5 - .../specs/2026-11-02-compose-ui-overhaul-design.md | 17 ++ .../main/kotlin/app/passwordstore/ui/APSAppBar.kt | 76 ------ 38 files changed, 17 insertions(+), 2079 deletions(-) delete mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt delete mode 100644 app/src/main/res/drawable/ic_add_48dp.xml delete mode 100644 app/src/main/res/drawable/ic_arrow_back_black_24dp.xml delete mode 100644 app/src/main/res/layout/activity_git_clone.xml delete mode 100644 app/src/main/res/layout/activity_onboarding.xml delete mode 100644 app/src/main/res/layout/activity_ssh_keygen.xml delete mode 100644 app/src/main/res/layout/basic_bottom_sheet.xml delete mode 100644 app/src/main/res/layout/fragment_clone.xml delete mode 100644 app/src/main/res/layout/fragment_key_selection.xml delete mode 100644 app/src/main/res/layout/fragment_welcome.xml delete mode 100644 app/src/main/res/values-hdpi/dimens.xml delete mode 100644 ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.kt (limited to 'ui/compose/src/main') diff --git a/app/lint.xml b/app/lint.xml index c0d501e0..8834cbd7 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -13,12 +13,6 @@ - - - - - - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0429b2ac..546bdde4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,11 +33,6 @@ tools:ignore="GoogleAppIndexingWarning" tools:targetApi="s"> - - - - - - - - diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt b/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt deleted file mode 100644 index e689f3bd..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.dialogs - -import android.content.Context -import android.content.DialogInterface.OnDismissListener -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewTreeObserver -import android.widget.FrameLayout -import androidx.annotation.StringRes -import androidx.core.view.isVisible -import app.passwordstore.R -import app.passwordstore.databinding.BasicBottomSheetBinding -import app.passwordstore.util.extensions.viewBinding -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment - -/** - * [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like API - * through [BasicBottomSheet.Builder] to create a similar UI, just at the bottom of the screen. - */ -class BasicBottomSheet -private constructor( - val title: String?, - val message: String, - val positiveButtonLabel: String?, - val negativeButtonLabel: String?, - val positiveButtonClickListener: View.OnClickListener?, - val negativeButtonClickListener: View.OnClickListener?, - val onDismissListener: OnDismissListener?, -) : BottomSheetDialogFragment() { - - private val binding by viewBinding(BasicBottomSheetBinding::bind) - - private var behavior: BottomSheetBehavior? = null - private val bottomSheetCallback = - object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, slideOffset: Float) {} - - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_COLLAPSED) { - dismiss() - } - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - if (savedInstanceState != null) dismiss() - return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - view.viewTreeObserver.addOnGlobalLayoutListener( - object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - view.viewTreeObserver.removeOnGlobalLayoutListener(this) - val dialog = dialog as BottomSheetDialog? ?: return - behavior = dialog.behavior - behavior?.apply { - state = BottomSheetBehavior.STATE_EXPANDED - peekHeight = 0 - addBottomSheetCallback(bottomSheetCallback) - } - if (!title.isNullOrEmpty()) { - binding.bottomSheetTitle.isVisible = true - binding.bottomSheetTitle.text = title - } - binding.bottomSheetMessage.text = message - if (positiveButtonClickListener != null) { - positiveButtonLabel?.let { buttonLbl -> binding.bottomSheetOkButton.text = buttonLbl } - binding.bottomSheetOkButton.isVisible = true - binding.bottomSheetOkButton.setOnClickListener { - positiveButtonClickListener.onClick(it) - dismiss() - } - } - if (negativeButtonClickListener != null) { - binding.bottomSheetCancelButton.isVisible = true - negativeButtonLabel?.let { buttonLbl -> - binding.bottomSheetCancelButton.text = buttonLbl - } - binding.bottomSheetCancelButton.setOnClickListener { - negativeButtonClickListener.onClick(it) - dismiss() - } - } - if (onDismissListener != null) { - dialog.setOnDismissListener(onDismissListener) - } - } - } - ) - } - - override fun dismiss() { - super.dismiss() - behavior?.removeBottomSheetCallback(bottomSheetCallback) - } - - class Builder(val context: Context) { - - private var title: String? = null - private var message: String? = null - private var positiveButtonLabel: String? = null - private var negativeButtonLabel: String? = null - private var positiveButtonClickListener: View.OnClickListener? = null - private var negativeButtonClickListener: View.OnClickListener? = null - private var onDismissListener: OnDismissListener? = null - - fun setTitleRes(@StringRes titleRes: Int): Builder { - this.title = context.resources.getString(titleRes) - return this - } - - fun setTitle(title: String): Builder { - this.title = title - return this - } - - fun setMessageRes(@StringRes messageRes: Int): Builder { - this.message = context.resources.getString(messageRes) - return this - } - - fun setMessage(message: String): Builder { - this.message = message - return this - } - - fun setPositiveButtonClickListener( - buttonLabel: String? = null, - listener: View.OnClickListener, - ): Builder { - this.positiveButtonClickListener = listener - this.positiveButtonLabel = buttonLabel - return this - } - - fun setNegativeButtonClickListener( - buttonLabel: String? = null, - listener: View.OnClickListener, - ): Builder { - this.negativeButtonClickListener = listener - this.negativeButtonLabel = buttonLabel - return this - } - - fun setOnDismissListener(onDismissListener: OnDismissListener): Builder { - this.onDismissListener = onDismissListener - return this - } - - fun build(): BasicBottomSheet { - require(message != null) { "Message needs to be set" } - return BasicBottomSheet( - title, - message!!, - positiveButtonLabel, - negativeButtonLabel, - positiveButtonClickListener, - negativeButtonClickListener, - onDismissListener, - ) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt b/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt deleted file mode 100644 index 2a1d3ebd..00000000 --- a/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt +++ /dev/null @@ -1,304 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.git.config - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.view.MenuItem -import android.view.View -import androidx.core.os.postDelayed -import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.databinding.ActivityGitCloneBinding -import app.passwordstore.ui.dialogs.BasicBottomSheet -import app.passwordstore.ui.git.base.BaseGitActivity -import app.passwordstore.util.extensions.snackbar -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.AuthMode -import app.passwordstore.util.settings.GitSettings -import app.passwordstore.util.settings.Protocol -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -/** - * Activity that encompasses both the initial clone as well as editing the server config for future - * changes. - */ -class GitServerConfigActivity : BaseGitActivity() { - - private val binding by viewBinding(ActivityGitCloneBinding::inflate) - - private lateinit var newAuthMode: AuthMode - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val isClone = intent?.extras?.getBoolean("cloning") ?: false - if (isClone) { - binding.saveButton.text = getString(R.string.clone_button) - } - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - newAuthMode = gitSettings.authMode - - binding.authModeGroup.apply { - when (newAuthMode) { - AuthMode.SshKey -> check(binding.authModeSshKey.id) - AuthMode.Password -> check(binding.authModePassword.id) - AuthMode.None -> clearChecked() - } - addOnButtonCheckedListener { _, _, _ -> - if (checkedButtonIds.isEmpty()) { - newAuthMode = AuthMode.None - } else { - when (checkedButtonId) { - binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey - binding.authModePassword.id -> newAuthMode = AuthMode.Password - View.NO_ID -> newAuthMode = AuthMode.None - } - } - } - } - - binding.serverUrl.setText( - gitSettings.url.also { - if (it.isNullOrEmpty()) return@also - setAuthModes(it.startsWith("http://") || it.startsWith("https://")) - } - ) - - binding.serverUrl.doOnTextChanged { text, _, _, _ -> - if (text.isNullOrEmpty()) return@doOnTextChanged - setAuthModes(text.startsWith("http://") || text.startsWith("https://")) - } - - binding.clearHostKeyButton.isVisible = gitSettings.hasSavedHostKey() - binding.clearHostKeyButton.setOnClickListener { - gitSettings.clearSavedHostKey() - Snackbar.make( - binding.root, - getString(R.string.clear_saved_host_key_success), - Snackbar.LENGTH_LONG, - ) - .show() - it.isVisible = false - } - binding.saveButton.setOnClickListener { - val newUrl = binding.serverUrl.text.toString().trim() - // If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://` - // in the beginning will cause the port to be seen as part of the path. Let users know - // about it and offer a quickfix. - if (newUrl.contains(PORT_REGEX)) { - if (newUrl.startsWith("https://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.https_scheme_with_port_title) - .setMessageRes(R.string.https_scheme_with_port_message) - .setPositiveButtonClickListener { - binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/")) - } - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } else if (!newUrl.startsWith("ssh://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.ssh_scheme_needed_message) - .setPositiveButtonClickListener { - @Suppress("SetTextI18n") binding.serverUrl.setText("ssh://$newUrl") - } - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } - } - if (newUrl.startsWith("git://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.git_scheme_disallowed_title) - .setMessageRes(R.string.git_scheme_disallowed_message) - .setPositiveButtonClickListener {} - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } - when ( - val updateResult = - gitSettings.updateConnectionSettingsIfValid( - newAuthMode = newAuthMode, - newUrl = binding.serverUrl.text.toString().trim(), - ) - ) { - GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> { - Snackbar.make( - binding.root, - getString(R.string.git_server_config_save_error), - Snackbar.LENGTH_LONG, - ) - .show() - } - is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> { - when (updateResult.newProtocol) { - Protocol.Https -> - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.git_server_config_save_missing_username_https) - .setPositiveButtonClickListener {} - .build() - .show(supportFragmentManager, "HTTPS_MISSING_USERNAME") - Protocol.Ssh -> - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.git_server_config_save_missing_username_ssh) - .setPositiveButtonClickListener {} - .build() - .show(supportFragmentManager, "SSH_MISSING_USERNAME") - } - } - GitSettings.UpdateConnectionSettingsResult.Valid -> { - if (isClone && PasswordRepository.repository == null) PasswordRepository.initialize() - if (!isClone) { - Snackbar.make( - binding.root, - getString(R.string.git_server_config_save_success), - Snackbar.LENGTH_SHORT, - ) - .show() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } else { - cloneRepository() - } - } - is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> { - val message = - getString( - R.string.git_server_config_save_auth_mode_mismatch, - updateResult.newProtocol, - updateResult.validModes.joinToString(", "), - ) - Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() - } - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private fun setAuthModes(isHttps: Boolean) = - with(binding) { - if (isHttps) { - authModeSshKey.isVisible = false - authModePassword.isVisible = true - if (authModeGroup.checkedButtonId != authModePassword.id) authModeGroup.clearChecked() - } else { - authModeSshKey.isVisible = true - authModePassword.isVisible = true - if (authModeGroup.checkedButtonId == View.NO_ID) authModeGroup.check(authModeSshKey.id) - } - } - - /** Clones the repository, the directory exists, deletes it */ - private fun cloneRepository() { - val localDir = - requireNotNull(PasswordRepository.getRepositoryDirectory()) { - "Repository directory must be set before cloning" - } - val localDirFiles = localDir.listFiles() ?: emptyArray() - // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder - if ( - localDir.exists() && - localDirFiles.isNotEmpty() && - !(localDirFiles.size == 1 && localDirFiles[0].name == ".git") - ) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_delete_title) - .setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString())) - .setCancelable(false) - .setPositiveButton(R.string.dialog_delete) { dialog, _ -> - runCatching { - lifecycleScope.launch { - val snackbar = - snackbar( - message = getString(R.string.delete_directory_progress_text), - length = Snackbar.LENGTH_INDEFINITE, - ) - withContext(dispatcherProvider.io()) { - localDir.deleteRecursively() - localDir.mkdirs() - } - snackbar.dismiss() - launchGitOperation(GitOp.CLONE) - .fold( - success = { - setResult(RESULT_OK) - finish() - }, - failure = { err -> promptOnErrorHandler(err) { finish() } }, - ) - } - } - .onFailure { e -> - e.printStackTrace() - MaterialAlertDialogBuilder(this).setMessage(e.message).show() - } - dialog.cancel() - } - .setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> dialog.cancel() } - .show() - } else { - runCatching { - // Silently delete & replace the lone .git folder if it exists - if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") { - localDir.deleteRecursively() - } - } - .onFailure { e -> - logcat(ERROR) { e.asLog() } - MaterialAlertDialogBuilder(this).setMessage(e.message).show() - } - lifecycleScope.launch { - launchGitOperation(GitOp.CLONE) - .fold( - success = { - setResult(RESULT_OK) - finish() - }, - failure = { promptOnErrorHandler(it) }, - ) - } - } - } - - companion object { - - private val PORT_REGEX = ":[0-9]{1,5}/".toRegex() - - fun createCloneIntent(context: Context): Intent { - return Intent(context, GitServerConfigActivity::class.java).apply { - putExtra("cloning", true) - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt b/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt deleted file mode 100644 index 709c2302..00000000 --- a/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.onboarding.activity - -import android.os.Bundle -import androidx.activity.addCallback -import androidx.appcompat.app.AppCompatActivity -import app.passwordstore.R -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.hide() - val callback = onBackPressedDispatcher.addCallback(enabled = false) { finishAffinity() } - supportFragmentManager.addOnBackStackChangedListener { - if (supportFragmentManager.backStackEntryCount == 0) { - callback.isEnabled = true - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt deleted file mode 100644 index a9d933b6..00000000 --- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.onboarding.fragments - -import android.os.Bundle -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.fragment.app.Fragment -import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.databinding.FragmentCloneBinding -import app.passwordstore.ui.git.config.GitServerConfigActivity -import app.passwordstore.util.extensions.finish -import app.passwordstore.util.extensions.performTransactionWithBackStack -import app.passwordstore.util.extensions.sharedPrefs -import app.passwordstore.util.extensions.unsafeLazy -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.PreferenceKeys -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -class CloneFragment : Fragment(R.layout.fragment_clone) { - - private val binding by viewBinding(FragmentCloneBinding::bind) - - private val settings by unsafeLazy { requireActivity().applicationContext.sharedPrefs } - - private val cloneAction = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } - finish() - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.cloneRemote.setOnClickListener { cloneToHiddenDir() } - binding.createLocal.setOnClickListener { createRepository() } - } - - /** Clones a remote Git repository to the app's private directory */ - private fun cloneToHiddenDir() { - cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext())) - } - - private fun createRepository() { - val localDir = PasswordRepository.getRepositoryDirectory() - runCatching { - check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" } - PasswordRepository.createRepository(localDir) - if (!PasswordRepository.isInitialized) { - PasswordRepository.initialize() - } - parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance()) - } - .onFailure { e -> - logcat(ERROR) { e.asLog() } - if (!localDir.delete()) { - logcat { "Failed to delete local repository: $localDir" } - } - finish() - } - } - - companion object { - - fun newInstance(): CloneFragment = CloneFragment() - } -} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt deleted file mode 100644 index 64c2e124..00000000 --- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.onboarding.fragments - -import android.content.SharedPreferences -import android.os.Bundle -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.databinding.FragmentKeySelectionBinding -import app.passwordstore.injection.prefs.SettingsPreferences -import app.passwordstore.ui.pgp.PGPKeyListActivity -import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.commitChange -import app.passwordstore.util.extensions.finish -import app.passwordstore.util.extensions.snackbar -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.PreferenceKeys -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import java.io.File -import javax.inject.Inject -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@AndroidEntryPoint -class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) { - - @Inject @SettingsPreferences lateinit var settings: SharedPreferences - @Inject lateinit var dispatcherProvider: DispatcherProvider - private val binding by viewBinding(FragmentKeySelectionBinding::bind) - private val gpgKeySelectAction = - registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - val data = result.data ?: return@registerForActivityResult - val selectedKey = - data.getStringExtra(PGPKeyListActivity.EXTRA_SELECTED_KEY) - ?: return@registerForActivityResult - lifecycleScope.launch { - withContext(dispatcherProvider.io()) { - val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") - gpgIdentifierFile.writeText(selectedKey) - } - settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } - requireActivity() - .commitChange(getString(R.string.git_commit_gpg_id, getString(R.string.app_name))) - finish() - } - } else { - requireActivity() - .snackbar( - message = getString(R.string.gpg_key_select_mandatory), - length = Snackbar.LENGTH_LONG, - ) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.selectKey.setOnClickListener { - gpgKeySelectAction.launch(PGPKeyListActivity.newSelectionActivity(requireContext())) - } - } - - companion object { - - fun newInstance() = KeySelectionFragment() - } -} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt deleted file mode 100644 index 44881c64..00000000 --- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.onboarding.fragments - -import android.os.Bundle -import android.view.View -import androidx.annotation.Keep -import androidx.fragment.app.Fragment -import app.passwordstore.R -import app.passwordstore.databinding.FragmentWelcomeBinding -import app.passwordstore.ui.settings.SettingsActivity -import app.passwordstore.util.extensions.launchActivity -import app.passwordstore.util.extensions.performTransactionWithBackStack -import app.passwordstore.util.extensions.viewBinding - -@Keep -class WelcomeFragment : Fragment(R.layout.fragment_welcome) { - - private val binding by viewBinding(FragmentWelcomeBinding::bind) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.letsGo.setOnClickListener { - parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance()) - } - binding.settingsButton.setOnClickListener { - requireActivity().launchActivity(SettingsActivity::class.java) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt deleted file mode 100644 index d276c552..00000000 --- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -@file:Suppress("BlockingMethodInNonBlockingContext") - -package app.passwordstore.ui.pgp - -import android.os.Bundle -import androidx.activity.result.contract.ActivityResultContracts.OpenDocument -import androidx.appcompat.app.AppCompatActivity -import app.passwordstore.R -import app.passwordstore.crypto.KeyUtils.tryGetId -import app.passwordstore.crypto.PGPKey -import app.passwordstore.crypto.PGPKeyManager -import app.passwordstore.crypto.errors.KeyAlreadyExistsException -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import kotlinx.coroutines.runBlocking - -@AndroidEntryPoint -class PGPKeyImportActivity : AppCompatActivity() { - - /** - * A [ByteArray] containing the contents of the previously selected file. This is necessary for - * the replacement case where we do not want users to have to pick the file again. - */ - private var lastBytes: ByteArray? = null - @Inject lateinit var keyManager: PGPKeyManager - - private val pgpKeyImportAction = - registerForActivityResult(OpenDocument()) { uri -> - runCatching { - if (uri == null) { - return@runCatching null - } - val keyInputStream = - contentResolver.openInputStream(uri) - ?: throw IllegalStateException("Failed to open selected file") - val bytes = keyInputStream.use { `is` -> `is`.readBytes() } - importKey(bytes, false) - } - .run(::handleImportResult) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - pgpKeyImportAction.launch(arrayOf("*/*")) - } - - override fun onDestroy() { - lastBytes = null - super.onDestroy() - } - - private fun importKey(bytes: ByteArray, replace: Boolean): PGPKey? { - lastBytes = bytes - val (key, error) = runBlocking { keyManager.addKey(PGPKey(bytes), replace = replace) } - if (replace) { - lastBytes = null - } - if (error != null) throw error - return key - } - - private fun handleImportResult(result: Result) { - if (result.isOk) { - val key = result.value - if (key == null) { - setResult(RESULT_CANCELED) - finish() - // This return convinces Kotlin that the control flow for `key == null` definitely - // terminates here and allows for a smart cast below. - return - } - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.pgp_key_import_succeeded)) - .setMessage(getString(R.string.pgp_key_import_succeeded_message, tryGetId(key))) - .setPositiveButton(android.R.string.ok) { _, _ -> - setResult(RESULT_OK) - finish() - } - .setCancelable(false) - .show() - } else { - if (result.error is KeyAlreadyExistsException && lastBytes != null) { - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.pgp_key_import_failed)) - .setMessage(getString(R.string.pgp_key_import_failed_replace_message)) - .setPositiveButton(R.string.dialog_yes) { _, _ -> - handleImportResult(runCatching { importKey(lastBytes!!, replace = true) }) - } - .setNegativeButton(R.string.dialog_no) { _, _ -> finish() } - .setCancelable(false) - .show() - } else { - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.pgp_key_import_failed)) - .setMessage(result.error.message) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .setCancelable(false) - .show() - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt deleted file mode 100644 index 312f5fb0..00000000 --- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.pgp - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -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.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import app.passwordstore.R -import app.passwordstore.crypto.PGPIdentifier -import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.ui.compose.theme.SpacingLarge -import app.passwordstore.util.extensions.conditional -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList - -@Composable -fun KeyList( - identifiers: ImmutableList, - onItemClick: (identifier: PGPIdentifier) -> Unit, - modifier: Modifier = Modifier, - onKeySelected: ((identifier: PGPIdentifier) -> Unit)? = null, -) { - if (identifiers.isEmpty()) { - Column( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - painter = painterResource(id = R.drawable.ic_launcher_foreground), - contentDescription = "Password Store logo", - ) - Text(stringResource(R.string.pgp_key_manager_no_keys_guidance)) - } - } else { - LazyColumn(modifier = modifier) { - items(identifiers) { identifier -> - KeyItem(identifier = identifier, onItemClick = onItemClick, onKeySelected = onKeySelected) - } - } - } -} - -@Composable -private fun KeyItem( - identifier: PGPIdentifier, - onItemClick: (identifier: PGPIdentifier) -> Unit, - modifier: Modifier = Modifier, - onKeySelected: ((identifier: PGPIdentifier) -> Unit)? = null, -) { - var isDeleting by remember { mutableStateOf(false) } - DeleteConfirmationDialog( - isDeleting = isDeleting, - onDismiss = { isDeleting = false }, - onConfirm = { - onItemClick(identifier) - isDeleting = false - }, - ) - val label = - when (identifier) { - is PGPIdentifier.KeyId -> identifier.id.toString() - is PGPIdentifier.UserId -> identifier.email - } - Row( - modifier = - modifier.padding(SpacingLarge).fillMaxWidth().conditional(onKeySelected != null) { - clickable { onKeySelected?.invoke(identifier) } - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - modifier = Modifier.weight(1f), - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - if (onKeySelected == null) { - IconButton(onClick = { isDeleting = true }) { - Icon( - painter = painterResource(R.drawable.ic_delete_24dp), - stringResource(id = R.string.delete), - ) - } - } - } -} - -@Suppress("NOTHING_TO_INLINE") -@Composable -private inline fun DeleteConfirmationDialog( - isDeleting: Boolean, - noinline onDismiss: () -> Unit, - noinline onConfirm: () -> Unit, -) { - if (isDeleting) { - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text(text = stringResource(R.string.pgp_key_manager_delete_confirmation_dialog_title)) - }, - confirmButton = { - TextButton(onClick = onConfirm) { Text(text = stringResource(R.string.dialog_yes)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(text = stringResource(R.string.dialog_no)) } - }, - ) - } -} - -@Preview -@Composable -private fun KeyListPreview() { - APSTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { - KeyList( - identifiers = - listOfNotNull( - PGPIdentifier.fromString("ultramicroscopicsilicovolcanoconiosis@example.com"), - PGPIdentifier.fromString("0xB950AE2813841585"), - ) - .toPersistentList(), - onItemClick = {}, - ) - } - } -} - -@Preview -@Composable -private fun EmptyKeyListPreview() { - APSTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { - KeyList(identifiers = persistentListOf(), onItemClick = {}) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt deleted file mode 100644 index 77b9aaf1..00000000 --- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.pgp - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.activity.viewModels -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import app.passwordstore.R -import app.passwordstore.ui.APSAppBar -import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.util.viewmodel.PGPKeyListViewModel -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class PGPKeyListActivity : ComponentActivity() { - - private val viewModel: PGPKeyListViewModel by viewModels() - private val keyImportAction = - registerForActivityResult(StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - viewModel.updateKeySet() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val isSelecting = intent.extras?.getBoolean(EXTRA_KEY_SELECTION) ?: false - setContent { - APSTheme { - Scaffold( - topBar = { - APSAppBar( - title = stringResource(R.string.activity_label_pgp_key_manager), - navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp), - onNavigationIconClick = { finish() }, - backgroundColor = MaterialTheme.colorScheme.surface, - ) - }, - floatingActionButton = { - FloatingActionButton( - onClick = { keyImportAction.launch(Intent(this, PGPKeyImportActivity::class.java)) } - ) { - Icon( - painter = painterResource(R.drawable.ic_add_48dp), - stringResource(R.string.pref_import_pgp_key_title), - ) - } - }, - ) { paddingValues -> - KeyList( - identifiers = viewModel.keys, - onItemClick = viewModel::deleteKey, - modifier = Modifier.padding(paddingValues), - onKeySelected = - if (isSelecting) { - { identifier -> - val result = Intent() - result.putExtra(EXTRA_SELECTED_KEY, identifier.toString()) - setResult(RESULT_OK, result) - finish() - } - } else null, - ) - } - } - } - } - - companion object { - const val EXTRA_SELECTED_KEY = "SELECTED_KEY" - private const val EXTRA_KEY_SELECTION = "KEY_SELECTION_MODE" - - fun newSelectionActivity(context: Context): Intent { - val intent = Intent(context, PGPKeyListActivity::class.java) - intent.putExtra(EXTRA_KEY_SELECTION, true) - return intent - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt deleted file mode 100644 index 3f04e35e..00000000 --- a/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.sshkeygen - -import android.app.Dialog -import android.content.Intent -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import app.passwordstore.R -import app.passwordstore.util.git.sshj.SshKey -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class ShowSshKeyFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val activity = requireActivity() - val publicKey = SshKey.sshPublicKey - return MaterialAlertDialogBuilder(requireActivity()).run { - setMessage(getString(R.string.ssh_keygen_message, publicKey)) - setTitle(R.string.your_public_key) - setNegativeButton(R.string.ssh_keygen_later) { _, _ -> - (activity as? SshKeyGenActivity)?.finish() - } - setPositiveButton(R.string.ssh_keygen_share) { _, _ -> - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, publicKey) - } - startActivity(Intent.createChooser(sendIntent, null)) - (activity as? SshKeyGenActivity)?.finish() - } - create() - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt deleted file mode 100644 index 618a4be0..00000000 --- a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.sshkeygen - -import android.content.SharedPreferences -import android.os.Bundle -import android.security.keystore.UserNotAuthenticatedException -import android.view.MenuItem -import android.view.View -import android.view.inputmethod.InputMethodManager -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.core.content.getSystemService -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.databinding.ActivitySshKeygenBinding -import app.passwordstore.injection.prefs.GitPreferences -import app.passwordstore.util.auth.BiometricAuthenticator -import app.passwordstore.util.auth.BiometricAuthenticator.Result -import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.keyguardManager -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.git.sshj.SshKey -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) { - Rsa({ requireAuthentication -> - SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) - }), - Ecdsa({ requireAuthentication -> - SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) - }), - Ed25519({ requireAuthentication -> - SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) - }), -} - -@AndroidEntryPoint -class SshKeyGenActivity : AppCompatActivity() { - - private var keyGenType = KeyGenType.Ecdsa - private val binding by viewBinding(ActivitySshKeygenBinding::inflate) - @GitPreferences @Inject lateinit var gitPrefs: SharedPreferences - @Inject lateinit var dispatcherProvider: DispatcherProvider - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - with(binding) { - generate.setOnClickListener { - if (SshKey.exists) { - MaterialAlertDialogBuilder(this@SshKeyGenActivity).run { - setTitle(R.string.ssh_keygen_existing_title) - setMessage(R.string.ssh_keygen_existing_message) - setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> - lifecycleScope.launch { generate() } - } - setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> - setResult(RESULT_CANCELED) - finish() - } - show() - } - } else { - lifecycleScope.launch { generate() } - } - } - keyTypeGroup.check(R.id.key_type_ecdsa) - keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa) - keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> - if (isChecked) { - keyGenType = - when (checkedId) { - R.id.key_type_ed25519 -> KeyGenType.Ed25519 - R.id.key_type_ecdsa -> KeyGenType.Ecdsa - R.id.key_type_rsa -> KeyGenType.Rsa - else -> throw IllegalStateException("Impossible key type selection") - } - keyTypeExplanation.setText( - when (keyGenType) { - KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519 - KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa - KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa - } - ) - } - } - keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure - keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private suspend fun generate() { - binding.generate.apply { - text = getString(R.string.ssh_key_gen_generating_progress) - isEnabled = false - } - binding.generate.text = getString(R.string.ssh_key_gen_generating_progress) - val result = runCatching { - withContext(dispatcherProvider.io()) { - val requireAuthentication = binding.keyRequireAuthentication.isChecked - if (requireAuthentication) { - val result = - withContext(dispatcherProvider.main()) { - suspendCoroutine { cont -> - BiometricAuthenticator.authenticate( - this@SshKeyGenActivity, - R.string.biometric_prompt_title_ssh_keygen, - ) { result -> - // Do not cancel on failed attempts as these are handled by the - // authenticator UI. - if (result !is Result.Retry) cont.resume(result) - } - } - } - if (result !is Result.Success) - throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure)) - } - keyGenType.generateKey(requireAuthentication) - } - } - gitPrefs.edit { remove("ssh_key_local_passphrase") } - binding.generate.apply { - text = getString(R.string.ssh_keygen_generate) - isEnabled = true - } - result.fold( - success = { ShowSshKeyFragment().show(supportFragmentManager, "public_key") }, - failure = { e -> - e.printStackTrace() - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.error_generate_ssh_key)) - .setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message) - .setPositiveButton(getString(R.string.dialog_ok)) { _, _ -> - setResult(RESULT_OK) - finish() - } - .show() - }, - ) - hideKeyboard() - } - - private fun hideKeyboard() { - val imm = getSystemService() ?: return - var view = currentFocus - if (view == null) { - view = View(this) - } - imm.hideSoftInputFromWindow(view.windowToken, 0) - } -} diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt deleted file mode 100644 index e4740741..00000000 --- a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.sshkeygen - -import android.net.Uri -import android.os.Bundle -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import app.passwordstore.R -import app.passwordstore.util.git.sshj.SshKey -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class SshKeyImportActivity : AppCompatActivity() { - - private val sshKeyImportAction = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> - if (uri == null) { - finish() - return@registerForActivityResult - } - runCatching { - SshKey.import(uri) - Toast.makeText( - this, - resources.getString(R.string.ssh_key_success_dialog_title), - Toast.LENGTH_LONG, - ) - .show() - setResult(RESULT_OK) - finish() - } - .onFailure { e -> - MaterialAlertDialogBuilder(this) - .setTitle(resources.getString(R.string.ssh_key_error_dialog_title)) - .setMessage(e.message) - .setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> finish() } - .show() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (SshKey.exists) { - MaterialAlertDialogBuilder(this).run { - setTitle(R.string.ssh_keygen_existing_title) - setMessage(R.string.ssh_keygen_existing_message) - setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> importSshKey() } - setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() } - setOnCancelListener { finish() } - show() - } - } else { - importSshKey() - } - } - - private fun importSshKey() { - sshKeyImportAction.launch(arrayOf("*/*")) - } -} diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt deleted file mode 100644 index 5c7429cd..00000000 --- a/app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.util.viewmodel - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import app.passwordstore.crypto.KeyUtils -import app.passwordstore.crypto.PGPIdentifier -import app.passwordstore.crypto.PGPKeyManager -import com.github.michaelbull.result.map -import com.github.michaelbull.result.onSuccess -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch - -@HiltViewModel -class PGPKeyListViewModel @Inject constructor(private val keyManager: PGPKeyManager) : ViewModel() { - var keys: ImmutableList by mutableStateOf(persistentListOf()) - - init { - updateKeySet() - } - - fun updateKeySet() { - viewModelScope.launch { - keyManager - .getAllKeys() - .map { keys -> keys.mapNotNull { key -> KeyUtils.tryGetEmail(key) } } - .onSuccess { keys = it.toPersistentList() } - } - } - - fun deleteKey(identifier: PGPIdentifier) { - viewModelScope.launch { - keyManager.removeKey(identifier) - updateKeySet() - } - } -} diff --git a/app/src/main/res/drawable/ic_add_48dp.xml b/app/src/main/res/drawable/ic_add_48dp.xml deleted file mode 100644 index 7192e79d..00000000 --- a/app/src/main/res/drawable/ic_add_48dp.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml b/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml deleted file mode 100644 index 234364a9..00000000 --- a/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/activity_git_clone.xml b/app/src/main/res/layout/activity_git_clone.xml deleted file mode 100644 index b2f699b1..00000000 --- a/app/src/main/res/layout/activity_git_clone.xml +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml deleted file mode 100644 index d1153f5d..00000000 --- a/app/src/main/res/layout/activity_onboarding.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/layout/activity_ssh_keygen.xml b/app/src/main/res/layout/activity_ssh_keygen.xml deleted file mode 100644 index 49548b36..00000000 --- a/app/src/main/res/layout/activity_ssh_keygen.xml +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/basic_bottom_sheet.xml b/app/src/main/res/layout/basic_bottom_sheet.xml deleted file mode 100644 index b45fc9c9..00000000 --- a/app/src/main/res/layout/basic_bottom_sheet.xml +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_clone.xml b/app/src/main/res/layout/fragment_clone.xml deleted file mode 100644 index 9cb34761..00000000 --- a/app/src/main/res/layout/fragment_clone.xml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_key_selection.xml b/app/src/main/res/layout/fragment_key_selection.xml deleted file mode 100644 index b9141960..00000000 --- a/app/src/main/res/layout/fragment_key_selection.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_welcome.xml b/app/src/main/res/layout/fragment_welcome.xml deleted file mode 100644 index c68cd56a..00000000 --- a/app/src/main/res/layout/fragment_welcome.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a9fa4b00..493391a8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -171,7 +171,6 @@ Zeige das Passwort. Soll das entschlüsselte Passwort sichtbar sein? Dies deaktiviert nicht das Kopieren. Passwort unverschlüsselt senden an… - App-Icon Eintrag zum Ausfüllen auswählen Phishing-resistente Suche @@ -283,16 +282,12 @@ Es gab einen Konflikt während des Rebase-Prozesses. Ihr lokaler Branch %1$s wurde auf einen anderen Branch namens %2$s gepusht.\nNutzen Sie diesen Branch, um den Konflikt auf dem Server zu beheben Das Repository befindet sich nicht im Rebase-Prozess; es ist nicht nötig, den Zustand auf einen weiteren Branch zu pushen. - Um fortzufahren, muss ein PGP-Schlüssel ausgewählt werden. Dateiname bereits vergeben Ein Ordner mit diesem Namen existiert bereits. - Los geht\'s - Wählen Sie einen \nRepository-Typ. Wählen Sie aus, ob Sie ein lokales Repository anlegen, oder ein Remote-Repository klonen möchten. Remote-Repository klonen Lokales Repository anlegen - Wählen Sie einen\nPGP-Schlüssel. Wählen einen PGP-Schlüssel, mit dem der Store initialisiert werden soll. Schlüssel auswählen diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d41048f8..d2a469e7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -159,7 +159,6 @@ Montrer le mot de passe Contrôle la visibilité des mots de passe une fois déchifrées, ceci n\'empêche pas de copier le mot de passe Envoyer le mot de passe en clair via… - Icône de l\'application Sélectionnez l\'entrée à remplir Recherche protégée contre le hameçonnage @@ -271,12 +270,9 @@ Un fichier portant ce nom existe déjà Un dossier portant ce nom existe déjà - Allons-y - Sélectionnez le type de dépôt \n Sélectionnez si vous voulez créer un dépôt local ou cloner un dépôt distant. Cloner le dépôt distant Créer un dépôt local - Sélectionnez\nla clé GPG Sélectionnez une clé GPG pour initialiser votre magasin Sélectionner la clé diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 3fb321e7..ad4c663d 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -172,7 +172,6 @@ Mostrar contrasinal Controlar a visibilidade do contrasinal unha vez descifrado, esto non desactiva o copiado do contrasinal Enviar contrasinal como texto plano usando… - Icona da app Elixe o elemento a completar Busca resistente ao phishing @@ -285,16 +284,12 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid Hai un conflito ó realizar rebase. A túa póla local %1$s fixo push a outra póla chamada %2$s\nUsa esta póla para resolver o conflito na túa computadora O repositorio non cambiou de base, non é preciso cambiar a outra póla - Hai que elexir unha chave GPG para continuar Xa existe un ficheiro con ese nome Xa existe un cartafol con ese nome - Imos alá - Escolle \nTipo de repositorio Escolle se queres crear un repositorio local ou clonar un remoto. Clonar repositorio remoto Crear repositorio local - Elixe\nChave\nGPG Elixe a chave GPG coa que queres iniciar a almacenaxe Elixe chave diff --git a/app/src/main/res/values-hdpi/dimens.xml b/app/src/main/res/values-hdpi/dimens.xml deleted file mode 100644 index f9a452aa..00000000 --- a/app/src/main/res/values-hdpi/dimens.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - 50dp - 70dp - 48dp - diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 72bcb009..8edc9d3d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -154,7 +154,6 @@ Mostra la password Controlla la visibilità delle password una volta decrittografate. Questo non disabilita la copia negli appunti. Invia password come testo semplice usando… - Icona app Seleziona la voce da compilare Ricerca resistente al phishing @@ -265,12 +264,9 @@ Un file con quel nome esiste già Una cartella con quel nome esiste già - Andiamo - Seleziona \nTipo di repository Seleziona se vuoi creare un repo locale o clonarne uno remoto. Clona repo remoto Crea repo locale - Seleziona\nChiave\nGPG Seleziona una chiave GPG con cui inizializzare il tuo archivio Seleziona chiave @@ -315,7 +311,6 @@ Trovato .gpg-id, ma contiene un ID chiave, un\'impronta o un ID utente non valido Trovato .gpg-id, ma contiene un ID esadecimale corto, che non è supportato Merge non riuscito, il repository è in conflitto. - Per proseguire devi selezionare una chiave GPG Il protocollo git:// è sconsigliato Il protocollo git di git-daemon non cifra il trasporto e non è adatto a operazioni sicure. Impostazioni proxy diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 3efbed68..aaa438dd 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -167,7 +167,6 @@ 비밀번호 표시하기 암호가 해독된 후 비밀번호의 공개 여부를 제어합니다. 그렇다고 해서 클립보드 복사가 비활성화되지는 않습니다. 비밀번호를 일반 텍스트로 보내는 중… - 앱 아이콘 입력할 항목 선택 피싱 방지 검색 @@ -281,16 +280,12 @@ username@example.com:… 리베이스하는 동안 충돌이 발생했습니다. 로컬 %1$s 브랜치가 %2$s이라는 다른 브랜치로 푸시되었습니다.\n이 브랜치를 사용하여 컴퓨터에서 충돌을 해결하세요. 이 저장소는 리베이스가 아니므로 다른 브랜치로 푸시할 필요가 없습니다. - 계속하려면 GPG 키를 선택해야 합니다 동일한 이름의 파일이 이미 있습니다 이 이름의 폴더가 이미 존재합니다 - 시작합니다! - 저장소 유형 \n선택 로컬 리포지토리를 만들 것인지, 원격 리포지토리를 복제할 것인지 선택합니다. 원격 저장소 복제 로컬 저장소 생성 - GPG 키\n선택 스토어를 초기화할 GPG 키를 선택합니다 키 선택 diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 65f14ce5..815cf41c 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -168,7 +168,6 @@ Pokaż hasło Kontroluj widoczność haseł po odszyfrowaniu. To nie wyłącza kopiowania do schowka. Wyślij niezaszyfrowane hasło, używając… - Ikona aplikacji Wybierz wpis do wypełnienia Wyszukiwanie odporne na wyłudzenie @@ -278,12 +277,9 @@ Plik o tej nazwie już istnieje Folder o tej nazwie już istnieje - Zaczynajmy - Wybierz \ntyp repozytorium Zaznacz, czy chcesz utworzyć lokalne repozytorium, czy sklonować zdalne repozytorium. Klonuj zdalne repozytorium Utwórz lokalne repozytorium - Wybierz\nklucz GPG Wybierz klucz GPG, którym zainicjować swoje repozytorium Wybierz klucz diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 86ebfb24..a1d1a38c 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -171,7 +171,6 @@ Mostrar a senha Controle a visibilidade das senhas quando descriptografadas. Isso não desativa a cópia para a área de transferência. Enviar senha como texto simples usando… - Ícone do aplicativo Selecione a entrada para preencher Busca resistente a phishing @@ -283,16 +282,12 @@ Houve um conflito ao executar o rebase. Houve um push de branch %1$s local para outra branch chamada %2$s\n Use esta branch para resolver o conflito no seu computador O repositório não está sendo rebased, não há necessidade de fazer push para outro branch - A seleção de uma chave GPG é necessária para prosseguir Já existe um arquivo com esse nome Já existe uma pasta com esse nome - Vamos lá - Selecione \nTipo de Repositório Selecione se você quer criar um repositório local ou clonar um repositório remoto. Clonar repositório remoto Criar repositório local - Selecione\nChave GPG Selecione uma chave GPG para inicializar o armazenamento com Selecione a chave diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8873b34f..d0934a4d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -164,7 +164,6 @@ Показывать пароли Видимость расшифрованных паролей, не влияет на копирование в буфер. Поделиться паролем в открытом виде с помощью - Иконка приложения Выберите запись для заполнения Устойчивый к фишингу поиск @@ -275,16 +274,12 @@ При попытке rebase\'а возник конфликт. Ваша локальная ветка %1$s была запушена %2$s\n Используйте эту ветку для разрешения конфликтов с помощью компьютера Не нужно пушить в другую ветку - rebase не был запущен - Для продолжения необходимо выбрать GPG-ключ Файл с таким именем уже существует Папка с таким именем уже существует - Поехали - Выберите \nтип репозитория Создать локальное хранилище, или клонировать удаленный репозиторий. Клонировать удаленный репозиторий Создать локальное хранилище - Выберите\nGPG ключ Выберите GPG ключ для инициализации хранилища Выберите ключ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 746b74d2..ddcdda1a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -172,7 +172,6 @@ 显示密码 控制密码解密后的可见性,这不会禁用复制到剪贴板 使用明文发送密码… - 应用图标 选择要填写的条目 防钓鱼搜索 @@ -285,18 +284,12 @@ personal.com 使用此分支解决计算机上的冲突 存储库没有变更,无需推送到另一个分支 - 选择一个 GPG 密钥才能继续 该名称的文件已存在 该名称的文件夹已存在 - 开始配置 - 选择仓库模式 你是要同步仓库还是现在创建一个仓库呢? 同步远程仓库 创建本地仓库 - 选择 -GPG -密钥 选择一个GPG密钥来初始化您的仓库 选择密钥 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index e5d7ff94..8351a358 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -7,9 +7,5 @@ 16dp 16dp - 16dp 8dp - 100dp - 140dp - 48dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dc8069b8..59ba74cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -199,7 +199,6 @@ Show the password Control the visibility of the passwords once decrypted. This does not disable copying to clipboard. Send password as plaintext using… - App icon Select entry to fill into @@ -321,17 +320,13 @@ The repository is not rebasing, no need to push to another branch - Selecting a GPG key is necessary to proceed A file by that name already exists A folder by that name already exists - Let\'s Go - Select \nRepository Type Select if you want to create a local repo or clone a remote repo. Clone Remote Repo Create Local Repo - Select\nGPG Key Select a GPG key to initialize your store with Select key diff --git a/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md b/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md index 7eab1692..94f76479 100644 --- a/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md +++ b/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md @@ -51,6 +51,23 @@ the maintainer's phone stays on 0.1.4 until then. editor was opened. The folder picker is a dialog, shared with the list's move action. The autofill save flow (`AutofillSaveActivity`) hosts the same editor. +### Setup + +- Without a store the app opens on a welcome screen: clone a remote repository or create a + local store; Settings is reachable from it. +- The git server screen serves the clone and later edits: URL, authentication mode (SSH key or + password; none or password for HTTP(S)), the SSH key's state with "Manage", "Clear saved host + key". A failed clone stays on the screen, so URL or key can be fixed and the clone retried. +- The SSH key screen shows the key's state and, for a key generated on the device, the public + key (copy, share); it imports a key file (refusing files sshj cannot read, such as OpenPGP + keys) and opens key generation (RSA, ECDSA, Ed25519, optionally behind the screen lock). +- One PGP keys screen manages keys (import, delete), picks the key of a new local store (the + repository is created only then, its `.gpg-id` naming the key by its uppercase long key ID, as + `pass init ` does), and imports the key a store needs: after a clone that no + imported key can decrypt, and when an entry is opened with no key imported. +- Screens outside the graph (Settings, autofill, git operations) and the entry view's key + import open these through a `MainActivity` setup intent until steps 6 and 7. + ## Architecture ### Activities diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.kt deleted file mode 100644 index 7ac99023..00000000 --- a/ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui - -import android.annotation.SuppressLint -import androidx.compose.foundation.layout.RowScope -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import app.passwordstore.ui.compose.preview.DevicePreviews -import app.passwordstore.ui.compose.preview.ThemePreviews -import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.ui.compose.theme.SpacingMedium - -@SuppressLint("ComposableLambdaParameterNaming") // The lint doesn't really apply to `actions` -@Composable -@OptIn(ExperimentalMaterial3Api::class) -public fun APSAppBar( - title: String, - backgroundColor: Color, - navigationIcon: Painter?, - modifier: Modifier = Modifier, - onNavigationIconClick: (() -> Unit) = {}, - actions: @Composable RowScope.() -> Unit = {}, -) { - TopAppBar( - title = { Text(text = title) }, - navigationIcon = { - if (navigationIcon != null) { - IconButton(onClick = onNavigationIconClick) { - Icon(painter = navigationIcon, contentDescription = "Back navigation button") - } - } - }, - actions = actions, - colors = TopAppBarDefaults.topAppBarColors(containerColor = backgroundColor), - modifier = modifier.shadow(SpacingMedium), - ) -} - -@ThemePreviews -@DevicePreviews -@Composable -private fun APSAppBarPreview() { - APSTheme { - APSAppBar( - title = "Preview", - backgroundColor = MaterialTheme.colorScheme.surface, - navigationIcon = rememberVectorPainter(Icons.AutoMirrored.Filled.ArrowBack), - actions = { - IconButton(onClick = {}) { - Icon( - painter = rememberVectorPainter(Icons.Filled.Search), - contentDescription = "Search items", - ) - } - }, - ) - } -} -- cgit v1.2.3