From 87010a522e2b0eeecff85da96ae52544f117a6bf Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 20:41:55 +0100 Subject: docs: start the compose branch for the UI overhaul Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +++++- docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6708cd53..77f15fe1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,11 @@ Keep "GrapheneOS" out of the app name: it is that project's trademark. ## Repository layout and remotes -- `master` is the only branch. Upstream's `develop` was renamed; do not recreate it. +- `master` is the only long-lived branch. Upstream's `develop` was renamed; do not recreate it. +- Temporary exception: the Compose UI overhaul + (`docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`) lives on `compose` until + it is complete; nothing is released from it. `master` stays releasable (public suffix list, + hotfixes). Remove this bullet when `compose` merges. - `origin` fetches from the maintainer's own git server (the main repository) and pushes to it and to the GitHub fork (`danixland/HardenedPass`) in one `git push`. GitHub is a push mirror: never create commits on GitHub (bots, web edits), they would 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 77e1dcae..e2aa6fe3 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 @@ -11,8 +11,8 @@ the maintainer's phone stays on 0.1.4 until then. ## Visual direction -- Material 3 "Material You": dynamic color on Android 12+, a fixed purple scheme on Android 8 to - 11 (minSdk 26). Light and dark follow the system. +- Material 3 "Material You": dynamic color on Android 12+, the existing fixed `APSTheme` scheme + on Android 8 to 11 (minSdk 26). Light and dark follow the system. - Pill-shaped search bar at the top of the list, grouped rounded surfaces, letter avatars on entries, extended FAB ("New entry"). - Folder location shown as breadcrumb chips (root, then each folder); the last chip is -- cgit v1.2.3 From 69e42da191f4c1596e282cfc31cd3a58ae263de1 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 20:45:32 +0100 Subject: build(app): add navigation-compose and kotlinx-serialization Co-Authored-By: Claude Opus 5.5 --- app/build.gradle.kts | 3 +++ gradle/libs.versions.toml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 68fc7bad..a8cac83e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,6 +12,7 @@ plugins { alias(libs.plugins.hilt) alias(libs.plugins.ksp) alias(libs.plugins.kotlin.composeCompiler) + alias(libs.plugins.kotlin.serialization) } android { @@ -66,6 +67,7 @@ dependencies { implementation(libs.bundles.androidxLifecycle) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.material) + implementation(libs.androidx.navigation.compose) implementation(libs.androidx.preference) implementation(libs.androidx.recyclerview) implementation(libs.androidx.recyclerviewSelection) @@ -76,6 +78,7 @@ dependencies { implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.core) implementation(libs.aps.sublimeFuzzy) implementation(libs.aps.zxingAndroidEmbedded) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c3cdcf35..ad9422a8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", ve androidx-lifecycle-livedataKtx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodelKtx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +androidx-navigation-compose = "androidx.navigation:navigation-compose:2.8.9" 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" @@ -60,6 +61,7 @@ kotlinx-collections-immutable = "org.jetbrains.kotlinx:kotlinx-collections-immut kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-core = "org.jetbrains.kotlinx:kotlinx-serialization-core:1.12.0" testing-junit = "junit:junit:4.13.2" testing-kotlintest-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } testing-robolectric = "org.robolectric:robolectric:4.13" @@ -103,3 +105,4 @@ testDependencies = [ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = "com.google.devtools.ksp:2.3.13" kotlin-composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -- cgit v1.2.3 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 --- ui/compose/build.gradle.kts | 1 + .../app/passwordstore/ui/compose/Breadcrumbs.kt | 16 +++++++++++ .../passwordstore/ui/compose/BreadcrumbsTest.kt | 33 ++++++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt create mode 100644 ui/compose/src/test/kotlin/app/passwordstore/ui/compose/BreadcrumbsTest.kt diff --git a/ui/compose/build.gradle.kts b/ui/compose/build.gradle.kts index b4f195cf..81d1e4cd 100644 --- a/ui/compose/build.gradle.kts +++ b/ui/compose/build.gradle.kts @@ -22,4 +22,5 @@ dependencies { api(libs.compose.ui.core) implementation(libs.androidx.core.ktx) implementation(libs.compose.ui.tooling) + testImplementation(libs.bundles.testDependencies) } 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("/")) } +} diff --git a/ui/compose/src/test/kotlin/app/passwordstore/ui/compose/BreadcrumbsTest.kt b/ui/compose/src/test/kotlin/app/passwordstore/ui/compose/BreadcrumbsTest.kt new file mode 100644 index 00000000..2eb4c3fc --- /dev/null +++ b/ui/compose/src/test/kotlin/app/passwordstore/ui/compose/BreadcrumbsTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.compose + +import kotlin.test.Test +import kotlin.test.assertEquals + +class BreadcrumbsTest { + + @Test + fun rootOnly() { + assertEquals(listOf(Crumb("", "")), crumbsFor("")) + } + + @Test + fun nestedPath() { + assertEquals( + listOf(Crumb("", ""), Crumb("work", "work"), Crumb("servers", "work/servers")), + crumbsFor("work/servers"), + ) + } + + @Test + fun ignoresStraySlashes() { + assertEquals( + listOf(Crumb("", ""), Crumb("work", "work"), Crumb("servers", "work/servers")), + crumbsFor("/work//servers/"), + ) + } +} -- 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 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(-) 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 2b9a095cbf4720f056532bded1be9f2584f8447a Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 20:50:22 +0100 Subject: feat(app): add the single-activity Compose host Its two routes hand off to the legacy list and decrypt activities until those screens are ported. Co-Authored-By: Claude Opus 5.5 --- app/src/main/AndroidManifest.xml | 4 ++ .../java/app/passwordstore/ui/main/MainActivity.kt | 77 ++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/main/MainActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1cbe76a9..457647d5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -64,6 +64,10 @@ + + + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.main + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.fragment.app.FragmentActivity +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.crypto.BasePGPActivity +import app.passwordstore.ui.crypto.DecryptActivity +import app.passwordstore.ui.passwords.PasswordStore +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.serialization.Serializable + +/** The password list, at the store root. */ +@Serializable data object ListRoute + +/** One entry. [filePath] was checked with [LaunchActivity.isFileInRepo] before routing here. */ +@Serializable data class EntryRoute(val filePath: String) + +/** + * Single-activity host for the Compose UI. Not exported: it is only started by [LaunchActivity], + * after its biometric gate and path check. Extends [FragmentActivity] only because BiometricPrompt + * needs one; it hosts no fragments. + */ +@AndroidEntryPoint +class MainActivity : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val filePath = intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH) + val start: Any = if (filePath != null) EntryRoute(filePath) else ListRoute + setContent { + APSTheme { + NavHost(rememberNavController(), startDestination = start) { + composable { + LegacyScreen( + Intent(this@MainActivity, PasswordStore::class.java).setAction(Intent.ACTION_VIEW) + ) + } + composable { entry -> + val route = entry.toRoute() + LegacyScreen( + Intent(this@MainActivity, DecryptActivity::class.java) + .putExtra(BasePGPActivity.EXTRA_FILE_PATH, route.filePath) + .putExtra( + BasePGPActivity.EXTRA_REPO_PATH, + PasswordRepository.getRepositoryDirectory().absolutePath, + ) + ) + } + } + } + } + } + + // ponytail: trampoline to a screen not yet ported; each port replaces one call with the + // real composable, and the last port deletes this function. + @Composable + private fun LegacyScreen(intent: Intent) { + LaunchedEffect(Unit) { + startActivity(intent) + finish() + } + } +} -- cgit v1.2.3 From 3e1f3a6f5ef5fba661f702e7457ee9c833c516f3 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 20:55:07 +0100 Subject: fix(app): make LegacyScreen a top-level composable Slack's ComposeUnstableReceiver lint rejects an instance composable on the activity. Co-Authored-By: Claude Opus 5.5 --- .../java/app/passwordstore/ui/main/MainActivity.kt | 23 ++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt index 018deae3..48546e00 100644 --- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt @@ -5,6 +5,7 @@ package app.passwordstore.ui.main +import android.app.Activity import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent @@ -46,32 +47,34 @@ class MainActivity : FragmentActivity() { NavHost(rememberNavController(), startDestination = start) { composable { LegacyScreen( - Intent(this@MainActivity, PasswordStore::class.java).setAction(Intent.ACTION_VIEW) + this@MainActivity, + Intent(this@MainActivity, PasswordStore::class.java).setAction(Intent.ACTION_VIEW), ) } composable { entry -> val route = entry.toRoute() LegacyScreen( + this@MainActivity, Intent(this@MainActivity, DecryptActivity::class.java) .putExtra(BasePGPActivity.EXTRA_FILE_PATH, route.filePath) .putExtra( BasePGPActivity.EXTRA_REPO_PATH, PasswordRepository.getRepositoryDirectory().absolutePath, - ) + ), ) } } } } } +} - // ponytail: trampoline to a screen not yet ported; each port replaces one call with the - // real composable, and the last port deletes this function. - @Composable - private fun LegacyScreen(intent: Intent) { - LaunchedEffect(Unit) { - startActivity(intent) - finish() - } +// ponytail: trampoline to a screen not yet ported; each port replaces one call with the +// real composable, and the last port deletes this function. +@Composable +private fun LegacyScreen(activity: Activity, intent: Intent) { + LaunchedEffect(Unit) { + activity.startActivity(intent) + activity.finish() } } -- cgit v1.2.3 From c4f4f92bbfdeef244767c09a9710c64b065896a2 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 20:55:08 +0100 Subject: feat(app): route the launcher through MainActivity Co-Authored-By: Claude Opus 5.5 --- .../java/app/passwordstore/ui/main/LaunchActivity.kt | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt index 1a5e8a89..befd37f9 100644 --- a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt @@ -13,8 +13,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.ui.crypto.BasePGPActivity -import app.passwordstore.ui.crypto.DecryptActivity -import app.passwordstore.ui.passwords.PasswordStore import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result import app.passwordstore.util.extensions.sharedPrefs @@ -50,22 +48,14 @@ class LaunchActivity : AppCompatActivity() { } } - private fun getDecryptIntent(): Intent { - return Intent(this, DecryptActivity::class.java) - } - private fun startTargetActivity(noAuth: Boolean) { - // This activity is exported, so the file path is untrusted input: only forward it to - // DecryptActivity when it points at a file inside the password store. + // This activity is exported, so the file path is untrusted input: only forward it when it + // points at a file inside the password store. MainActivity is not exported and trusts it. val repoDir = PasswordRepository.getRepositoryDirectory() val filePath = intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH) - val intentToStart = - if (intent.action == ACTION_DECRYPT_PASS && isFileInRepo(filePath, repoDir)) - getDecryptIntent().apply { - putExtra(BasePGPActivity.EXTRA_FILE_PATH, filePath) - putExtra(BasePGPActivity.EXTRA_REPO_PATH, repoDir.absolutePath) - } - else Intent(this, PasswordStore::class.java).setAction(Intent.ACTION_VIEW) + val intentToStart = Intent(this, MainActivity::class.java) + if (intent.action == ACTION_DECRYPT_PASS && isFileInRepo(filePath, repoDir)) + intentToStart.putExtra(BasePGPActivity.EXTRA_FILE_PATH, filePath) startActivity(intentToStart) Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L) -- cgit v1.2.3 From 44fcd6e368387b1ea9def207529d4a7081360efb Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 21:22:09 +0100 Subject: docs: add the Compose list implementation plan Co-Authored-By: Claude Opus 5.5 --- docs/superpowers/plans/2026-11-02-compose-list.md | 1901 +++++++++++++++++++++ 1 file changed, 1901 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-02-compose-list.md diff --git a/docs/superpowers/plans/2026-11-02-compose-list.md b/docs/superpowers/plans/2026-11-02-compose-list.md new file mode 100644 index 00000000..ea50a619 --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-compose-list.md @@ -0,0 +1,1901 @@ +# Compose UI overhaul, step 2: Password list. Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the legacy `PasswordStore` activity and `PasswordFragment` with a Compose list screen behind `MainActivity`'s `ListRoute`: pill search bar, breadcrumb chips, folders and entries in grouped surfaces, pull to sync, long-press multi-select (move, delete, rename, pin) and a create sheet. Screens not ported yet keep opening through their activity intents. + +**Architecture:** One back stack entry per folder level: `ListRoute(path)`, where `path` is relative to the store root and `""` is the root. Each entry gets its own `SearchableRepositoryViewModel` through `hiltViewModel()`, so its rows, query and scroll position survive navigation, rotation and process death without extra code. System back pops one folder (the `NavHost` does it); at the root it leaves the app. Breadcrumb jumps and opening a folder from search results go through one pure function, `folderMoves`, that keeps the back stack equal to the folder path. `MainActivity` now extends `BaseGitActivity`, because the git operations (pull to sync, menu actions, commits after move and delete) need an `AppCompatActivity`; the store operations move from `PasswordStore` into top-level functions in `ui/list/StoreActions.kt`. The list refreshes every time its entry resumes, which also covers results coming back from legacy activities (editor, onboarding, folder picker, settings). + +**Tech Stack:** Kotlin 2.4.21, Compose BOM 2024.09.03 (Material 3 1.3.0: `SearchBar`, `Modifier.pullToRefresh`, `ModalBottomSheet`), navigation-compose 2.8.9, `androidx.hilt:hilt-navigation-compose:1.2.0` (new), `androidx.lifecycle:lifecycle-runtime-compose` 2.8.6 (made explicit), kotlinx-collections-immutable (already a dependency). + +Spec: `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`. Step 1 plan: `docs/superpowers/plans/2026-11-02-compose-foundation.md`. + +## Decisions taken in this plan + +- **Folder depth lives in the route, not in ViewModel state.** `ListRoute(val path: String = "")`. Reasons: system back pops one level for free; the navigation back stack (with its arguments) is saved across process death, while the ViewModel's private `navigationStack` is not; per-entry `rememberSaveable` keeps each folder's query and scroll position, which replaces the old RecyclerView state stack. Breadcrumb jumps pop entries; a folder opened from search results rebuilds the stack from the shared parent (`folderMoves`), so back always goes to the parent folder. `MainActivity` and `LaunchActivity` start the list exactly as before (`ListRoute()` is the root). +- **Entry subtitle:** none while browsing; in search results, the entry's folder relative to the current folder (what the old list showed above the name). Username and OTP are not shown: the default `DirectoryStructure` (`EncryptedUsername`) keeps the username inside the encrypted file, the path-based structures would only repeat the row label or the folder already shown in the breadcrumbs, and OTP is only knowable after decrypting. The list never decrypts. Task 5 corrects the spec line. +- **Tapping an entry still starts `DecryptActivity` directly** (as the old list did), not through `EntryRoute`: `EntryRoute`'s step 1 trampoline finishes `MainActivity`, which would close the list. Step 3 switches the tap to `navigate(EntryRoute(...))`. +- **`MainActivity` extends `BaseGitActivity`** (an `AppCompatActivity`, hence still a `FragmentActivity` for `BiometricPrompt`). Git operations cast their activity to `AppCompatActivity`. It gets a no-action-bar theme so AppCompat does not draw a bar over Compose. Task 5 corrects the spec line. +- **Results from legacy activities** are not read through result callbacks except for the folder picker (which returns data). The list refreshes on every resume of its back stack entry, which is when any legacy activity returns. +- **Dialogs:** create sheet, new folder, rename, delete confirmation, clone prompt and "create a store first" are Compose. The overwrite confirmation and the move error that can pop up in the middle of a move stay `MaterialAlertDialogBuilder`, like the git error prompts in `BaseGitActivity` (they are `ponytail:` marked). +- **Security:** `LaunchActivity`'s gate and `isFileInRepo` check are untouched. Entry paths given to `DecryptActivity` come from the app's own directory listing, as before. New folder names get the same "inside the repository" check renames already had (old folder creation had none). The autofill inline suggestion's attribution intent now opens `LaunchActivity` (biometric gate) instead of the list activity directly. + +## Feature parity (old `PasswordStore` / `PasswordFragment` behavior) + +| Behavior today | Decision | +|---|---| +| Onboarding redirect when `REPOSITORY_INITIALIZED` is false (checked on every resume, after `PasswordRepository.initialize()`) | Port (`MainActivity.checkStore`, called on resume) | +| `REPO_CHANGED` re-creates the fragment | Drop: nothing sets the key any more; the list re-reads the store on every resume anyway | +| `matchWith` extra (pick an entry and return it to the caller) | Drop: no caller sets it, dead code | +| Folder browsing with title = folder name, up arrow, back pops a folder | Port as breadcrumbs plus back stack entries | +| Search (fuzzy, recursive per `FILTER_RECURSIVELY`, sort per `SORT_ORDER`, `SHOW_HIDDEN_CONTENTS`) | Port: reuses the ViewModel unchanged | +| Search clears when a folder is opened; back collapses search | Port (`BackHandler` clears the query first) | +| `SEARCH_ON_START` opens search | Port (focuses the search field) | +| Type-to-search and Ctrl+F / search key on a hardware keyboard | Drop: the search field is always on screen, one tap away | +| Overflow: Sync / Pull / Push (by auth mode and git state), "create a store first" dialog when not initialized | Port | +| Overflow: Settings | Port (opens `SettingsActivity`) | +| Overflow: "Refresh list" | Drop: the list refreshes on every resume and after every action, pull to sync also refreshes | +| Pull to sync: refresh only without a `.git` dir, clone prompt for a non-git store, Pull for `AuthMode.None` else Sync, errors via `promptOnErrorHandler`, disabled by `DISABLE_SYNC_ACTION` and while selecting | Port | +| Tap entry: `DecryptActivity` plus dynamic shortcut | Port (legacy intent until step 3) | +| `RECENTLY_USED` sort records open time of tapped folders and entries | Port (`recordUse`) | +| Folder child count (subfolders and `.gpg` files) | Port (`FolderRow` count) | +| Multi-select: move (folder picker, overwrite prompt, autofill match update, commit) | Port (folder picker stays the legacy `SelectFolderActivity` until step 4) | +| Multi-select: delete (no prompt for empty folders, plural prompt otherwise, autofill update, commit) | Port | +| Multi-select: rename folders (validation, recently-used timestamp carried over, commit) | Port, one folder at a time (`ponytail:`, old code queued one dialog per selected folder) | +| Multi-select: pin entry shortcut on the home screen | Port | +| FAB opens the create sheet: new password (legacy editor), new folder (dialog, then opens it) | Port (editor stays `PasswordCreationActivity` until step 4) | +| Fast scroller, item animations, dividers | Drop: no Compose equivalent in our dependencies; search covers jumping | +| `onKeyDown` printable-char helper `isPrintable` | Drop with type-to-search | + +Left for later steps because other legacy screens still use them: `PasswordItemRecyclerAdapter`, `password_recycler_view.xml`, `password_row_layout.xml`, `SearchableRepositoryAdapter` and fastscroll (`SelectFolderFragment`, `AutofillFilterView`), `BasicBottomSheet` (`GitServerConfigActivity`), `swiperefreshlayout` (the `password_recycler_view.xml` layout still contains it), the unused `PreferenceKeys.REPO_CHANGED` constant (step 8). + +**Conventions for every task** + +- New files get this header: + ```kotlin + /* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + ``` +- Commits are GPG-signed (default config, never disable). End each message with + `Co-Authored-By: Claude Opus 5.5 `. +- No em dashes in docs, comments or commit messages. +- Before each commit run `./gradlew spotlessApply`, then the step's checks. +- Work on the `compose` branch. + +--- + +### Task 1: hilt-navigation-compose and lifecycle-runtime-compose + +**Files:** +- Modify: `gradle/libs.versions.toml` +- Modify: `app/build.gradle.kts` + +- [ ] **Step 1: Catalog entries** + +In `[libraries]` of `gradle/libs.versions.toml`, after `androidx-fragment-ktx`, add: + +```toml +androidx-hilt-navigation-compose = "androidx.hilt:hilt-navigation-compose:1.2.0" +``` + +After `androidx-lifecycle-livedataKtx`, add: + +```toml +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +``` + +(`collectAsStateWithLifecycle` and `LifecycleResumeEffect` live there. It already arrives +transitively through Compose UI; declaring it pins it to the catalog's lifecycle version.) + +- [ ] **Step 2: Use them in the app module** + +In `app/build.gradle.kts` `dependencies { }`, after `implementation(libs.androidx.fragment.ktx)`: + +```kotlin + implementation(libs.androidx.hilt.navigation.compose) +``` + +and after `implementation(libs.androidx.lifecycle.viewmodel.compose)`: + +```kotlin + implementation(libs.androidx.lifecycle.runtime.compose) +``` + +- [ ] **Step 3: Build and lint** + +Run: `CI=1 ./gradlew :app:assembleDebug :app:lintDebug` +Expected: `BUILD SUCCESSFUL`. If lint reports `app/lint-baseline.xml` entries that no longer match +(baselined jars are keyed on their cache path, see AGENTS.md), update the moved paths in +`app/lint-baseline.xml` and re-run. + +- [ ] **Step 4: Commit** + +```bash +git add gradle/libs.versions.toml app/build.gradle.kts app/lint-baseline.xml +git commit -m "build(app): add hilt-navigation-compose and lifecycle-runtime-compose + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: List model (TDD) + +Two pure functions: the rows for one folder (or one search inside it), and the back stack moves +between two folder paths. + +**Files:** +- Create: `app/src/main/java/app/passwordstore/ui/list/ListModel.kt` +- Test: `app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt` +- Modify: `app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt` + +- [ ] **Step 1: Write the failing test** + +`app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt`: + +```kotlin +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class ListModelTest { + + private val root = Files.createTempDirectory("aps").toFile() + + @AfterTest + fun cleanUp() { + root.deleteRecursively() + } + + private fun file(path: String) = + File(root, path).apply { + parentFile!!.mkdirs() + writeText("x") + } + + private fun folder(path: String) = File(root, path).apply { mkdirs() } + + @Test + fun openingAChildFolderPushesIt() { + assertEquals(0 to listOf("work"), folderMoves("", "work")) + } + + @Test + fun breadcrumbJumpsOnlyPop() { + assertEquals(1 to emptyList(), folderMoves("work/servers", "work")) + assertEquals(2 to emptyList(), folderMoves("work/servers", "")) + } + + @Test + fun searchResultsRebuildTheStackFromTheSharedParent() { + assertEquals(1 to listOf("personal", "personal/mail"), folderMoves("work", "personal/mail")) + assertEquals( + 0 to listOf("work/servers", "work/servers/eu"), + folderMoves("work", "work/servers/eu"), + ) + } + + @Test + fun sameFolderMovesNothing() { + assertEquals(0 to emptyList(), folderMoves("work", "work")) + } + + @Test + fun browsingShowsNamesAndFolderCounts() { + val work = folder("work") + file("work/mail.gpg") + file("work/notes.txt") + folder("work/servers") + val site = file("site.gpg") + + val rows = listRows(listOf(work, site), root) + + assertEquals(listOf(ListRow(work.absolutePath, "work", null, 2)), rows.folders) + assertEquals(listOf(ListRow(site.absolutePath, "site", null, 0)), rows.entries) + } + + @Test + fun searchResultsShowWhereTheyAre() { + val servers = folder("work/servers") + val vpn = file("work/vpn.gpg") + + val rows = listRows(listOf(vpn, servers), root) + + assertEquals(listOf(ListRow(servers.absolutePath, "work/servers", null, 0)), rows.folders) + assertEquals(listOf(ListRow(vpn.absolutePath, "vpn", "work", 0)), rows.entries) + } + + @Test + fun keepsTheGivenOrderInsideEachGroup() { + val b = file("b.gpg") + val a = file("a.gpg") + val z = folder("z") + val y = folder("y") + + val rows = listRows(listOf(b, z, a, y), root) + + assertEquals(listOf(z.absolutePath, y.absolutePath), rows.folders.map { it.path }) + assertEquals(listOf(b.absolutePath, a.absolutePath), rows.entries.map { it.path }) + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.list.ListModelTest` +Expected: compilation FAILS with `Unresolved reference 'folderMoves'` / `'listRows'` / `'ListRow'`. + +- [ ] **Step 3: Implement the model** + +`app/src/main/java/app/passwordstore/ui/list/ListModel.kt`: + +```kotlin +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import app.passwordstore.ui.compose.crumbsFor +import java.io.File +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +/** + * One list row. [path] is the absolute path and the row's key; [count] is a folder's number of + * children, 0 for entries. + */ +data class ListRow(val path: String, val label: String, val subtitle: String?, val count: Int) + +/** The rows of one folder, or of a search inside it: folders above entries. */ +data class ListRows( + val folders: ImmutableList = persistentListOf(), + val entries: ImmutableList = persistentListOf(), +) + +/** + * Splits [files] (already sorted or ranked) into folder and entry rows for [dir], keeping their + * order. Labels and subtitles are relative to [dir]: browsing shows plain names, search results + * show where they are. Entries get no username or OTP subtitle: both live inside the encrypted + * file, and the list never decrypts. + */ +fun listRows(files: List, dir: File): ListRows { + val (folders, entries) = files.partition { it.isDirectory } + return ListRows( + folders + .map { ListRow(it.absolutePath, it.relativeTo(dir).path, null, childCount(it)) } + .toImmutableList(), + entries + .map { + val parent = it.parentFile?.relativeTo(dir)?.path.orEmpty() + ListRow(it.absolutePath, it.name.removeSuffix(".gpg"), parent.ifEmpty { null }, 0) + } + .toImmutableList(), + ) +} + +/** Children counted for a folder: subfolders and .gpg files, as the old list counted them. */ +private fun childCount(folder: File): Int = + folder.listFiles { f -> f.isDirectory || f.extension == "gpg" }?.size ?: 0 + +/** + * How to get from the list at [from] to the list at [to] (both relative to the store root) while + * the back stack holds one entry per folder level: how many entries to pop, then which paths to + * push. Breadcrumb jumps only pop; opening a folder from search results may pop and push. + */ +fun folderMoves(from: String, to: String): Pair> { + val current = crumbsFor(from) + val target = crumbsFor(to) + val shared = current.zip(target).takeWhile { (a, b) -> a == b }.size + return (current.size - shared) to target.drop(shared).map { it.path } +} +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.list.ListModelTest` +Expected: `BUILD SUCCESSFUL`, 7 tests passed. + +- [ ] **Step 5: Expose rows from the ViewModel** + +In `SearchableRepositoryViewModel.kt`, directly after the line +`val currentDir = _currentDir.asStateFlow()`, add: + +```kotlin + + /** [searchResult] as folder and entry rows for the Compose list, built off the main thread. */ + val rows = + searchResult + .map { result -> listRows(result.passwordItems.map { it.file }, currentDir.value) } + .flowOn(dispatcherProvider.io()) +``` + +and add the import `import app.passwordstore.ui.list.listRows`. Nothing else in the ViewModel +changes; `AutofillFilterView` and `SelectFolderFragment` never collect `rows`. + +- [ ] **Step 6: Build and test** + +Run: `./gradlew spotlessApply && CI=1 ./gradlew :app:assembleDebug :app:testDebugUnitTest` +Expected: `BUILD SUCCESSFUL`, all unit tests pass (existing ones included). + +- [ ] **Step 7: Commit** + +```bash +git add app/src/main/java/app/passwordstore/ui/list/ListModel.kt \ + app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt \ + app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +git commit -m "feat(app): add the list rows and folder navigation model + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Store operations + +Port of `PasswordStore`'s delete, move, rename and recently-used code into top-level functions, +so the Compose list can call them on `MainActivity`. Behavior is the same except for two fixes +noted inline: a move now waits for the overwrite answer before committing (the old code +committed first and moved later), and a new folder name is checked to stay inside the store. + +**Files:** +- Create: `app/src/main/java/app/passwordstore/ui/list/StoreActions.kt` + +- [ ] **Step 1: Write the functions** + +`app/src/main/java/app/passwordstore/ui/list/StoreActions.kt`: + +```kotlin +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import android.content.Context +import androidx.core.content.edit +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.crypto.BasePGPActivity.Companion.getLongName +import app.passwordstore.ui.git.base.BaseGitActivity +import app.passwordstore.util.autofill.AutofillMatcher +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.listFilesRecursively +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.PasswordSortOrder +import app.passwordstore.util.settings.PreferenceKeys +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.io.File +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.logcat + +/** Shared preferences file of the "recently used" sort order, keyed by base64 of the path. */ +private const val HISTORY = "recent_password_history" + +/** Password files in [files], counting a folder by everything below it. */ +fun countFiles(files: List): Int = + files.sumOf { if (it.isFile) 1 else it.listFilesRecursively().size } + +/** Why [name] cannot become the folder [target], as a string resource, or null when it can. */ +fun folderNameError(name: String, target: File): Int? = + when { + name.isBlank() -> R.string.message_category_error_empty_field + target.isFile -> R.string.folder_creation_err_file_exists + target.isDirectory -> R.string.folder_creation_err_folder_exists + !target.isInsideRepository() -> R.string.message_error_destination_outside_repo + else -> null + } + +/** Records that [file] was opened, for the "recently used" sort order. */ +fun Context.recordUse(file: File) { + if (sharedPrefs.getString(PreferenceKeys.SORT_ORDER) != PasswordSortOrder.RECENTLY_USED.name) + return + getSharedPreferences(HISTORY, Context.MODE_PRIVATE).edit { + putString(file.absolutePath.base64(), System.currentTimeMillis().toString()) + } +} + +/** Deletes [files] (entries or whole folders), drops their autofill matches and commits. */ +suspend fun BaseGitActivity.deleteItems(files: List) { + val root = PasswordRepository.getRepositoryDirectory() + val deleted = files.flatMap { if (it.isDirectory) it.listFilesRecursively() else listOf(it) } + files.forEach { it.deleteRecursively() } + AutofillMatcher.updateMatches(applicationContext, delete = deleted) + commitChange( + getString(R.string.git_commit_remove_text, files.joinToString(", ") { it.toRelativeString(root) }) + ) +} + +/** Moves [files] into the folder [target], asking before overwriting, then commits. */ +suspend fun BaseGitActivity.moveItems(files: List, target: File) { + val repo = PasswordRepository.getRepositoryDirectory().absolutePath + for (source in files) { + if (!source.exists()) { + logcat(ERROR) { "Tried moving something that appears non-existent." } + continue + } + val destination = File(target, source.name) + val name = source.nameWithoutExtension + if ( + destination.exists() && + !confirm( + getString(R.string.password_exists_title), + getString( + R.string.password_exists_message, + getLongName(target.absolutePath, repo, name), + getLongName(source.parent.orEmpty(), repo, name), + ), + ) + ) + continue + moveFile(source, destination) + } + val message = + if (files.size == 1) { + val name = files[0].nameWithoutExtension + getString( + R.string.git_commit_move_text, + getLongName(files[0].parent.orEmpty(), repo, name), + getLongName(target.absolutePath, repo, name), + ) + } else { + val relative = "${target.absolutePath}/".replace(repo, "").replace("/+".toRegex(), "/") + getString(R.string.git_commit_move_multiple_text, relative) + } + commitChange(message) +} + +/** Renames the folder [from] to [to], keeps its recently-used time, then commits. */ +suspend fun BaseGitActivity.renameFolder(from: File, to: File) { + moveFile(from, to) + val history = getSharedPreferences(HISTORY, Context.MODE_PRIVATE) + val time = history.getString(from.absolutePath.base64()) + if (time != null) + history.edit { + remove(from.absolutePath.base64()) + putString(to.absolutePath.base64(), time) + } + commitChange(getString(R.string.git_commit_move_text, from.name, to.name)) +} + +private suspend fun BaseGitActivity.moveFile(source: File, destination: File) { + val moved = + withContext(dispatcherProvider.io()) { + val map = + if (source.isDirectory) { + destination.mkdirs() + source.listFilesRecursively().associateWith { destination.resolve(it.relativeTo(source)) } + } else { + mapOf(source to destination) + } + source.renameTo(destination).also { ok -> + if (ok) AutofillMatcher.updateMatches(this@moveFile, map) + } + } + if (!moved) { + logcat(ERROR) { "Something went wrong while moving $source to $destination." } + // ponytail: View dialog like BaseGitActivity's git prompts; both move to Compose together. + MaterialAlertDialogBuilder(this) + .setTitle(R.string.password_move_error_title) + .setMessage(getString(R.string.password_move_error_message, source, destination)) + .setPositiveButton(android.R.string.ok, null) + .show() + } +} + +/** Shows [message] under [title] and suspends until answered; true when confirmed. */ +// ponytail: View dialog, see moveFile. +private suspend fun Context.confirm(title: String, message: String): Boolean = + suspendCancellableCoroutine { cont -> + MaterialAlertDialogBuilder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.dialog_ok) { _, _ -> cont.resume(true) } + .setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(false) } + .setOnCancelListener { cont.resume(false) } + .show() + } +``` + +- [ ] **Step 2: Build** + +Run: `./gradlew spotlessApply && CI=1 ./gradlew :app:assembleDebug` +Expected: `BUILD SUCCESSFUL` (the functions are not called yet; unused top-level public functions +produce no Kotlin warning). + +- [ ] **Step 3: Commit** + +```bash +git add app/src/main/java/app/passwordstore/ui/list/StoreActions.kt +git commit -m "feat(app): port the list's store operations out of PasswordStore + +A move now waits for the overwrite answer before it commits, and new +folder names must stay inside the store. + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: List screen (stateless UI) and strings + +**Files:** +- Create: `app/src/main/java/app/passwordstore/ui/list/ListScreen.kt` +- Modify: `app/src/main/res/values/strings.xml` +- Modify: `app/src/main/res/values-it/strings.xml` + +- [ ] **Step 1: Strings** + +In `app/src/main/res/values/strings.xml`, before the closing ``, add: + +```xml + + New entry + Password store + More options + Clear search +``` + +In `app/src/main/res/values-it/strings.xml`, before the closing ``, add: + +```xml + + Nuova voce + Archivio password + Altre opzioni + Cancella ricerca +``` + +Everything else reuses existing strings (`action_search`, `action_settings`, `git_sync`, +`git_pull`, `git_push`, `delete_title`, `move`, `delete`, `title_rename_folder`, +`place_shortcut_on_home_screen`, `dialog_cancel`, `bottom_sheet_create_new_password`, +`bottom_sheet_create_new_folder`) and existing drawables (`ic_search_24dp`, +`ic_move_folder_24dp`, `ic_delete_24dp`, `ic_edit_24dp`, `ic_push_pin_24dp`, +`ic_action_new_password`, `ic_action_new_folder`). + +- [ ] **Step 2: The screen** + +`app/src/main/java/app/passwordstore/ui/list/ListScreen.kt`: + +```kotlin +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +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.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.passwordstore.R +import app.passwordstore.ui.compose.BreadcrumbBar +import app.passwordstore.ui.compose.EntryRow +import app.passwordstore.ui.compose.FolderRow +import app.passwordstore.ui.compose.GroupedSurface +import app.passwordstore.ui.compose.crumbsFor +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.ui.git.base.BaseGitActivity.GitOp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet + +/** + * The password list: search bar, breadcrumbs, folders above entries, pull to sync, long-press + * selection and the "New entry" button. Row callbacks take absolute paths; [onCrumb] takes a path + * relative to the store root, like [path]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListScreen( + path: String, + query: String, + rows: ListRows, + selected: ImmutableSet, + refreshing: Boolean, + canSync: Boolean, + gitOps: ImmutableList, + onQueryChange: (String) -> Unit, + onCrumb: (relativePath: String) -> Unit, + onOpenFolder: (path: String) -> Unit, + onOpenEntry: (path: String) -> Unit, + onToggle: (path: String) -> Unit, + onClearSelection: () -> Unit, + onSync: () -> Unit, + onGitOp: (GitOp) -> Unit, + onSettings: () -> Unit, + onNew: () -> Unit, + onMove: () -> Unit, + onDelete: () -> Unit, + onRename: () -> Unit, + onPin: () -> Unit, + modifier: Modifier = Modifier, + searchFocus: FocusRequester = remember { FocusRequester() }, +) { + val selecting = selected.isNotEmpty() + val listState = rememberLazyListState() + val pullState = rememberPullToRefreshState() + // Filtered results always start at the top, where the best match is. + LaunchedEffect(query) { if (query.isNotEmpty()) listState.scrollToItem(0) } + Scaffold( + modifier = modifier, + topBar = { + if (selecting) { + SelectionBar(rows, selected, onClearSelection, onMove, onDelete, onRename, onPin) + } else { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + SearchField(query, gitOps, onQueryChange, onGitOp, onSettings, searchFocus) + BreadcrumbBar( + crumbsFor(path), + stringResource(R.string.list_store_root), + onCrumb, + Modifier.fillMaxWidth().padding(vertical = SpacingMedium), + ) + } + } + }, + floatingActionButton = { + if (!selecting) + ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.list_new_entry)) }, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + onClick = onNew, + ) + }, + ) { padding -> + Box( + Modifier.padding(padding) + .fillMaxSize() + .pullToRefresh(refreshing, pullState, enabled = canSync && !selecting, onRefresh = onSync) + ) { + // ponytail: each group is one lazy item, fine for hundreds of rows; split rows into their + // own items (with per-row corner shapes) if a store ever has thousands in one folder. + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 96.dp), + ) { + if (rows.folders.isNotEmpty()) + item(key = "folders") { + GroupedSurface(Modifier.fillMaxWidth()) { + rows.folders.forEach { row -> + key(row.path) { + FolderRow( + name = row.label, + count = row.count, + onClick = { if (selecting) onToggle(row.path) else onOpenFolder(row.path) }, + selected = row.path in selected, + onLongClick = { onToggle(row.path) }, + ) + } + } + } + } + if (rows.entries.isNotEmpty()) + item(key = "entries") { + GroupedSurface(Modifier.fillMaxWidth()) { + rows.entries.forEach { row -> + key(row.path) { + EntryRow( + name = row.label, + subtitle = row.subtitle, + onClick = { if (selecting) onToggle(row.path) else onOpenEntry(row.path) }, + selected = row.path in selected, + onLongClick = { onToggle(row.path) }, + ) + } + } + } + } + } + PullToRefreshDefaults.Indicator(pullState, refreshing, Modifier.align(Alignment.TopCenter)) + } + } +} + +/** Bottom sheet behind the "New entry" button: a password entry or a folder. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateSheet( + onDismiss: () -> Unit, + onNewEntry: () -> Unit, + onNewFolder: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet(onDismissRequest = onDismiss, modifier = modifier) { + ListItem( + headlineContent = { Text(stringResource(R.string.bottom_sheet_create_new_password)) }, + modifier = Modifier.clickable(onClick = onNewEntry), + leadingContent = { + Icon(painterResource(R.drawable.ic_action_new_password), contentDescription = null) + }, + ) + ListItem( + headlineContent = { Text(stringResource(R.string.bottom_sheet_create_new_folder)) }, + modifier = Modifier.clickable(onClick = onNewFolder), + leadingContent = { + Icon(painterResource(R.drawable.ic_action_new_folder), contentDescription = null) + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchField( + query: String, + gitOps: ImmutableList, + onQueryChange: (String) -> Unit, + onGitOp: (GitOp) -> Unit, + onSettings: () -> Unit, + focus: FocusRequester, +) { + val focusManager = LocalFocusManager.current + var menu by remember { mutableStateOf(false) } + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = { focusManager.clearFocus() }, + expanded = false, + onExpandedChange = {}, + modifier = Modifier.focusRequester(focus), + placeholder = { Text(stringResource(R.string.action_search)) }, + leadingIcon = { + Icon(painterResource(R.drawable.ic_search_24dp), contentDescription = null) + }, + trailingIcon = { + Row { + if (query.isNotEmpty()) + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Filled.Close, stringResource(R.string.list_clear_search)) + } + Box { + IconButton(onClick = { menu = true }) { + Icon(Icons.Filled.MoreVert, stringResource(R.string.list_more_options)) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + gitOps.forEach { op -> + DropdownMenuItem( + text = { Text(stringResource(op.label)) }, + onClick = { + menu = false + onGitOp(op) + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.action_settings)) }, + onClick = { + menu = false + onSettings() + }, + ) + } + } + } + }, + ) + }, + expanded = false, + onExpandedChange = {}, + ) {} +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelectionBar( + rows: ListRows, + selected: ImmutableSet, + onClear: () -> Unit, + onMove: () -> Unit, + onDelete: () -> Unit, + onRename: () -> Unit, + onPin: () -> Unit, +) { + val single = selected.singleOrNull() + TopAppBar( + title = { Text(pluralStringResource(R.plurals.delete_title, selected.size, selected.size)) }, + navigationIcon = { + IconButton(onClick = onClear) { + Icon(Icons.Filled.Close, stringResource(R.string.dialog_cancel)) + } + }, + actions = { + ActionIcon(R.drawable.ic_move_folder_24dp, R.string.move, onMove) + ActionIcon(R.drawable.ic_delete_24dp, R.string.delete, onDelete) + // ponytail: rename one folder at a time; the old list queued one dialog per folder. + if (rows.folders.any { it.path == single }) + ActionIcon(R.drawable.ic_edit_24dp, R.string.title_rename_folder, onRename) + if (rows.entries.any { it.path == single }) + ActionIcon(R.drawable.ic_push_pin_24dp, R.string.place_shortcut_on_home_screen, onPin) + }, + ) +} + +@Composable +private fun ActionIcon(@DrawableRes icon: Int, @StringRes label: Int, onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon(painterResource(icon), contentDescription = stringResource(label)) + } +} + +private val GitOp.label: Int + get() = + when (this) { + GitOp.SYNC -> R.string.git_sync + GitOp.PUSH -> R.string.git_push + else -> R.string.git_pull + } +``` + +- [ ] **Step 3: Build** + +Run: `./gradlew spotlessApply && CI=1 ./gradlew :app:assembleDebug` +Expected: `BUILD SUCCESSFUL`. If `SearchBar`'s `inputField` overload does not resolve, the +Compose BOM is older than 2024.09 (material3 1.3.0); check `gradle/libs.versions.toml` before +changing code. + +- [ ] **Step 4: Commit** + +```bash +git add app/src/main/java/app/passwordstore/ui/list/ListScreen.kt \ + app/src/main/res/values/strings.xml app/src/main/res/values-it/strings.xml +git commit -m "feat(app): add the Compose password list screen + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Wire the list into MainActivity + +After this task the launcher opens the Compose list. `PasswordStore` still compiles but nothing +starts it except the autofill attribution intent (fixed in Task 6). + +**Files:** +- Create: `app/src/main/java/app/passwordstore/ui/list/ListDestination.kt` +- Modify (replace whole file): `app/src/main/java/app/passwordstore/ui/main/MainActivity.kt` +- Modify: `app/src/main/res/values/themes_material3.xml` +- Modify: `app/src/main/AndroidManifest.xml` +- Modify: `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md` + +- [ ] **Step 1: The list destination** + +`app/src/main/java/app/passwordstore/ui/list/ListDestination.kt`: + +```kotlin +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import android.app.Activity +import android.content.Intent +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.ui.folderselect.SelectFolderActivity +import app.passwordstore.ui.git.base.BaseGitActivity.GitOp +import app.passwordstore.ui.git.config.GitServerConfigActivity +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.PreferenceKeys +import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel +import java.io.File +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.launch + +private enum class ListDialog { + None, + Create, + NewFolder, + Rename, + Delete, + Clone, + NotInitialized, +} + +/** + * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the + * legacy activities and the store operations. [onOpenFolder] takes a path relative to the root. + */ +@Composable +fun ListDestination( + activity: MainActivity, + path: String, + onOpenFolder: (relativePath: String) -> Unit, + modifier: Modifier = Modifier, + viewModel: SearchableRepositoryViewModel = hiltViewModel(), +) { + val root = PasswordRepository.getRepositoryDirectory() + val dir = File(root, path) + val prefs = activity.sharedPrefs + var query by rememberSaveable { mutableStateOf("") } + var selected by rememberSaveable { mutableStateOf(listOf()) } + var dialog by rememberSaveable { mutableStateOf(ListDialog.None) } + var deleteCount by rememberSaveable { mutableIntStateOf(0) } + var refreshing by remember { mutableStateOf(false) } + var resumes by remember { mutableIntStateOf(0) } + val searchFocus = remember { FocusRequester() } + + // Declared before the collection below on purpose: effects start in order, so the ViewModel + // already points at this folder when the first rows are built (no flash of the store root). + LaunchedEffect(path, query) { + viewModel.navigateTo(dir, pushPreviousLocation = false) + if (query.isNotBlank()) viewModel.search(query.trim()) + } + val rows by viewModel.rows.collectAsStateWithLifecycle(ListRows()) + val gitOps = remember(resumes) { activity.gitOps() } + val canSync = remember(resumes) { !prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false) } + + LaunchedEffect(Unit) { + if (prefs.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) searchFocus.requestFocus() + } + // Every return to this folder (back from a child, or from a legacy activity) re-reads it. + LifecycleResumeEffect(Unit) { + if (activity.checkStore()) { + if (path.isNotEmpty() && !dir.isDirectory) onOpenFolder("") else viewModel.forceRefresh() + } + resumes++ + onPauseOrDispose {} + } + BackHandler(enabled = selected.isNotEmpty()) { selected = emptyList() } + BackHandler(enabled = selected.isEmpty() && query.isNotEmpty()) { query = "" } + + val moveLauncher = + rememberLauncherForActivityResult(StartActivityForResult()) { result -> + val data = result.data + val target = data?.getStringExtra("SELECTED_FOLDER_PATH")?.let(::File) + val files = data?.getStringArrayExtra("Files")?.map(::File) + if (result.resultCode == Activity.RESULT_OK && target?.isDirectory == true && files != null) { + selected = emptyList() + activity.lifecycleScope.launch { + activity.moveItems(files, target) + viewModel.forceRefresh() + } + } + } + + fun ifInitialized(action: () -> Unit) { + if (PasswordRepository.isInitialized) action() else dialog = ListDialog.NotInitialized + } + + fun runGit(op: GitOp) { + refreshing = true + activity.lifecycleScope.launch { + activity.runGit(op) + refreshing = false + viewModel.forceRefresh() + } + } + + Box(modifier) { + ListScreen( + path = path, + query = query, + rows = rows, + selected = selected.toImmutableSet(), + refreshing = refreshing, + canSync = canSync, + gitOps = gitOps, + onQueryChange = { query = it }, + onCrumb = onOpenFolder, + onOpenFolder = { folder -> + activity.recordUse(File(folder)) + query = "" + onOpenFolder(File(folder).relativeTo(root).path) + }, + onOpenEntry = { entry -> + activity.recordUse(File(entry)) + activity.openEntry(File(entry)) + }, + onToggle = { p -> selected = if (p in selected) selected - p else selected + p }, + onClearSelection = { selected = emptyList() }, + onSync = { + when { + root.resolve(".git").listFiles().isNullOrEmpty() -> viewModel.forceRefresh() + !PasswordRepository.isGitRepo() -> dialog = ListDialog.Clone + activity.gitSettings.authMode == AuthMode.None -> runGit(GitOp.PULL) + else -> runGit(GitOp.SYNC) + } + }, + onGitOp = { op -> ifInitialized { runGit(op) } }, + onSettings = activity::openSettings, + onNew = { dialog = ListDialog.Create }, + onMove = { + moveLauncher.launch( + Intent(activity, SelectFolderActivity::class.java) + .putExtra("Files", selected.toTypedArray()) + ) + }, + onDelete = { + val files = selected.map(::File) + val count = countFiles(files) + if (count == 0) { + files.forEach { it.deleteRecursively() } + selected = emptyList() + viewModel.forceRefresh() + } else { + deleteCount = count + dialog = ListDialog.Delete + } + }, + onRename = { dialog = ListDialog.Rename }, + onPin = { + activity.pinEntry(File(selected.single())) + selected = emptyList() + }, + searchFocus = searchFocus, + ) + + when (dialog) { + ListDialog.None -> Unit + ListDialog.Create -> + CreateSheet( + onDismiss = { dialog = ListDialog.None }, + onNewEntry = { + dialog = ListDialog.None + ifInitialized { activity.createEntry(dir) } + }, + onNewFolder = { + dialog = ListDialog.None + ifInitialized { dialog = ListDialog.NewFolder } + }, + ) + ListDialog.NewFolder -> + FolderNameDialog( + title = stringResource(R.string.title_create_folder), + message = null, + confirm = stringResource(R.string.button_create), + validate = { folderNameError(it, File(dir, it)) }, + onConfirm = { name -> + dialog = ListDialog.None + val folder = File(dir, name).canonicalFile + folder.mkdirs() + onOpenFolder(folder.relativeTo(root.canonicalFile).path) + }, + onDismiss = { dialog = ListDialog.None }, + ) + ListDialog.Rename -> { + val folder = File(selected.single()) + FolderNameDialog( + title = stringResource(R.string.title_rename_folder), + message = stringResource(R.string.message_rename_folder, folder.name), + confirm = stringResource(R.string.dialog_ok), + validate = { folderNameError(it, File(folder.parentFile, it)) }, + onConfirm = { name -> + dialog = ListDialog.None + selected = emptyList() + activity.lifecycleScope.launch { + activity.renameFolder(folder, File(folder.parentFile, name)) + viewModel.forceRefresh() + } + }, + onDismiss = { dialog = ListDialog.None }, + ) + } + ListDialog.Delete -> + Prompt( + text = pluralStringResource(R.plurals.delete_dialog_text, deleteCount, deleteCount), + confirm = stringResource(R.string.dialog_yes), + onConfirm = { + val files = selected.map(::File) + dialog = ListDialog.None + selected = emptyList() + activity.lifecycleScope.launch { + activity.deleteItems(files) + viewModel.forceRefresh() + } + }, + onDismiss = { dialog = ListDialog.None }, + dismiss = stringResource(R.string.dialog_no), + ) + ListDialog.Clone -> + Prompt( + text = stringResource(R.string.clone_git_repo), + confirm = stringResource(R.string.clone_button), + onConfirm = { + dialog = ListDialog.None + activity.startActivity(GitServerConfigActivity.createCloneIntent(activity)) + }, + onDismiss = { dialog = ListDialog.None }, + dismiss = stringResource(R.string.dialog_cancel), + ) + ListDialog.NotInitialized -> + Prompt( + text = stringResource(R.string.creation_dialog_text), + confirm = stringResource(R.string.dialog_ok), + onConfirm = { dialog = ListDialog.None }, + onDismiss = { dialog = ListDialog.None }, + ) + } + } +} + +/** Asks for a folder name; [validate] returns an error string resource or null. */ +@Composable +private fun FolderNameDialog( + title: String, + message: String?, + confirm: String, + validate: (String) -> Int?, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var name by rememberSaveable { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + val focus = remember { FocusRequester() } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + val err = error + Column { + if (message != null) Text(message, Modifier.padding(bottom = SpacingMedium)) + OutlinedTextField( + value = name, + onValueChange = { + name = it + error = null + }, + modifier = Modifier.focusRequester(focus), + label = { Text(stringResource(R.string.crypto_name_hint)) }, + supportingText = if (err != null) { { Text(stringResource(err)) } } else null, + isError = err != null, + singleLine = true, + ) + } + LaunchedEffect(Unit) { focus.requestFocus() } + }, + confirmButton = { + TextButton( + onClick = { + val e = validate(name) + error = e + if (e == null) onConfirm(name) + } + ) { + Text(confirm) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +/** A one-message dialog; [dismiss] adds a second button that only closes it. */ +@Composable +private fun Prompt( + text: String, + confirm: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + dismiss: String? = null, +) { + AlertDialog( + onDismissRequest = onDismiss, + text = { Text(text) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } }, + dismissButton = + if (dismiss != null) { + { TextButton(onClick = onDismiss) { Text(dismiss) } } + } else null, + ) +} +``` + +- [ ] **Step 2: MainActivity** + +Replace `app/src/main/java/app/passwordstore/ui/main/MainActivity.kt` with: + +```kotlin +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.main + +import android.app.Activity +import android.content.ComponentName +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import app.passwordstore.data.password.PasswordItem +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.crypto.BasePGPActivity +import app.passwordstore.ui.crypto.DecryptActivity +import app.passwordstore.ui.crypto.PasswordCreationActivity +import app.passwordstore.ui.git.base.BaseGitActivity +import app.passwordstore.ui.list.ListDestination +import app.passwordstore.ui.list.folderMoves +import app.passwordstore.ui.onboarding.activity.OnboardingActivity +import app.passwordstore.ui.settings.SettingsActivity +import app.passwordstore.util.extensions.launchActivity +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.PreferenceKeys +import app.passwordstore.util.shortcuts.ShortcutHandler +import com.github.michaelbull.result.onFailure +import dagger.hilt.android.AndroidEntryPoint +import java.io.File +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.Serializable + +/** + * The password list of the folder at [path], relative to the store root ("" is the root). The + * back stack holds one entry per folder level. + */ +@Serializable data class ListRoute(val path: String = "") + +/** One entry. [filePath] was checked with [LaunchActivity.isFileInRepo] before routing here. */ +@Serializable data class EntryRoute(val filePath: String) + +/** + * Single-activity host for the Compose UI. Not exported: it is only started by [LaunchActivity], + * after its biometric gate and path check. Extends [BaseGitActivity] (an AppCompatActivity, so + * also the FragmentActivity BiometricPrompt needs) because the git operations require one; it + * hosts no fragments. + */ +@AndroidEntryPoint +class MainActivity : BaseGitActivity() { + + @Inject lateinit var shortcutHandler: ShortcutHandler + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val filePath = intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH) + val start: Any = if (filePath != null) EntryRoute(filePath) else ListRoute() + setContent { + APSTheme { + val nav = rememberNavController() + NavHost(nav, startDestination = start) { + composable { entry -> + val path = entry.toRoute().path + ListDestination( + activity = this@MainActivity, + path = path, + onOpenFolder = { target -> + // Only the folder on top may move the stack: a double tap must not push twice. + if (nav.currentBackStackEntry?.id == entry.id) { + val (pops, pushes) = folderMoves(path, target) + repeat(pops) { nav.popBackStack() } + pushes.forEach { nav.navigate(ListRoute(it)) } + } + }, + ) + } + composable { entry -> + val route = entry.toRoute() + LegacyScreen( + this@MainActivity, + Intent(this@MainActivity, DecryptActivity::class.java) + .putExtra(BasePGPActivity.EXTRA_FILE_PATH, route.filePath) + .putExtra( + BasePGPActivity.EXTRA_REPO_PATH, + PasswordRepository.getRepositoryDirectory().absolutePath, + ), + ) + } + } + } + } + } + + /** Re-reads the store; starts onboarding and returns false when there is none yet. */ + fun checkStore(): Boolean { + PasswordRepository.initialize() + if (sharedPrefs.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) return true + launchActivity(OnboardingActivity::class.java) + return false + } + + /** Git actions offered in the list's menu, chosen as the old list chose its menu. */ + fun gitOps(): ImmutableList = + when { + gitSettings.authMode == AuthMode.None -> persistentListOf(GitOp.PULL) + PasswordRepository.isGitRepo() -> persistentListOf(GitOp.SYNC, GitOp.PULL, GitOp.PUSH) + else -> persistentListOf() + } + + /** Runs [op]; failures show the usual git error dialog. */ + suspend fun runGit(op: GitOp) { + launchGitOperation(op).onFailure { promptOnErrorHandler(it) } + } + + // ponytail: the list still opens the legacy decrypt screen directly; step 3 navigates to + // EntryRoute instead (its trampoline would finish this activity and close the list). + fun openEntry(file: File) { + val item = entryItem(file) + val authIntent = item.createAuthEnabledIntent(this) + startActivity(Intent(authIntent).setComponent(ComponentName(this, DecryptActivity::class.java))) + shortcutHandler.addDynamicShortcut(item, authIntent) + } + + fun pinEntry(file: File) { + val item = entryItem(file) + shortcutHandler.addPinnedShortcut(item, item.createAuthEnabledIntent(this)) + } + + fun createEntry(dir: File) { + startActivity( + Intent(this, PasswordCreationActivity::class.java) + .putExtra(BasePGPActivity.EXTRA_FILE_PATH, dir.absolutePath) + .putExtra( + BasePGPActivity.EXTRA_REPO_PATH, + PasswordRepository.getRepositoryDirectory().absolutePath, + ) + ) + } + + fun openSettings() { + launchActivity(SettingsActivity::class.java) + } + + private fun entryItem(file: File) = + PasswordItem.newPassword(file.name, file, PasswordRepository.getRepositoryDirectory()) +} + +// ponytail: trampoline to a screen not yet ported; each port replaces one call with the +// real composable, and the last port deletes this function. +@Composable +private fun LegacyScreen(activity: Activity, intent: Intent) { + LaunchedEffect(Unit) { + activity.startActivity(intent) + activity.finish() + } +} +``` + +- [ ] **Step 3: No action bar for MainActivity** + +In `app/src/main/res/values/themes_material3.xml`, after the closing `` of +`AppThemeM3`, add: + +```xml + + + +``` + +- [ ] **Step 4: Manifest** + +In `app/src/main/AndroidManifest.xml`, replace + +```xml + +``` + +with + +```xml + +``` + +and in the `.ui.settings.SettingsActivity` element replace +`android:parentActivityName=".ui.passwords.PasswordStore"` with +`android:parentActivityName=".ui.main.MainActivity"` (Settings' up arrow returns to the list, as +before). + +- [ ] **Step 5: Spec corrections** + +In `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`, replace + +``` +- `MainActivity` (new, not exported, extends `FragmentActivity` for `BiometricPrompt`, uses no + fragments) hosts a Compose `NavHost`. +``` + +with + +``` +- `MainActivity` (new, not exported, extends `BaseGitActivity`: an `AppCompatActivity`, as the + git operations require, and so a `FragmentActivity` for `BiometricPrompt`; uses no fragments) + hosts a Compose `NavHost`. +``` + +(the sentence continues unchanged with "It replaces ..."), and replace + +``` +- Folders in one grouped surface, entries in another below it. Folder rows show their entry + count; entry rows show the username, plus "OTP" when the entry has one. +``` + +with + +``` +- Folders in one grouped surface, entries in another below it. Folder rows show their entry + count; in search results, entry rows show their folder. Username and OTP are not shown: both + live inside the encrypted file, and the list never decrypts. +- One back stack entry per folder level (`ListRoute(path)`): system back goes up one folder and + leaves the app at the root. +``` + +- [ ] **Step 6: Build, test, lint** + +Run: +```bash +./gradlew spotlessApply +CI=1 ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug +``` +Expected: `BUILD SUCCESSFUL`. Likely lint findings and their fixes: a compose-lint rule on a new +composable (fix the code, do not suppress, unless it is `ComposeUnstableCollections` on a type +that is already immutable); nothing should report unused resources yet, because `PasswordStore` +still references the old ones. + +- [ ] **Step 7: Quick smoke on the emulator** + +With the emulator from Task 7 running (`export ANDROID_SERIAL=emulator-5554` first; a phone may +be connected over USB, never install on it): +```bash +./gradlew :app:installDebug +adb shell am start -n xyz.danix.hardenedpass.debug/app.passwordstore.ui.main.LaunchActivity +sleep 3; adb shell dumpsys activity activities | grep -E "topResumedActivity" +``` +Expected: `MainActivity` (or `OnboardingActivity` if the store is not set up). Full checks are in +Task 7. + +- [ ] **Step 8: Commit** + +```bash +git add app/src/main/java/app/passwordstore/ui/list/ListDestination.kt \ + app/src/main/java/app/passwordstore/ui/main/MainActivity.kt \ + app/src/main/res/values/themes_material3.xml app/src/main/AndroidManifest.xml \ + docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md +git commit -m "feat(app): show the Compose password list in MainActivity + +Folders are back stack entries (ListRoute(path)), so back goes up one +level. MainActivity extends BaseGitActivity for pull to sync and the +commits after move, rename and delete. + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: Remove PasswordStore and what only it used + +**Files:** +- Delete: `app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt` +- Delete: `app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt` +- Delete: `app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt` +- Delete: `app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt` +- Delete: `app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt` +- Delete: `app/src/main/res/layout/activity_pwdstore.xml`, `item_create_sheet.xml`, `folder_dialog_fragment.xml` +- Delete: `app/src/main/res/menu/main_menu_git.xml`, `main_menu_no_auth.xml`, `main_menu_non_git.xml`, `context_pass.xml` +- Delete: `app/src/main/res/anim/scale_up.xml`, `scale_down.xml` +- Modify: `app/src/main/res/values/dimens.xml`, `app/src/main/res/values*/strings.xml` +- Modify: `app/src/main/AndroidManifest.xml` +- Modify: `app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt` +- Modify: `app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt` +- Modify: `app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt` + +Kept on purpose (still used by legacy screens): `PasswordItemRecyclerAdapter`, +`password_recycler_view.xml`, `password_row_layout.xml`, `SearchableRepositoryAdapter`, +`BasicBottomSheet`, the `swiperefreshlayout`, `recyclerview*` and fastscroll dependencies. + +- [ ] **Step 1: Autofill attribution intent goes through the launcher** + +In `AutofillViewUtils.kt`, replace the import `app.passwordstore.ui.passwords.PasswordStore` with +`app.passwordstore.ui.main.LaunchActivity`, and `Intent(context, PasswordStore::class.java)` with +`Intent(context, LaunchActivity::class.java)`. (Long-pressing an inline suggestion now passes +the biometric gate, instead of opening the list activity directly.) + +- [ ] **Step 2: Folder picker no longer borrows PasswordStore's constants** + +In `SelectFolderActivity.kt`, replace the body of `onCreate` with: + +```kotlin + super.onCreate(savedInstanceState) + passwordList = SelectFolderFragment() + supportActionBar?.show() + supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + supportFragmentManager.commit { replace(R.id.pgp_handler_linearlayout, passwordList) } +``` + +and delete the imports `app.passwordstore.data.repo.PasswordRepository`, +`app.passwordstore.ui.passwords.PASSWORD_FRAGMENT_TAG` and +`app.passwordstore.ui.passwords.PasswordStore`. (The fragment tag was never looked up.) + +In `SelectFolderFragment.kt`, replace + +```kotlin + val path = + requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) { + "Cannot navigate if ${PasswordStore.REQUEST_ARG_PATH} is not provided" + } + model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false) +``` + +with + +```kotlin + model.navigateTo( + PasswordRepository.getRepositoryDirectory(), + listMode = ListMode.DirectoriesOnly, + pushPreviousLocation = false, + ) +``` + +and replace the import `app.passwordstore.ui.passwords.PasswordStore` with +`app.passwordstore.data.repo.PasswordRepository`. (The only path ever passed was the store root.) + +- [ ] **Step 3: Manifest** + +In `app/src/main/AndroidManifest.xml`, delete: + +```xml + + +``` + +- [ ] **Step 4: Delete the files** + +```bash +git rm app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt \ + app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt \ + app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt \ + app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt \ + app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt \ + app/src/main/res/layout/activity_pwdstore.xml \ + app/src/main/res/layout/item_create_sheet.xml \ + app/src/main/res/layout/folder_dialog_fragment.xml \ + app/src/main/res/menu/main_menu_git.xml \ + app/src/main/res/menu/main_menu_no_auth.xml \ + app/src/main/res/menu/main_menu_non_git.xml \ + app/src/main/res/menu/context_pass.xml \ + app/src/main/res/anim/scale_up.xml \ + app/src/main/res/anim/scale_down.xml +``` + +- [ ] **Step 5: Delete resources only those files used** + +Lint runs with `UnusedResources` as an error, and a string left only in a translation is an +`ExtraTranslation` error, so remove them from every locale: + +```bash +for s in refresh_list new_folder_set_gpg_key dialog_skip message_category_error_category_exists edit; do + sed -i "//d" app/src/main/res/values*/strings.xml +done +sed -i '/name="bottom_sheet_item_height"/d' app/src/main/res/values/dimens.xml +grep -rnE 'name="(refresh_list|new_folder_set_gpg_key|dialog_skip|message_category_error_category_exists|edit|bottom_sheet_item_height)"' app/src/main/res +``` + +Expected: the final `grep` prints nothing (each of these strings is a single line in every +locale). + +- [ ] **Step 6: Nothing refers to the removed classes** + +Run: +```bash +grep -rnwE 'PasswordStore|PasswordFragment|ItemCreationBottomSheet|FolderCreationDialogFragment|OnOffItemAnimator|PASSWORD_FRAGMENT_TAG' app/src +``` +Expected: no output. + +- [ ] **Step 7: Full local check** + +Run: +```bash +./gradlew spotlessApply +CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug :app:assembleRelease \ + lint metalavaCheckCompatibilityRelease +``` +Expected: `BUILD SUCCESSFUL`. If lint reports further unused resources, they were used only by +the deleted files: delete them (strings from every `values*/strings.xml`) and re-run. If it +reports an issue in a file from Tasks 2 to 5, fix it there and include it in this commit. + +- [ ] **Step 8: Commit** + +```bash +git add -A app/src/main +git commit -m "refactor(app): remove the legacy PasswordStore list + +The Compose list replaces it. The autofill inline suggestion's +attribution intent now opens LaunchActivity, behind the biometric gate. + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: Emulator check and push + +No real store or keys: the e2e environment from AGENTS.md only. A physical phone may be +connected over USB: every `adb` and Gradle install command in this task targets the emulator +through `ANDROID_SERIAL`; never touch the phone. + +- [ ] **Step 1: Server, emulator, clean install** + +```bash +export ANDROID_SERIAL=emulator-5554 +scripts/e2e-env.sh up +$ANDROID_HOME/emulator/emulator -avd aps-test -no-window -no-audio -no-boot-anim \ + -no-snapshot-save -gpu swiftshader_indirect & +adb wait-for-device +until [ "$(adb shell getprop sys.boot_completed | tr -d '\r')" = 1 ]; do sleep 2; done +for s in animator_duration_scale transition_animation_scale window_animation_scale; do + adb shell settings put global $s 0; done +adb shell settings put secure autofill_service null +adb uninstall xyz.danix.hardenedpass.debug || true +./gradlew :app:installDebug +scripts/e2e-env.sh push-keys +``` + +(`ANDROID_HOME` is the `sdk.dir` from `local.properties`. `installDebug` and `adb` both honor +`ANDROID_SERIAL`.) If the emulator shows its lock screen, unlock it with its test PIN: +`adb shell input keyevent 82; adb shell input text 1234; adb shell input keyevent 66`. + +Helpers for this shell (read the screen with `uiautomator`, tap by any node attribute): + +```bash +ui() { adb exec-out uiautomator dump /dev/tty; } +center() { local b; b=$(ui | grep -o "]*$1[^>]*>" | head -1 | grep -o 'bounds="[^"]*"' | grep -oE '[0-9]+' | tr '\n' ' '); [ -n "$b" ] || { echo "not found: $1" >&2; return 1; }; set -- $b; echo "$(( ($1 + $3) / 2 )) $(( ($2 + $4) / 2 ))"; } +tap() { local c; c=$(center "$1") && adb shell input tap $c; sleep 1; } +hold() { local c; c=$(center "$1") && set -- $c && adb shell input swipe $1 $2 $1 $2 900; sleep 1; } +seen() { ui | grep -q "$1" && echo "ok: $1" || echo "MISSING: $1"; } +top() { adb shell dumpsys activity activities | grep topResumedActivity; } +open() { adb shell am start -n xyz.danix.hardenedpass.debug/app.passwordstore.ui.main.LaunchActivity; sleep 3; } +``` + +- [ ] **Step 2: Onboarding redirect and clone** + +`open`, then `seen "Let's Go"` (expected `ok`: the list sent a fresh install to onboarding). +Go through onboarding: "Let's Go", "Clone Remote Repo", Repository URL +`ssh://git@10.0.2.2:2222/srv/git/store.git`, authentication mode "SSH key", Save; import the +SSH key `aps-e2e-ssh-key` from Download when asked, accept the host key, then "Select key" and +import `aps-e2e-pgp-key.asc` from Download. +Expected afterwards: `top` shows `MainActivity`, and + +```bash +seen 'text="otp"'; seen 'text="work"'; seen 'text="example.org"'; seen 'content-desc="Password store"' +``` + +all print `ok` (two folders in the first group, the entry in the second, the root chip). + +- [ ] **Step 3: Folder navigation, breadcrumbs, back** + +```bash +tap 'text="work"'; seen 'text="mail.example.org"'; seen 'text="work"' # crumb "work" is shown +adb shell input keyevent 4; seen 'text="otp"' # back: up one level +tap 'text="work"'; tap 'content-desc="Password store"'; seen 'text="otp"' # root chip jumps up +adb shell input keyevent 4; sleep 1; adb shell dumpsys window | grep mCurrentFocus +``` +Expected: the last command shows a focus outside `xyz.danix.hardenedpass.debug` (back at the +root leaves the app). `open` again. + +- [ ] **Step 4: Search** + +```bash +tap 'text="Search"'; adb shell input text mail; sleep 1 +seen 'text="mail.example.org"'; seen 'text="work"' # result with its folder as subtitle +adb shell input keyevent 4; adb shell input keyevent 4; seen 'text="otp"' +``` +Expected: all `ok`; the first back hides the keyboard, the second clears the query and the full +root list returns. + +- [ ] **Step 5: Entry opens the legacy decrypt screen, list survives** + +`tap 'text="example.org"'`, enter the PGP passphrase `test` if asked, then `top`: expected +`DecryptActivity`. `ui | grep -o 'text="hunter2"'` should match after revealing the password +(the screen is `FLAG_SECURE`, so screenshots are black; read the dump). Press back: +`top` shows `MainActivity` and `seen 'text="otp"'` prints `ok` (the list was not finished). + +- [ ] **Step 6: Create entry through the legacy editor** + +```bash +tap 'text="New entry"'; tap 'text="Create new password"' +tap 'resource-id="xyz.danix.hardenedpass.debug:id/filename"'; adb shell input text e2e-new +tap 'resource-id="xyz.danix.hardenedpass.debug:id/password"'; adb shell input text s3cret-e2e +tap 'content-desc="Save"' || tap 'text="Save"' +sleep 2; top; seen 'text="e2e-new"' +``` +Expected: `MainActivity` on top and `ok: text="e2e-new"` (the list refreshed when the editor +returned). + +- [ ] **Step 7: Create folder** + +```bash +tap 'text="New entry"'; tap 'text="Create new folder"'; adb shell input text e2e-folder +tap 'text="Create"'; seen 'text="e2e-folder"' +adb shell input keyevent 4; seen 'text="e2e-folder"' +``` +Expected: the list opens the new folder (crumb `e2e-folder`), back returns to the root, where the +folder is listed with count `0`. + +- [ ] **Step 8: Multi-select move, then delete** + +```bash +hold 'text="e2e-new"'; seen 'text="1 item selected"' +tap 'content-desc="Move"' # legacy folder picker +tap 'text="work"'; tap 'content-desc="Select"' || tap 'text="Select"' +sleep 2; seen 'text="e2e-new"' # expected MISSING at the root now +tap 'text="work"'; seen 'text="e2e-new"' +hold 'text="e2e-new"'; tap 'content-desc="Delete"'; tap 'text="Yes"' +sleep 2; seen 'text="e2e-new"' # expected MISSING +adb shell input keyevent 4 +``` +Expected: as commented. Also check rotation keeps the folder: in `work`, run +`adb shell settings put system accelerometer_rotation 0; adb shell settings put system user_rotation 1; sleep 2; seen 'text="mail.example.org"'; adb shell settings put system user_rotation 0`. + +- [ ] **Step 9: Pull to sync pushes the commits** + +```bash +adb shell input swipe 540 700 540 1800 400; sleep 8 +seen 'text="otp"' +docker exec -u git aps-e2e-git git -C /srv/git/store.git log --oneline +``` +Expected: no error dialog; the server log shows, above `Initial test store`, the commit that +added `e2e-new`, `Rename e2e-new to work/e2e-new.` and `Remove work/e2e-new.gpg from store.` + +- [ ] **Step 10: Tear down and push** + +```bash +adb uninstall xyz.danix.hardenedpass.debug +adb shell rm -f /sdcard/Download/aps-e2e-ssh-key /sdcard/Download/aps-e2e-pgp-key.asc +adb emu kill +scripts/e2e-env.sh down +unset ANDROID_SERIAL +git push +``` + +Expected: push succeeds to every `origin` push URL. + +--- + +## Self-review notes + +- **Spec coverage for step 2:** `ListRoute` is a real Compose screen (Tasks 4, 5); pill search + bar (Material 3 `SearchBar`), breadcrumb chips with jumps, back pops one folder and leaves at + the root (Tasks 2, 5); folders and entries in two `GroupedSurface`s, folder counts, letter + avatars, subtitle decision recorded (Tasks 2, 4); pull to sync (Task 5); long-press + multi-select with move and delete, plus rename and pin for parity (Tasks 3, 4, 5); extended + FAB "New entry" opening the create sheet (Tasks 4, 5); `hilt-navigation-compose` added for + `hiltViewModel()` (Task 1); `SearchableRepositoryViewModel` reused with one added flow + (Task 2); results from legacy activities refresh the list through the resume refresh (Task 5); + `PasswordStore` and its fragment, dialogs, layouts and menus deleted, shared leftovers listed + for steps 4, 7 and 8 (Task 6); TDD for the pure logic (Task 2); emulator check covering + onboarding clone, browse, search, folders and back, pull to sync, create entry and folder, move + and delete (Task 7); full local CI (Task 6). +- **Parity table summary:** ported: onboarding redirect, folders and breadcrumbs, search with + its settings, search-on-start, git menu (sync, pull, push, by auth mode), settings, pull to + sync with all its branches, entry open plus dynamic shortcut, recently-used tracking, folder + counts, move, delete, rename (one folder at a time), pin, create entry (legacy editor), create + folder. Kept as legacy intents: decrypt, editor, folder picker, settings, onboarding, clone. + Dropped: `REPO_CHANGED` and `matchWith` (dead), "Refresh list" (automatic now), + type-to-search and Ctrl+F (search always visible), fast scroller and item animations. +- **Spec deviations, recorded in the spec by Task 5:** `MainActivity` extends `BaseGitActivity`; + no username or OTP subtitle. +- **Open questions for the maintainer:** + 1. Sort order "files first" and "independent" lose their meaning now that folders always sit + in their own group above entries (the spec layout). This plan ignores the difference; + should step 6 drop those two choices from Settings, or should "files first" put the entries + group above the folders group? + 2. Hardware-keyboard type-to-search and Ctrl+F are dropped. Do you use a keyboard with the + phone or tablet? Restoring them is a small `onPreviewKeyEvent` on the screen. + 3. The "Refresh list" menu item is dropped (the list re-reads on every return and after every + action). Keep it dropped? + +## Maintainer decisions (2026-11-02) + +- Sort order: the "files first" and "independent" options are removed when Settings is ported + (spec step 6). Folders always sit above entries. Nothing to do in this plan. +- Hardware keyboard type-to-search and Ctrl+F: dropped. +- "Refresh list" menu item: dropped. -- cgit v1.2.3 From a08ced744fbd7966f58ffa32da6d37bebf166619 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 21:24:28 +0100 Subject: build(app): add hilt-navigation-compose and lifecycle-runtime-compose Co-Authored-By: Claude Opus 5.5 --- app/build.gradle.kts | 2 ++ gradle/libs.versions.toml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a8cac83e..cc10d21e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,8 +64,10 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.documentfile) implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.hilt.navigation.compose) implementation(libs.bundles.androidxLifecycle) implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.material) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.preference) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ad9422a8..9d8f4111 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,8 +22,10 @@ androidx-constraintlayout = "androidx.constraintlayout:constraintlayout:2.2.2" androidx-core-ktx = "androidx.core:core-ktx:1.13.1" androidx-documentfile = "androidx.documentfile:documentfile:1.1.0" androidx-fragment-ktx = "androidx.fragment:fragment-ktx:1.8.4" +androidx-hilt-navigation-compose = "androidx.hilt:hilt-navigation-compose:1.2.0" androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "lifecycle" } androidx-lifecycle-livedataKtx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodelKtx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-navigation-compose = "androidx.navigation:navigation-compose:2.8.9" -- cgit v1.2.3 From 58bf4426d3dc3db51089bd6fd4af1e382040a5c6 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 21:25:16 +0100 Subject: feat(app): add the list rows and folder navigation model Co-Authored-By: Claude Opus 5.5 --- .../java/app/passwordstore/ui/list/ListModel.kt | 61 ++++++++++++++ .../viewmodel/SearchableRepositoryViewModel.kt | 7 ++ .../app/passwordstore/ui/list/ListModelTest.kt | 93 ++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/list/ListModel.kt create mode 100644 app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt diff --git a/app/src/main/java/app/passwordstore/ui/list/ListModel.kt b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt new file mode 100644 index 00000000..9154baa5 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import app.passwordstore.ui.compose.crumbsFor +import java.io.File +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +/** + * One list row. [path] is the absolute path and the row's key; [count] is a folder's number of + * children, 0 for entries. + */ +data class ListRow(val path: String, val label: String, val subtitle: String?, val count: Int) + +/** The rows of one folder, or of a search inside it: folders above entries. */ +data class ListRows( + val folders: ImmutableList = persistentListOf(), + val entries: ImmutableList = persistentListOf(), +) + +/** + * Splits [files] (already sorted or ranked) into folder and entry rows for [dir], keeping their + * order. Labels and subtitles are relative to [dir]: browsing shows plain names, search results + * show where they are. Entries get no username or OTP subtitle: both live inside the encrypted + * file, and the list never decrypts. + */ +fun listRows(files: List, dir: File): ListRows { + val (folders, entries) = files.partition { it.isDirectory } + return ListRows( + folders + .map { ListRow(it.absolutePath, it.relativeTo(dir).path, null, childCount(it)) } + .toImmutableList(), + entries + .map { + val parent = it.parentFile?.relativeTo(dir)?.path.orEmpty() + ListRow(it.absolutePath, it.name.removeSuffix(".gpg"), parent.ifEmpty { null }, 0) + } + .toImmutableList(), + ) +} + +/** Children counted for a folder: subfolders and .gpg files, as the old list counted them. */ +private fun childCount(folder: File): Int = + folder.listFiles { f -> f.isDirectory || f.extension == "gpg" }?.size ?: 0 + +/** + * How to get from the list at [from] to the list at [to] (both relative to the store root) while + * the back stack holds one entry per folder level: how many entries to pop, then which paths to + * push. Breadcrumb jumps only pop; opening a folder from search results may pop and push. + */ +fun folderMoves(from: String, to: String): Pair> { + val current = crumbsFor(from) + val target = crumbsFor(to) + val shared = current.zip(target).takeWhile { (a, b) -> a == b }.size + return (current.size - shared) to target.drop(shared).map { it.path } +} diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt index 48804e7a..ca570dc4 100644 --- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -24,6 +24,7 @@ import androidx.recyclerview.widget.RecyclerView import app.passwordstore.data.password.PasswordItem import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.ui.list.listRows import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.checkMainThread import app.passwordstore.util.coroutines.DispatcherProvider @@ -302,6 +303,12 @@ constructor( private val _currentDir = MutableStateFlow(root) val currentDir = _currentDir.asStateFlow() + /** [searchResult] as folder and entry rows for the Compose list, built off the main thread. */ + val rows = + searchResult + .map { result -> listRows(result.passwordItems.map { it.file }, currentDir.value) } + .flowOn(dispatcherProvider.io()) + data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?) private val navigationStack = ArrayDeque() diff --git a/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt new file mode 100644 index 00000000..60e3020a --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class ListModelTest { + + private val root = Files.createTempDirectory("aps").toFile() + + @AfterTest + fun cleanUp() { + root.deleteRecursively() + } + + private fun file(path: String) = + File(root, path).apply { + parentFile!!.mkdirs() + writeText("x") + } + + private fun folder(path: String) = File(root, path).apply { mkdirs() } + + @Test + fun openingAChildFolderPushesIt() { + assertEquals(0 to listOf("work"), folderMoves("", "work")) + } + + @Test + fun breadcrumbJumpsOnlyPop() { + assertEquals(1 to emptyList(), folderMoves("work/servers", "work")) + assertEquals(2 to emptyList(), folderMoves("work/servers", "")) + } + + @Test + fun searchResultsRebuildTheStackFromTheSharedParent() { + assertEquals(1 to listOf("personal", "personal/mail"), folderMoves("work", "personal/mail")) + assertEquals( + 0 to listOf("work/servers", "work/servers/eu"), + folderMoves("work", "work/servers/eu"), + ) + } + + @Test + fun sameFolderMovesNothing() { + assertEquals(0 to emptyList(), folderMoves("work", "work")) + } + + @Test + fun browsingShowsNamesAndFolderCounts() { + val work = folder("work") + file("work/mail.gpg") + file("work/notes.txt") + folder("work/servers") + val site = file("site.gpg") + + val rows = listRows(listOf(work, site), root) + + assertEquals(listOf(ListRow(work.absolutePath, "work", null, 2)), rows.folders) + assertEquals(listOf(ListRow(site.absolutePath, "site", null, 0)), rows.entries) + } + + @Test + fun searchResultsShowWhereTheyAre() { + val servers = folder("work/servers") + val vpn = file("work/vpn.gpg") + + val rows = listRows(listOf(vpn, servers), root) + + assertEquals(listOf(ListRow(servers.absolutePath, "work/servers", null, 0)), rows.folders) + assertEquals(listOf(ListRow(vpn.absolutePath, "vpn", "work", 0)), rows.entries) + } + + @Test + fun keepsTheGivenOrderInsideEachGroup() { + val b = file("b.gpg") + val a = file("a.gpg") + val z = folder("z") + val y = folder("y") + + val rows = listRows(listOf(b, z, a, y), root) + + assertEquals(listOf(z.absolutePath, y.absolutePath), rows.folders.map { it.path }) + assertEquals(listOf(b.absolutePath, a.absolutePath), rows.entries.map { it.path }) + } +} -- cgit v1.2.3 From e899247e8d75a15d529eb5f244f2abd9fdf5edce Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 21:26:13 +0100 Subject: feat(app): port the list's store operations out of PasswordStore A move now waits for the overwrite answer before it commits, and new folder names must stay inside the store. Co-Authored-By: Claude Opus 5.5 --- .../java/app/passwordstore/ui/list/StoreActions.kt | 159 +++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/list/StoreActions.kt diff --git a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt new file mode 100644 index 00000000..6b31f652 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import android.content.Context +import androidx.core.content.edit +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.crypto.BasePGPActivity.Companion.getLongName +import app.passwordstore.ui.git.base.BaseGitActivity +import app.passwordstore.util.autofill.AutofillMatcher +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.listFilesRecursively +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.PasswordSortOrder +import app.passwordstore.util.settings.PreferenceKeys +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.io.File +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.logcat + +/** Shared preferences file of the "recently used" sort order, keyed by base64 of the path. */ +private const val HISTORY = "recent_password_history" + +/** Password files in [files], counting a folder by everything below it. */ +fun countFiles(files: List): Int = + files.sumOf { if (it.isFile) 1 else it.listFilesRecursively().size } + +/** Why [name] cannot become the folder [target], as a string resource, or null when it can. */ +fun folderNameError(name: String, target: File): Int? = + when { + name.isBlank() -> R.string.message_category_error_empty_field + target.isFile -> R.string.folder_creation_err_file_exists + target.isDirectory -> R.string.folder_creation_err_folder_exists + !target.isInsideRepository() -> R.string.message_error_destination_outside_repo + else -> null + } + +/** Records that [file] was opened, for the "recently used" sort order. */ +fun Context.recordUse(file: File) { + if (sharedPrefs.getString(PreferenceKeys.SORT_ORDER) != PasswordSortOrder.RECENTLY_USED.name) + return + getSharedPreferences(HISTORY, Context.MODE_PRIVATE).edit { + putString(file.absolutePath.base64(), System.currentTimeMillis().toString()) + } +} + +/** Deletes [files] (entries or whole folders), drops their autofill matches and commits. */ +suspend fun BaseGitActivity.deleteItems(files: List) { + val root = PasswordRepository.getRepositoryDirectory() + val deleted = files.flatMap { if (it.isDirectory) it.listFilesRecursively() else listOf(it) } + files.forEach { it.deleteRecursively() } + AutofillMatcher.updateMatches(applicationContext, delete = deleted) + commitChange( + getString( + R.string.git_commit_remove_text, + files.joinToString(", ") { it.toRelativeString(root) }, + ) + ) +} + +/** Moves [files] into the folder [target], asking before overwriting, then commits. */ +suspend fun BaseGitActivity.moveItems(files: List, target: File) { + val repo = PasswordRepository.getRepositoryDirectory().absolutePath + for (source in files) { + if (!source.exists()) { + logcat(ERROR) { "Tried moving something that appears non-existent." } + continue + } + val destination = File(target, source.name) + val name = source.nameWithoutExtension + if ( + destination.exists() && + !confirm( + getString(R.string.password_exists_title), + getString( + R.string.password_exists_message, + getLongName(target.absolutePath, repo, name), + getLongName(source.parent.orEmpty(), repo, name), + ), + ) + ) + continue + moveFile(source, destination) + } + val message = + if (files.size == 1) { + val name = files[0].nameWithoutExtension + getString( + R.string.git_commit_move_text, + getLongName(files[0].parent.orEmpty(), repo, name), + getLongName(target.absolutePath, repo, name), + ) + } else { + val relative = "${target.absolutePath}/".replace(repo, "").replace("/+".toRegex(), "/") + getString(R.string.git_commit_move_multiple_text, relative) + } + commitChange(message) +} + +/** Renames the folder [from] to [to], keeps its recently-used time, then commits. */ +suspend fun BaseGitActivity.renameFolder(from: File, to: File) { + moveFile(from, to) + val history = getSharedPreferences(HISTORY, Context.MODE_PRIVATE) + val time = history.getString(from.absolutePath.base64()) + if (time != null) + history.edit { + remove(from.absolutePath.base64()) + putString(to.absolutePath.base64(), time) + } + commitChange(getString(R.string.git_commit_move_text, from.name, to.name)) +} + +private suspend fun BaseGitActivity.moveFile(source: File, destination: File) { + val moved = + withContext(dispatcherProvider.io()) { + val map = + if (source.isDirectory) { + destination.mkdirs() + source.listFilesRecursively().associateWith { destination.resolve(it.relativeTo(source)) } + } else { + mapOf(source to destination) + } + source.renameTo(destination).also { ok -> + if (ok) AutofillMatcher.updateMatches(this@moveFile, map) + } + } + if (!moved) { + logcat(ERROR) { "Something went wrong while moving $source to $destination." } + // ponytail: View dialog like BaseGitActivity's git prompts; both move to Compose together. + MaterialAlertDialogBuilder(this) + .setTitle(R.string.password_move_error_title) + .setMessage(getString(R.string.password_move_error_message, source, destination)) + .setPositiveButton(android.R.string.ok, null) + .show() + } +} + +/** Shows [message] under [title] and suspends until answered; true when confirmed. */ +// ponytail: View dialog, see moveFile. +private suspend fun Context.confirm(title: String, message: String): Boolean = + suspendCancellableCoroutine { cont -> + MaterialAlertDialogBuilder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.dialog_ok) { _, _ -> cont.resume(true) } + .setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(false) } + .setOnCancelListener { cont.resume(false) } + .show() + } -- cgit v1.2.3 From 6c2ee8112c2ea0d1eb40d07698813900b78af36b Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 21:27:28 +0100 Subject: feat(app): add the Compose password list screen Co-Authored-By: Claude Opus 5.5 --- .../java/app/passwordstore/ui/list/ListScreen.kt | 314 +++++++++++++++++++++ app/src/main/res/values-it/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + 3 files changed, 326 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/list/ListScreen.kt diff --git a/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt new file mode 100644 index 00000000..5a39cb5c --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt @@ -0,0 +1,314 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +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.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.passwordstore.R +import app.passwordstore.ui.compose.BreadcrumbBar +import app.passwordstore.ui.compose.EntryRow +import app.passwordstore.ui.compose.FolderRow +import app.passwordstore.ui.compose.GroupedSurface +import app.passwordstore.ui.compose.crumbsFor +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.ui.git.base.BaseGitActivity.GitOp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet + +/** + * The password list: search bar, breadcrumbs, folders above entries, pull to sync, long-press + * selection and the "New entry" button. Row callbacks take absolute paths; [onCrumb] takes a path + * relative to the store root, like [path]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListScreen( + path: String, + query: String, + rows: ListRows, + selected: ImmutableSet, + refreshing: Boolean, + canSync: Boolean, + gitOps: ImmutableList, + onQueryChange: (String) -> Unit, + onCrumb: (relativePath: String) -> Unit, + onOpenFolder: (path: String) -> Unit, + onOpenEntry: (path: String) -> Unit, + onToggle: (path: String) -> Unit, + onClearSelection: () -> Unit, + onSync: () -> Unit, + onGitOp: (GitOp) -> Unit, + onSettings: () -> Unit, + onNew: () -> Unit, + onMove: () -> Unit, + onDelete: () -> Unit, + onRename: () -> Unit, + onPin: () -> Unit, + modifier: Modifier = Modifier, + searchFocus: FocusRequester = remember { FocusRequester() }, +) { + val selecting = selected.isNotEmpty() + val listState = rememberLazyListState() + val pullState = rememberPullToRefreshState() + // Filtered results always start at the top, where the best match is. + LaunchedEffect(query) { if (query.isNotEmpty()) listState.scrollToItem(0) } + Scaffold( + modifier = modifier, + topBar = { + if (selecting) { + SelectionBar(rows, selected, onClearSelection, onMove, onDelete, onRename, onPin) + } else { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + SearchField(query, gitOps, onQueryChange, onGitOp, onSettings, searchFocus) + BreadcrumbBar( + crumbsFor(path), + stringResource(R.string.list_store_root), + onCrumb, + Modifier.fillMaxWidth().padding(vertical = SpacingMedium), + ) + } + } + }, + floatingActionButton = { + if (!selecting) + ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.list_new_entry)) }, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + onClick = onNew, + ) + }, + ) { padding -> + Box( + Modifier.padding(padding) + .fillMaxSize() + .pullToRefresh(refreshing, pullState, enabled = canSync && !selecting, onRefresh = onSync) + ) { + // ponytail: each group is one lazy item, fine for hundreds of rows; split rows into their + // own items (with per-row corner shapes) if a store ever has thousands in one folder. + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 96.dp), + ) { + if (rows.folders.isNotEmpty()) + item(key = "folders") { + GroupedSurface(Modifier.fillMaxWidth()) { + rows.folders.forEach { row -> + key(row.path) { + FolderRow( + name = row.label, + count = row.count, + onClick = { if (selecting) onToggle(row.path) else onOpenFolder(row.path) }, + selected = row.path in selected, + onLongClick = { onToggle(row.path) }, + ) + } + } + } + } + if (rows.entries.isNotEmpty()) + item(key = "entries") { + GroupedSurface(Modifier.fillMaxWidth()) { + rows.entries.forEach { row -> + key(row.path) { + EntryRow( + name = row.label, + subtitle = row.subtitle, + onClick = { if (selecting) onToggle(row.path) else onOpenEntry(row.path) }, + selected = row.path in selected, + onLongClick = { onToggle(row.path) }, + ) + } + } + } + } + } + PullToRefreshDefaults.Indicator(pullState, refreshing, Modifier.align(Alignment.TopCenter)) + } + } +} + +/** Bottom sheet behind the "New entry" button: a password entry or a folder. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateSheet( + onDismiss: () -> Unit, + onNewEntry: () -> Unit, + onNewFolder: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet(onDismissRequest = onDismiss, modifier = modifier) { + ListItem( + headlineContent = { Text(stringResource(R.string.bottom_sheet_create_new_password)) }, + modifier = Modifier.clickable(onClick = onNewEntry), + leadingContent = { + Icon(painterResource(R.drawable.ic_action_new_password), contentDescription = null) + }, + ) + ListItem( + headlineContent = { Text(stringResource(R.string.bottom_sheet_create_new_folder)) }, + modifier = Modifier.clickable(onClick = onNewFolder), + leadingContent = { + Icon(painterResource(R.drawable.ic_action_new_folder), contentDescription = null) + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchField( + query: String, + gitOps: ImmutableList, + onQueryChange: (String) -> Unit, + onGitOp: (GitOp) -> Unit, + onSettings: () -> Unit, + focus: FocusRequester, +) { + val focusManager = LocalFocusManager.current + var menu by remember { mutableStateOf(false) } + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = { focusManager.clearFocus() }, + expanded = false, + onExpandedChange = {}, + modifier = Modifier.focusRequester(focus), + placeholder = { Text(stringResource(R.string.action_search)) }, + leadingIcon = { + Icon(painterResource(R.drawable.ic_search_24dp), contentDescription = null) + }, + trailingIcon = { + Row { + if (query.isNotEmpty()) + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Filled.Close, stringResource(R.string.list_clear_search)) + } + Box { + IconButton(onClick = { menu = true }) { + Icon(Icons.Filled.MoreVert, stringResource(R.string.list_more_options)) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + gitOps.forEach { op -> + DropdownMenuItem( + text = { Text(stringResource(op.label)) }, + onClick = { + menu = false + onGitOp(op) + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.action_settings)) }, + onClick = { + menu = false + onSettings() + }, + ) + } + } + } + }, + ) + }, + expanded = false, + onExpandedChange = {}, + ) {} +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelectionBar( + rows: ListRows, + selected: ImmutableSet, + onClear: () -> Unit, + onMove: () -> Unit, + onDelete: () -> Unit, + onRename: () -> Unit, + onPin: () -> Unit, +) { + val single = selected.singleOrNull() + TopAppBar( + title = { Text(pluralStringResource(R.plurals.delete_title, selected.size, selected.size)) }, + navigationIcon = { + IconButton(onClick = onClear) { + Icon(Icons.Filled.Close, stringResource(R.string.dialog_cancel)) + } + }, + actions = { + ActionIcon(R.drawable.ic_move_folder_24dp, R.string.move, onMove) + ActionIcon(R.drawable.ic_delete_24dp, R.string.delete, onDelete) + // ponytail: rename one folder at a time; the old list queued one dialog per folder. + if (rows.folders.any { it.path == single }) + ActionIcon(R.drawable.ic_edit_24dp, R.string.title_rename_folder, onRename) + if (rows.entries.any { it.path == single }) + ActionIcon(R.drawable.ic_push_pin_24dp, R.string.place_shortcut_on_home_screen, onPin) + }, + ) +} + +@Composable +private fun ActionIcon(@DrawableRes icon: Int, @StringRes label: Int, onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon(painterResource(icon), contentDescription = stringResource(label)) + } +} + +private val GitOp.label: Int + get() = + when (this) { + GitOp.SYNC -> R.string.git_sync + GitOp.PUSH -> R.string.git_push + else -> R.string.git_pull + } diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index dfb18d52..95893b17 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -351,4 +351,10 @@ Nell\'app non è ancora stata importata nessuna chiave PGP: premi il pulsante qui sotto per scegliere un file di chiave Sblocca le frasi segrete memorizzate Cancella la password memorizzata allo spegnimento dello schermo + + + Nuova voce + Archivio password + Altre opzioni + Cancella ricerca diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 07f24aa5..b280e9f2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -385,4 +385,10 @@ There are no PGP keys imported in the app yet, press the button below to pick a key file Unlock passphrase cache Clear cached password on screen-off + + + New entry + Password store + More options + Clear search -- cgit v1.2.3 From 32ee99b3243da8c55428538b326b159d03f93f6c Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 21:28:50 +0100 Subject: feat(app): show the Compose password list in MainActivity Folders are back stack entries (ListRoute(path)), so back goes up one level. MainActivity extends BaseGitActivity for pull to sync and the commits after move, rename and delete. Co-Authored-By: Claude Opus 5.5 --- app/src/main/AndroidManifest.xml | 6 +- .../app/passwordstore/ui/list/ListDestination.kt | 352 +++++++++++++++++++++ .../java/app/passwordstore/ui/main/MainActivity.kt | 111 ++++++- app/src/main/res/values/themes_material3.xml | 6 + .../specs/2026-11-02-compose-ui-overhaul-design.md | 10 +- 5 files changed, 467 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/ui/list/ListDestination.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 457647d5..5c54c683 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -66,7 +66,9 @@ + android:exported="false" + android:theme="@style/NoActionBarThemeM3" + android:windowSoftInputMode="adjustResize" /> + android:parentActivityName=".ui.main.MainActivity" /> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import android.app.Activity +import android.content.Intent +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.ui.folderselect.SelectFolderActivity +import app.passwordstore.ui.git.base.BaseGitActivity.GitOp +import app.passwordstore.ui.git.config.GitServerConfigActivity +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.PreferenceKeys +import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel +import java.io.File +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.launch + +private enum class ListDialog { + None, + Create, + NewFolder, + Rename, + Delete, + Clone, + NotInitialized, +} + +/** + * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the + * legacy activities and the store operations. [onOpenFolder] takes a path relative to the root. + */ +@Composable +fun ListDestination( + activity: MainActivity, + path: String, + onOpenFolder: (relativePath: String) -> Unit, + modifier: Modifier = Modifier, + viewModel: SearchableRepositoryViewModel = hiltViewModel(), +) { + val root = PasswordRepository.getRepositoryDirectory() + val dir = File(root, path) + val prefs = activity.sharedPrefs + var query by rememberSaveable { mutableStateOf("") } + var selected by rememberSaveable { mutableStateOf(listOf()) } + var dialog by rememberSaveable { mutableStateOf(ListDialog.None) } + var deleteCount by rememberSaveable { mutableIntStateOf(0) } + var refreshing by remember { mutableStateOf(false) } + var resumes by remember { mutableIntStateOf(0) } + val searchFocus = remember { FocusRequester() } + + // Declared before the collection below on purpose: effects start in order, so the ViewModel + // already points at this folder when the first rows are built (no flash of the store root). + LaunchedEffect(path, query) { + viewModel.navigateTo(dir, pushPreviousLocation = false) + if (query.isNotBlank()) viewModel.search(query.trim()) + } + val rows by viewModel.rows.collectAsStateWithLifecycle(ListRows()) + val gitOps = remember(resumes) { activity.gitOps() } + val canSync = remember(resumes) { !prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false) } + + LaunchedEffect(Unit) { + if (prefs.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) searchFocus.requestFocus() + } + // Every return to this folder (back from a child, or from a legacy activity) re-reads it. + LifecycleResumeEffect(Unit) { + if (activity.checkStore()) { + if (path.isNotEmpty() && !dir.isDirectory) onOpenFolder("") else viewModel.forceRefresh() + } + resumes++ + onPauseOrDispose {} + } + BackHandler(enabled = selected.isNotEmpty()) { selected = emptyList() } + BackHandler(enabled = selected.isEmpty() && query.isNotEmpty()) { query = "" } + + val moveLauncher = + rememberLauncherForActivityResult(StartActivityForResult()) { result -> + val data = result.data + val target = data?.getStringExtra("SELECTED_FOLDER_PATH")?.let(::File) + val files = data?.getStringArrayExtra("Files")?.map(::File) + if (result.resultCode == Activity.RESULT_OK && target?.isDirectory == true && files != null) { + selected = emptyList() + activity.lifecycleScope.launch { + activity.moveItems(files, target) + viewModel.forceRefresh() + } + } + } + + fun ifInitialized(action: () -> Unit) { + if (PasswordRepository.isInitialized) action() else dialog = ListDialog.NotInitialized + } + + fun runGit(op: GitOp) { + refreshing = true + activity.lifecycleScope.launch { + activity.runGit(op) + refreshing = false + viewModel.forceRefresh() + } + } + + Box(modifier) { + ListScreen( + path = path, + query = query, + rows = rows, + selected = selected.toImmutableSet(), + refreshing = refreshing, + canSync = canSync, + gitOps = gitOps, + onQueryChange = { query = it }, + onCrumb = onOpenFolder, + onOpenFolder = { folder -> + activity.recordUse(File(folder)) + query = "" + onOpenFolder(File(folder).relativeTo(root).path) + }, + onOpenEntry = { entry -> + activity.recordUse(File(entry)) + activity.openEntry(File(entry)) + }, + onToggle = { p -> selected = if (p in selected) selected - p else selected + p }, + onClearSelection = { selected = emptyList() }, + onSync = { + when { + root.resolve(".git").listFiles().isNullOrEmpty() -> viewModel.forceRefresh() + !PasswordRepository.isGitRepo() -> dialog = ListDialog.Clone + activity.gitSettings.authMode == AuthMode.None -> runGit(GitOp.PULL) + else -> runGit(GitOp.SYNC) + } + }, + onGitOp = { op -> ifInitialized { runGit(op) } }, + onSettings = activity::openSettings, + onNew = { dialog = ListDialog.Create }, + onMove = { + moveLauncher.launch( + Intent(activity, SelectFolderActivity::class.java) + .putExtra("Files", selected.toTypedArray()) + ) + }, + onDelete = { + val files = selected.map(::File) + val count = countFiles(files) + if (count == 0) { + files.forEach { it.deleteRecursively() } + selected = emptyList() + viewModel.forceRefresh() + } else { + deleteCount = count + dialog = ListDialog.Delete + } + }, + onRename = { dialog = ListDialog.Rename }, + onPin = { + activity.pinEntry(File(selected.single())) + selected = emptyList() + }, + searchFocus = searchFocus, + ) + + when (dialog) { + ListDialog.None -> Unit + ListDialog.Create -> + CreateSheet( + onDismiss = { dialog = ListDialog.None }, + onNewEntry = { + dialog = ListDialog.None + ifInitialized { activity.createEntry(dir) } + }, + onNewFolder = { + dialog = ListDialog.None + ifInitialized { dialog = ListDialog.NewFolder } + }, + ) + ListDialog.NewFolder -> + FolderNameDialog( + title = stringResource(R.string.title_create_folder), + message = null, + confirm = stringResource(R.string.button_create), + validate = { folderNameError(it, File(dir, it)) }, + onConfirm = { name -> + dialog = ListDialog.None + val folder = File(dir, name).canonicalFile + folder.mkdirs() + onOpenFolder(folder.relativeTo(root.canonicalFile).path) + }, + onDismiss = { dialog = ListDialog.None }, + ) + ListDialog.Rename -> { + val folder = File(selected.single()) + FolderNameDialog( + title = stringResource(R.string.title_rename_folder), + message = stringResource(R.string.message_rename_folder, folder.name), + confirm = stringResource(R.string.dialog_ok), + validate = { folderNameError(it, File(folder.parentFile, it)) }, + onConfirm = { name -> + dialog = ListDialog.None + selected = emptyList() + activity.lifecycleScope.launch { + activity.renameFolder(folder, File(folder.parentFile, name)) + viewModel.forceRefresh() + } + }, + onDismiss = { dialog = ListDialog.None }, + ) + } + ListDialog.Delete -> + Prompt( + text = pluralStringResource(R.plurals.delete_dialog_text, deleteCount, deleteCount), + confirm = stringResource(R.string.dialog_yes), + onConfirm = { + val files = selected.map(::File) + dialog = ListDialog.None + selected = emptyList() + activity.lifecycleScope.launch { + activity.deleteItems(files) + viewModel.forceRefresh() + } + }, + onDismiss = { dialog = ListDialog.None }, + dismiss = stringResource(R.string.dialog_no), + ) + ListDialog.Clone -> + Prompt( + text = stringResource(R.string.clone_git_repo), + confirm = stringResource(R.string.clone_button), + onConfirm = { + dialog = ListDialog.None + activity.startActivity(GitServerConfigActivity.createCloneIntent(activity)) + }, + onDismiss = { dialog = ListDialog.None }, + dismiss = stringResource(R.string.dialog_cancel), + ) + ListDialog.NotInitialized -> + Prompt( + text = stringResource(R.string.creation_dialog_text), + confirm = stringResource(R.string.dialog_ok), + onConfirm = { dialog = ListDialog.None }, + onDismiss = { dialog = ListDialog.None }, + ) + } + } +} + +/** Asks for a folder name; [validate] returns an error string resource or null. */ +@Composable +private fun FolderNameDialog( + title: String, + message: String?, + confirm: String, + validate: (String) -> Int?, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var name by rememberSaveable { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + val focus = remember { FocusRequester() } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + val err = error + Column { + if (message != null) Text(message, Modifier.padding(bottom = SpacingMedium)) + OutlinedTextField( + value = name, + onValueChange = { + name = it + error = null + }, + modifier = Modifier.focusRequester(focus), + label = { Text(stringResource(R.string.crypto_name_hint)) }, + supportingText = + if (err != null) { + { Text(stringResource(err)) } + } else null, + isError = err != null, + singleLine = true, + ) + } + LaunchedEffect(Unit) { focus.requestFocus() } + }, + confirmButton = { + TextButton( + onClick = { + val e = validate(name) + error = e + if (e == null) onConfirm(name) + } + ) { + Text(confirm) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +/** A one-message dialog; [dismiss] adds a second button that only closes it. */ +@Composable +private fun Prompt( + text: String, + confirm: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + dismiss: String? = null, +) { + AlertDialog( + onDismissRequest = onDismiss, + text = { Text(text) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } }, + dismissButton = + if (dismiss != null) { + { TextButton(onClick = onDismiss) { Text(dismiss) } } + } else null, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt index 48546e00..5877ae07 100644 --- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt @@ -6,49 +6,81 @@ package app.passwordstore.ui.main import android.app.Activity +import android.content.ComponentName import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.fragment.app.FragmentActivity import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute +import app.passwordstore.data.password.PasswordItem import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.ui.compose.theme.APSTheme import app.passwordstore.ui.crypto.BasePGPActivity import app.passwordstore.ui.crypto.DecryptActivity -import app.passwordstore.ui.passwords.PasswordStore +import app.passwordstore.ui.crypto.PasswordCreationActivity +import app.passwordstore.ui.git.base.BaseGitActivity +import app.passwordstore.ui.list.ListDestination +import app.passwordstore.ui.list.folderMoves +import app.passwordstore.ui.onboarding.activity.OnboardingActivity +import app.passwordstore.ui.settings.SettingsActivity +import app.passwordstore.util.extensions.launchActivity +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.PreferenceKeys +import app.passwordstore.util.shortcuts.ShortcutHandler +import com.github.michaelbull.result.onFailure import dagger.hilt.android.AndroidEntryPoint +import java.io.File +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.serialization.Serializable -/** The password list, at the store root. */ -@Serializable data object ListRoute +/** + * The password list of the folder at [path], relative to the store root ("" is the root). The back + * stack holds one entry per folder level. + */ +@Serializable data class ListRoute(val path: String = "") /** One entry. [filePath] was checked with [LaunchActivity.isFileInRepo] before routing here. */ @Serializable data class EntryRoute(val filePath: String) /** * Single-activity host for the Compose UI. Not exported: it is only started by [LaunchActivity], - * after its biometric gate and path check. Extends [FragmentActivity] only because BiometricPrompt - * needs one; it hosts no fragments. + * after its biometric gate and path check. Extends [BaseGitActivity] (an AppCompatActivity, so also + * the FragmentActivity BiometricPrompt needs) because the git operations require one; it hosts no + * fragments. */ @AndroidEntryPoint -class MainActivity : FragmentActivity() { +class MainActivity : BaseGitActivity() { + + @Inject lateinit var shortcutHandler: ShortcutHandler override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val filePath = intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH) - val start: Any = if (filePath != null) EntryRoute(filePath) else ListRoute + val start: Any = if (filePath != null) EntryRoute(filePath) else ListRoute() setContent { APSTheme { - NavHost(rememberNavController(), startDestination = start) { - composable { - LegacyScreen( - this@MainActivity, - Intent(this@MainActivity, PasswordStore::class.java).setAction(Intent.ACTION_VIEW), + val nav = rememberNavController() + NavHost(nav, startDestination = start) { + composable { entry -> + val path = entry.toRoute().path + ListDestination( + activity = this@MainActivity, + path = path, + onOpenFolder = { target -> + // Only the folder on top may move the stack: a double tap must not push twice. + if (nav.currentBackStackEntry?.id == entry.id) { + val (pops, pushes) = folderMoves(path, target) + repeat(pops) { nav.popBackStack() } + pushes.forEach { nav.navigate(ListRoute(it)) } + } + }, ) } composable { entry -> @@ -67,6 +99,59 @@ class MainActivity : FragmentActivity() { } } } + + /** Re-reads the store; starts onboarding and returns false when there is none yet. */ + fun checkStore(): Boolean { + PasswordRepository.initialize() + if (sharedPrefs.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) return true + launchActivity(OnboardingActivity::class.java) + return false + } + + /** Git actions offered in the list's menu, chosen as the old list chose its menu. */ + fun gitOps(): ImmutableList = + when { + gitSettings.authMode == AuthMode.None -> persistentListOf(GitOp.PULL) + PasswordRepository.isGitRepo() -> persistentListOf(GitOp.SYNC, GitOp.PULL, GitOp.PUSH) + else -> persistentListOf() + } + + /** Runs [op]; failures show the usual git error dialog. */ + suspend fun runGit(op: GitOp) { + launchGitOperation(op).onFailure { promptOnErrorHandler(it) } + } + + // ponytail: the list still opens the legacy decrypt screen directly; step 3 navigates to + // EntryRoute instead (its trampoline would finish this activity and close the list). + fun openEntry(file: File) { + val item = entryItem(file) + val authIntent = item.createAuthEnabledIntent(this) + startActivity(Intent(authIntent).setComponent(ComponentName(this, DecryptActivity::class.java))) + shortcutHandler.addDynamicShortcut(item, authIntent) + } + + fun pinEntry(file: File) { + val item = entryItem(file) + shortcutHandler.addPinnedShortcut(item, item.createAuthEnabledIntent(this)) + } + + fun createEntry(dir: File) { + startActivity( + Intent(this, PasswordCreationActivity::class.java) + .putExtra(BasePGPActivity.EXTRA_FILE_PATH, dir.absolutePath) + .putExtra( + BasePGPActivity.EXTRA_REPO_PATH, + PasswordRepository.getRepositoryDirectory().absolutePath, + ) + ) + } + + fun openSettings() { + launchActivity(SettingsActivity::class.java) + } + + private fun entryItem(file: File) = + PasswordItem.newPassword(file.name, file, PasswordRepository.getRepositoryDirectory()) } // ponytail: trampoline to a screen not yet ported; each port replaces one call with the diff --git a/app/src/main/res/values/themes_material3.xml b/app/src/main/res/values/themes_material3.xml index 7e34b472..0ce3796f 100644 --- a/app/src/main/res/values/themes_material3.xml +++ b/app/src/main/res/values/themes_material3.xml @@ -14,6 +14,12 @@ @style/APSThemeM3.BottomSheetDialog + + + + +``` + +In `app/lint.xml` delete ` ` (deleted in step 7), ` ` under `UnusedResources`, and the whole block: + +```xml + + + +``` + +- [ ] **Step 6: Check** + +```bash +./gradlew spotlessApply +CI=1 ./gradlew spotlessCheck :app:testDebugUnitTest -PslimTests :app:assembleDebug :app:assembleRelease :app:lintDebug +./gradlew :app:dependencies --configuration releaseRuntimeClasspath | grep -cE 'modernandroidpreferences|fastscroll|recyclerview-selection|androidx.preference:preference' +./gradlew :app:dependencyInsight --configuration releaseRuntimeClasspath --dependency androidx.fragment:fragment | grep -m1 'androidx.fragment:fragment:' +./gradlew :app:dependencyInsight --configuration releaseRuntimeClasspath --dependency androidx.recyclerview:recyclerview | grep -m1 'androidx.recyclerview:recyclerview:' +``` +Expected: `BUILD SUCCESSFUL`; the count is `0`; `androidx.fragment:fragment:1.8.4` as in Step 1; the recyclerview line shows whatever Material Components' graph resolves (record it in the report; accepted per Decisions). If the build reports `Unresolved reference 'viewModels'` in `AutofillFilterView.kt`, stop and report (`activity-compose` should provide it through `activity-ktx`). If lint now reports `UnusedIds`, delete each reported id that no code reads (`grep -rn 'R.id.' app/src`) and include it here; if code reads it, report. + +- [ ] **Step 7: Commit** + +```bash +git add -A app/build.gradle.kts gradle/libs.versions.toml build-logic app/lint.xml app/src/main/res +git status --short +git commit -m "build(app): drop the View-era dependencies and view binding + +Nothing uses preference, recyclerview, recyclerview-selection, +fastscroll or modernandroidpreferences since the Compose screens +replaced the Views, nor view binding. fragment-ktx stays as the pin +that keeps androidx.fragment at 1.8.4. The preference widget override, +the multiselect attribute, the toolbar and bottom sheet styles and the +lint ignores for deleted layouts go. Lint checks unused ids again. + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: The git credential prompt without ConstraintLayout (security-relevant) + +**Files:** +- Modify: `app/src/main/res/layout/git_credential_layout.xml` +- Modify: `app/build.gradle.kts`, `gradle/libs.versions.toml` + +- [ ] **Step 1: Confirm the only user** + +```bash +grep -rn constraintlayout --include=*.xml --include=*.kt --include=*.kts --include=*.toml . | grep -v /build/ +``` +Expected: `app/build.gradle.kts`, `gradle/libs.versions.toml` and `git_credential_layout.xml` (open and close tags) only. + +- [ ] **Step 2: Replace the layout** + +Replace everything below the license comment of `git_credential_layout.xml` with: + +```xml + + + + + + + + + + + +``` + +Only the root and the `layout_constraint*` attributes change; ids, `inputType="textPassword"`, the password toggle, the error slot and the checkbox default stay. `CredentialFinder.kt` is untouched. + +- [ ] **Step 3: Drop the dependency** + +Delete ` implementation(libs.androidx.constraintlayout)` from `app/build.gradle.kts` and `androidx-constraintlayout = "androidx.constraintlayout:constraintlayout:2.2.2"` from `gradle/libs.versions.toml`. + +- [ ] **Step 4: Check** + +```bash +./gradlew spotlessApply +CI=1 ./gradlew spotlessCheck :app:assembleDebug :app:assembleRelease :app:lintDebug +``` +Expected: `BUILD SUCCESSFUL`. The dialog itself is checked on the emulator in Task 6 Step 9. + +- [ ] **Step 5: Security review** + +Reviewer brief: the credential field stays `textPassword` with the toggle, the same ids feed `CredentialFinder` (`findViewById` on three ids), the "remember" box keeps its default, and nothing else in the prompt changed. Apply findings. + +- [ ] **Step 6: Commit** + +```bash +git add app/src/main/res/layout/git_credential_layout.xml app/build.gradle.kts gradle/libs.versions.toml +git commit -m "refactor(app): lay out the git credential prompt without ConstraintLayout + +The prompt was ConstraintLayout's last user: a vertical LinearLayout +holds the same field and checkbox, so the dependency goes. Ids, input +type and the remember box's default are unchanged. + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Name HardenedPass in every locale + +**Files:** +- Modify: `app/src/main/res/values-{de,fr,gl,ko-rKR,pl-rPL,pt-rBR,ru,zh-rCN}/strings.xml` + +- [ ] **Step 1: The occurrences** + +```bash +grep -n 'Password Store' app/src/main/res/values-*/strings.xml | grep '` section comments are untouched (not `" +``` + +--- + +### Task 6: Full CI set and emulator regression pass + +No commit. The build under test is `compose` after Tasks 1 to 5. No real store or keys: the e2e environment from AGENTS.md only. + +**Device safety, for every step of this task.** The agent's shell keeps no functions or variables between Bash calls, so Step 2 writes the environment and every helper to `SCRATCHPAD/e2e.sh`, where `SCRATCHPAD` stands for the executing session's scratchpad directory: replace it with that absolute path everywhere in this task before running anything. Every later step starts with the two lines below, which also stop the step if the serial is not the emulator's. All helpers carry an `e2e_` prefix: unprefixed names such as `pass`, `open`, `top` or `sync` are real commands, and `pass` would reach the maintainer's real password store. A physical tablet may be connected: it is never touched. The PIN `1234` is the emulator's own test PIN, never the owner's. + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +``` + +Record each step's result (ok, MISSING, counts) for the report and for Task 7. + +- [ ] **Step 1: Full local CI set** + +```bash +CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug :app:assembleRelease \ + lint metalavaCheckCompatibilityRelease +``` +Expected: `BUILD SUCCESSFUL`. A failure here is fixed with a new commit on `compose` (message in the style of the task that caused it) before the emulator pass; describe it in the report. + +- [ ] **Step 2: Environment file, server, emulator** + +Write the environment and helpers: + +```bash +mkdir -p SCRATCHPAD/e2e-out +cat > SCRATCHPAD/e2e.sh <<'EOF' +export ANDROID_SERIAL=emulator-5554 +cd "$(git rev-parse --show-toplevel)" || exit 1 +ANDROID_HOME=$(sed -n 's/^sdk.dir=//p' local.properties) +OUT=SCRATCHPAD/e2e-out +APP=xyz.danix.hardenedpass.debug +e2e_ui() { adb exec-out uiautomator dump /dev/tty; } +e2e_center() { local b; b=$(e2e_ui | grep -o "]*$1[^>]*>" | sed -n "${2:-1}p" | grep -o 'bounds="[^"]*"' | grep -oE '[0-9]+' | tr '\n' ' '); [ -n "$b" ] || { echo "not found: $1" >&2; return 1; }; set -- $b; echo "$(( ($1 + $3) / 2 )) $(( ($2 + $4) / 2 ))"; } +e2e_tap() { local c; c=$(e2e_center "$1" "$2") && adb shell input tap $c; sleep 1; } +e2e_field() { e2e_tap 'class="android.widget.EditText"' "$1"; adb shell input keycombination 113 29; adb shell input keyevent 67; adb shell input text "$2"; } +e2e_value() { e2e_ui | grep -o ']*class="android.widget.EditText"[^>]*>' | sed -n "${1}p" | grep -o ' text="[^"]*"' | cut -d'"' -f2; } +e2e_seen() { e2e_ui | grep -q "$1" && echo "ok: $1" || echo "MISSING: $1"; } +e2e_enabled() { e2e_ui | grep -o "]*$1[^>]*>" | head -1 | grep -o 'enabled="[a-z]*"'; } +e2e_top() { adb shell dumpsys activity activities | grep topResumedActivity; } +e2e_open() { adb shell am start -n $APP/app.passwordstore.ui.main.LaunchActivity; sleep 3; } +e2e_pick() { sleep 2; e2e_ui | grep -q "text=\"$1\"" || { e2e_tap 'content-desc="Show roots"'; e2e_tap 'text="Downloads"'; sleep 1; }; e2e_tap "text=\"$1\""; sleep 2; } +e2e_until_seen() { for _ in $(seq 30); do e2e_ui | grep -q "$1" && { echo "ok: $1"; return; }; sleep 1; done; echo "MISSING: $1"; } +e2e_prefs() { adb shell run-as $APP cat shared_prefs/${APP}_preferences.xml | grep -o "name=\"$1\"[^/]*" || echo "absent: $1"; } +e2e_secure() { adb shell dumpsys window | grep -c 'fl=.*SECURE'; } +e2e_pass() { e2e_tap 'class="android.widget.EditText"'; adb shell input text "$1"; e2e_tap 'text="OK"'; sleep 2; } +e2e_srv() { docker exec -u git aps-e2e-git git -C /srv/git/store.git "$@"; } +e2e_reveal() { e2e_srv show "${2:-master}:$1.gpg" > "$OUT/x.gpg"; GNUPGHOME=.e2e/gnupg gpg -q --batch --pinentry-mode loopback --passphrase test -d "$OUT/x.gpg"; } +e2e_sync() { e2e_tap 'content-desc="More options"'; e2e_tap 'text="Synchronize repository"'; sleep 10; } +EOF +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +type e2e_pass | head -1 +scripts/e2e-env.sh up +``` +Expected: `e2e_pass is a function`; the e2e server is up. (The heredoc is quoted: `$1`, `$APP` and `$OUT` expand when the helpers run, not when the file is written.) + +Start the emulator as a background command that outlives this call (the agent's background-run option; the variables come from the file): + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +"$ANDROID_HOME/emulator/emulator" -avd aps-test -no-window -no-audio -no-boot-anim \ + -no-snapshot-save -gpu swiftshader_indirect +``` + +Then unlock and install: + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +adb wait-for-device +until [ "$(adb shell getprop sys.boot_completed | tr -d '\r')" = 1 ]; do sleep 2; done +adb shell locksettings set-pin 1234 2>/dev/null || true +adb shell input keyevent KEYCODE_WAKEUP; adb shell input keyevent 82 +adb shell input text 1234; adb shell input keyevent 66 +for s in animator_duration_scale transition_animation_scale window_animation_scale; do + adb shell settings put global $s 0; done +adb shell settings put secure autofill_service null +adb uninstall $APP || true +./gradlew :app:installDebug +scripts/e2e-env.sh push-keys +``` +(`set-pin` fails harmlessly when the AVD already has the PIN. `installDebug` and `push-keys` honor `ANDROID_SERIAL`.) + +- [ ] **Step 3: Clone over SSH, PGP import** + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +e2e_open; e2e_tap 'text="Clone Remote Repo"' +e2e_field 1 ssh://git@10.0.2.2:2222/srv/git/store.git +e2e_tap 'text="Manage"'; e2e_tap 'text="Import SSH key"'; e2e_pick aps-e2e-ssh-key +e2e_seen 'text="Imported private key"'; adb shell input keyevent 4; sleep 1 +e2e_tap 'text="Clone"'; sleep 15 +e2e_tap 'text="Import PGP key"' 2; e2e_pick aps-e2e-pgp-key.asc; e2e_tap 'text="OK"'; sleep 2 +e2e_seen 'text="example.org"'; e2e_seen 'text="work"'; e2e_seen 'text="otp"'; e2e_top +``` +Expected: `MainActivity` on top with the cloned store (`example.org`, `work`, `otp`). (If the FAB is the first "Import PGP key" match on this image, use index 1.) + +- [ ] **Step 4: Decrypt** + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +e2e_tap 'text="example.org"'; e2e_pass test +e2e_seen 'text="alice"'; e2e_seen 'text="hunter2"'; e2e_secure +adb shell input keyevent 4; sleep 1; e2e_seen 'text="otp"'; e2e_secure +``` +Expected: the entry shows `alice` and `hunter2` (if `hunter2` is `MISSING`, the password is masked by setting: `e2e_tap 'content-desc="Show password"'` and check again), `e2e_secure` at `1` or more; back on the list, `0`. + +- [ ] **Step 5: TOTP (by eye)** + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +e2e_tap 'text="otp"'; e2e_tap 'text="example.com"'; e2e_pass test +adb emu screenrecord screenshot "$OUT" +python3 -c 'import hmac,hashlib,struct,time,base64;k=base64.b32decode("JBSWY3DPEHPK3PXP");h=hmac.new(k,struct.pack(">Q",int(time.time())//30),hashlib.sha1).digest();o=h[-1]&15;print("%06d"%((struct.unpack(">I",h[o:o+4])[0]&0x7fffffff)%1000000))' +e2e_secure +adb shell input keyevent 4; sleep 1; adb shell input keyevent 4; sleep 1; e2e_seen 'text="otp"' +``` +The OTP screen never goes idle (no `uiautomator dump` on it) and `screencap` is black (`FLAG_SECURE`); the emulator console screenshot grabs the host-side display. Expected: in the PNG in `$OUT`, `s3cret` and "OTP (expires in Ns)" with the code the Python line printed (same 30 s window) and a shrinking bar; `e2e_secure` at `1` or more. If the console screenshot is black or unsupported, record "TOTP by eye: not checked" for the maintainer's phone test. + +- [ ] **Step 6: Create and push** + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +e2e_tap 'text="New entry"'; e2e_tap 'text="Create new password"'; e2e_secure +adb shell input text regress.example.net +e2e_field 2 r3gress-pw; e2e_field 3 dora +e2e_tap 'text="Save"'; sleep 3; e2e_seen 'text="regress.example.net"' +e2e_sync +e2e_seen 'text="An error occurred during a Git operation"' +e2e_srv log --format=%s -3 +e2e_reveal regress.example.net +``` +Expected: the editor is `FLAG_SECURE`; the list shows the new entry; the sync shows no error dialog (`MISSING`); the server log's newest commit names `regress.example.net` (above `Initial test store`); the server copy decrypts to `r3gress-pw` and `username: dora`. + +- [ ] **Step 7: Conflict recovery, then hard reset** + +A conflicting change on the server, made inside the container with its own git (no host git, hooks or signing involved): + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +printf 'server-side\nusername: alice\n' | GNUPGHOME=.e2e/gnupg gpg -q --batch --yes \ + --trust-model always -e -r "APS Test " -o "$OUT/example.org.gpg" +docker exec -u git aps-e2e-git git clone -q /srv/git/store.git /tmp/w +docker cp "$OUT/example.org.gpg" aps-e2e-git:/tmp/w/example.org.gpg +docker exec aps-e2e-git chown git:git /tmp/w/example.org.gpg +docker exec -u git aps-e2e-git sh -c 'cd /tmp/w && git -c user.name=e2e -c user.email=e2e@example.org commit -qam "Server-side change" && git push -q origin master' +e2e_srv log --format=%s -1 +``` +Expected: `Server-side change`. + +The same file changed on the device, then a sync: + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +e2e_tap 'text="example.org"'; e2e_pass test; e2e_tap 'content-desc="Edit password"' +e2e_field 2 device-side; e2e_tap 'text="Save"'; sleep 3; e2e_seen 'text="example.org"' +e2e_sync +e2e_until_seen 'detached head'; e2e_seen 'text="An error occurred during a Git operation"' +e2e_tap 'text="OK"' +``` +Expected: the rebase fails and the View error dialog says "Pull has failed, you're in a detached head..."; OK closes it. + +Abort and push a new branch: + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +e2e_tap 'content-desc="More options"'; e2e_tap 'text="Settings"'; e2e_tap 'text="Repository"' +e2e_tap 'text="Local Git config'; sleep 2 +e2e_seen 'HEAD detached at'; e2e_enabled 'text="Abort rebase and push new branch"' +e2e_tap 'text="Abort rebase and push new branch"' +e2e_until_seen 'pushed to another branch'; e2e_seen 'text="Abort and Push"' +e2e_tap 'text="OK"'; sleep 1 +e2e_srv branch --list +B=$(e2e_srv branch --list 'conflicting-*' | tr -d ' *\r') +e2e_srv log --format=%s -1 "$B"; e2e_reveal example.org "$B" +``` +Expected: the git config screen shows "HEAD detached at ...", the abort button `enabled="true"`; the "Abort and Push" dialog names the pushed branch; OK returns to the Repository settings. The server has `master` and one `conflicting-master-` branch whose newest commit is the device's edit of `example.org`, decrypting to `device-side` and `username: alice`. + +Hard reset to the server's `master`: + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +e2e_tap 'text="Local Git config'; sleep 2; e2e_seen 'text="On branch master"' +e2e_tap 'text="Hard reset to remote branch"'; e2e_seen 'are discarded'; e2e_value 1 +e2e_tap 'text="OK"'; e2e_until_seen 'remote branch: done' +adb shell input keyevent 4; sleep 1; adb shell input keyevent 4; sleep 1; adb shell input keyevent 4; sleep 1 +e2e_tap 'text="example.org"'; e2e_pass test; e2e_seen 'text="server-side"'; e2e_seen 'text="alice"' +adb shell input keyevent 4; sleep 1 +``` +Expected: back on `master` after the abort; the reset dialog warns and prefills `master`; the snackbar "Hard reset to remote branch: done"; the list's `example.org` now decrypts to the server's `server-side`. + +- [ ] **Step 8: Fingerprint unlock (passphrase cache and app lock)** + +Fingerprint (once per AVD), after the two safety lines: `adb shell dumpsys fingerprint | grep -o '"count":[0-9]*'`. If it prints nothing or `"count":0`, enroll: `adb shell am start -a android.settings.FINGERPRINT_ENROLL`, enter the emulator PIN `1234`, follow the screens (dump them with `e2e_ui` to find the buttons) and run `adb emu finger touch 1` until enrollment completes; then `e2e_open`. + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +e2e_tap 'content-desc="More options"'; e2e_tap 'text="Settings"'; e2e_tap 'text="PGP settings"' +e2e_tap 'text="Enable passphrase caching"'; sleep 1; adb emu finger touch 1; sleep 2 +e2e_prefs enable_gpg_passphrase_cache +adb shell input keyevent 4; sleep 1; adb shell input keyevent 4; sleep 1 +e2e_tap 'text="example.org"'; e2e_seen 'text="Unlock passphrase cache"' +adb emu finger touch 1; sleep 2 +e2e_seen 'text="Clear cached password on screen-off"' +e2e_tap 'text="Clear cached password on screen-off"' +e2e_pass test; e2e_seen 'text="server-side"' +adb shell input keyevent 4; sleep 1 +e2e_tap 'text="example.org"'; adb emu finger touch 1; sleep 2 +e2e_seen 'text="server-side"'; e2e_seen 'class="android.widget.EditText"' +adb shell input keyevent 4; sleep 1 +``` +Expected: the cache switch stores `true` after the touch; the first open shows the prompt, then the passphrase dialog with the cache box, and decrypts; the second open decrypts with the touch alone (`ok: server-side`, `MISSING: ...EditText`). + +App lock: + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +e2e_tap 'content-desc="More options"'; e2e_tap 'text="Settings"'; e2e_tap 'text="General"' +e2e_tap 'text="Enable biometric authentication"'; sleep 1; adb emu finger touch 1; sleep 2 +e2e_prefs biometric_auth_delete_soon_please +adb shell am force-stop $APP; e2e_open +adb emu finger touch 1; sleep 3; e2e_seen 'text="example.org"'; e2e_top +e2e_tap 'content-desc="More options"'; e2e_tap 'text="Settings"'; e2e_tap 'text="General"' +e2e_tap 'text="Enable biometric authentication"'; sleep 1; adb emu finger touch 1; sleep 2 +e2e_prefs biometric_auth_delete_soon_please +adb shell input keyevent 4; sleep 1; adb shell input keyevent 4; sleep 1 +``` +Expected: `true` after the first touch; a cold start shows the prompt and the touch opens the list (`MainActivity` on top); `false` after turning it off. + +- [ ] **Step 9: The git credential prompt (Task 4's layout)** + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +e2e_tap 'content-desc="More options"'; e2e_tap 'text="Settings"'; e2e_tap 'text="Repository"' +e2e_tap 'text="Edit Git server settings"'; sleep 2 +e2e_tap 'text="Password"'; e2e_tap 'text="Save"'; sleep 2 +adb shell input keyevent 4; sleep 1; adb shell input keyevent 4; sleep 1 +e2e_tap 'content-desc="More options"'; e2e_tap 'text="Pull from remote"'; sleep 3 +e2e_seen 'Please provide the password'; e2e_seen 'text="Remember password"' +e2e_ui | grep -o ']*text="Remember password"[^>]*>' | grep -o 'checked="[a-z]*"' +e2e_ui | grep -o ']*class="android.widget.EditText"[^>]*>' | grep -o 'password="[a-z]*"' +adb exec-out screencap -p > "$OUT/credential.png" +e2e_tap 'text="Cancel"'; sleep 3 +e2e_seen 'text="An error occurred during a Git operation"' +``` +If an error dialog shows after Cancel, `e2e_tap 'text="OK"'` (after the two safety lines). Then restore SSH key mode: + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +e2e_tap 'content-desc="More options"'; e2e_tap 'text="Settings"'; e2e_tap 'text="Repository"' +e2e_tap 'text="Edit Git server settings"'; sleep 2 +e2e_tap 'text="SSH key"'; e2e_tap 'text="Save"'; sleep 2 +adb shell input keyevent 4; sleep 1; adb shell input keyevent 4; sleep 1 +e2e_sync; e2e_seen 'text="An error occurred during a Git operation"' +``` +Expected: the View credential dialog shows the message, a password field (`password="true"`) and "Remember password" `checked="true"`, laid out as before (field above checkbox, see the PNG); Cancel closes it without a crash; after restoring SSH key mode the sync works again (no error dialog). + +- [ ] **Step 10: Invalid `.gpg-id` (the Toast)** + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +adb shell run-as $APP sh -c 'mkdir -p files/store/badid && echo 0x1234ABCD > files/store/badid/.gpg-id && cp files/store/work/mail.example.org.gpg files/store/badid/x.gpg' +e2e_open; e2e_tap 'text="badid"'; e2e_tap 'text="x"'; sleep 1 +adb exec-out screencap -p > "$OUT/toast.png" +e2e_seen 'class="android.widget.EditText"' +adb logcat -d -b crash | grep -c 'FATAL EXCEPTION' +adb shell input keyevent 4; sleep 1 +adb shell run-as $APP rm -r files/store/badid +``` +Expected: no passphrase dialog (`MISSING: ...EditText`), no crash (`0`), and `toast.png` shows "Found .gpg-id, but it contains a short hex ID, which is not supported" at the bottom (the entry window behind it is black, FLAG_SECURE; the toast is SystemUI's window). If the screenshot shows no toast, record it for the maintainer's phone test. The folder is removed before anything syncs. + +- [ ] **Step 11: Autofill activities as root (smoke)** + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +if adb root | grep -q 'cannot run as root'; then echo "skip: no adb root"; else + adb wait-for-device; sleep 2 + X=app.passwordstore.autofill.oreo.ui + adb shell am start -n $APP/app.passwordstore.ui.autofill.AutofillFilterView \ + --es android.view.autofill.extra.CLIENT_STATE x --es $X.EXTRA_FORM_ORIGIN_WEB example.org; sleep 3 + e2e_top; e2e_seen 'Select entry to fill into' + adb shell input keyevent 4; sleep 1 + adb logcat -c + adb shell am start -n $APP/app.passwordstore.ui.autofill.AutofillDecryptActivity; sleep 2 + e2e_top; adb logcat -d | grep -c 'started without EXTRA_FILE_PATH' + adb unroot; adb wait-for-device; sleep 2 +fi +``` +Expected with root: the picker opens (`AutofillFilterView` on top, header found); the decryption, started without extras, logs once (`1`) and finishes at once (no longer on top). Without root: `skip`. Record whether root worked: Task 7 adds the root note to AGENTS.md only if it did. + +- [ ] **Step 12: Minified release build (smoke)** + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +adb install -r "$(find app/build/outputs/apk/release -name '*.apk' | head -1)" +adb logcat -c +adb shell am start -n xyz.danix.hardenedpass/app.passwordstore.ui.main.LaunchActivity; sleep 3 +e2e_tap 'text="Settings"'; e2e_tap 'text="General"'; e2e_seen 'text="Password sort order"' +adb shell input keyevent 4; sleep 1; e2e_tap 'text="Misc"'; e2e_seen 'text="Debug logging"' +adb logcat -d -b crash | grep -c 'FATAL EXCEPTION' +adb uninstall xyz.danix.hardenedpass +``` +Expected: the R8 build from Step 1 opens the welcome screen and two Settings categories, crash count `0`; the release package is removed from the emulator afterwards. + +- [ ] **Step 13: Tear down** + +```bash +source SCRATCHPAD/e2e.sh +[ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 +adb shell cmd uimode night no +adb uninstall $APP +adb shell rm -f /sdcard/Download/aps-e2e-ssh-key /sdcard/Download/aps-e2e-pgp-key.asc +adb emu kill +scripts/e2e-env.sh down +date +%F +``` +Look at `SCRATCHPAD/e2e-out` (TOTP, credential and toast screenshots) before deleting it. Note the printed date: Task 7 records it as `RUN_DATE`. + +--- + +### Task 7: AGENTS.md, the spec and CONTRIBUTING.md, push `compose` + +**Files:** +- Modify: `AGENTS.md` +- Modify: `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md` +- Modify: `CONTRIBUTING.md` + +Below, `RUN_DATE` is the date Task 6 Step 13 printed; write the actual date, never the placeholder. Do not touch AGENTS.md's public suffix list "Last run:" line: `master` changed it, and leaving it alone keeps the merge clean there. + +- [ ] **Step 1: AGENTS.md, branches** + +Replace + +``` +- `master` is the only long-lived branch. Upstream's `develop` was renamed; do not recreate it. +- Temporary exception: the Compose UI overhaul + (`docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`) lives on `compose` until + it is complete; nothing is released from it. `master` stays releasable (public suffix list, + hotfixes). Remove this bullet when `compose` merges. +``` + +with (the exact line `master` has, so this hunk merges cleanly; it lands on `master` only with the merge, after which `compose` is deleted) + +``` +- `master` is the only branch. Upstream's `develop` was renamed; do not recreate it. +``` + +- [ ] **Step 2: AGENTS.md, what the UI is** + +After the paragraph that ends ``Build conventions live in `build-logic/`.`` insert: + +``` + +The UI is Jetpack Compose with Material 3. `MainActivity` hosts every screen in one `NavHost` +(list, entry, editor, setup, settings); `LaunchActivity` is the only exported activity. The +autofill service starts its four activities (`AutofillFilterView`, `AutofillDecryptActivity`, +`AutofillSaveActivity`, `AutofillPublisherChangedActivity`) directly, so they stay separate and +draw with the same theme. +``` + +- [ ] **Step 3: AGENTS.md, emulator notes** + +Replace ` ./gradlew installDebug` with ` ./gradlew :app:installDebug`. + +Replace + +``` +Drive it with `adb shell input`, `uiautomator dump` and `screencap`. Decrypt screens set +`FLAG_SECURE`, so screenshots of them are black; read them from the `uiautomator` dump. +``` + +with + +``` +If a physical device is connected too, `export ANDROID_SERIAL=emulator-5554` first, so every +`adb` and Gradle install command targets the emulator. Agent shells keep no functions or +variables between calls: keep that export and any helpers in a sourced file, and prefix helper +names (`e2e_pass`, not `pass`, which is the real `pass` and reaches a real store). Drive it with +`adb shell input`, `uiautomator dump` and `screencap`; Compose nodes carry `text` and +`content-desc` in the dump, no resource ids, so match on those. The entry view, the editor and +its generator sheet, the proxy screen, the passphrase dialogs and the autofill decryption and +save windows set `FLAG_SECURE`, so screenshots of them are black; read them from the +`uiautomator` dump. +``` + +Only if Task 6 Step 11 ran with root, replace + +``` +Autofill cannot be tested on the emulator image: Settings is denylisted in +`OreoAutofillService`, and the only browser (WebView Shell) is not a trusted browser. Use a +real device with a trusted browser (e.g. Firefox) instead: +``` + +with + +``` +Autofill cannot be tested on the emulator image: Settings is denylisted in +`OreoAutofillService`, and the only browser (WebView Shell) is not a trusted browser. Its +activities are not exported; after `adb root` (the AOSP image allows it), `am start -n` opens the +picker, the publisher warning and the save editor with test extras, but not a real decryption +(it needs a client state `Bundle`). Use a real device with a trusted browser (e.g. Firefox) +instead: +``` + +Replace + +``` +Last full device run (2026-11-02): clone over SSH, PGP import, decrypt, TOTP, create and push, +conflict recovery, hard reset, fingerprint unlock (emulator) and Firefox autofill (tablet) all +passed. +``` + +with (drop "TOTP" from the list if Task 6 Step 5 could not check it) + +``` +Last full emulator run (RUN_DATE, Compose UI): clone over SSH, PGP import, decrypt, TOTP, +create and push, conflict recovery (abort rebase, push a new branch), hard reset and +fingerprint unlock passed. Last Firefox autofill run on the tablet: 2026-11-02, before the +Compose autofill UI; repeat it before the next release. +``` + +(If the maintainer reports that step 7's tablet checklist passed, write that date instead and drop the "before the Compose autofill UI; repeat it" clause. Task 8 asks for it.) + +- [ ] **Step 4: AGENTS.md, verified facts** + +After the `LaunchActivity` bullet (already current on `compose`: it names `MainActivity` and `EntryRoute`), insert: + +``` +- Material Components (Views) stays after the Compose overhaul: the window themes derive from + `Theme.Material3.DayNight` (the AppCompat activities need it), and the git operations' View + dialogs (`BaseGitActivity`, `GitOperation`, `CredentialFinder` with + `git_credential_layout.xml`, `BreakOutOfDetached`, `StoreActions`), `DynamicColors` and the + `snackbar` helper use it. `oreo_autofill_dataset.xml` stays a layout: fill suggestions are + `RemoteViews` drawn by the system. `fragment-ktx` is declared only to pin `androidx.fragment` + (appcompat alone resolves an older one). +``` + +- [ ] **Step 5: The spec** + +Replace the line + +``` +Date: 2026-11-02. Status: approved, not started. +``` + +with + +``` +Date: 2026-11-02. Status: complete (RUN_DATE); merges into `master` after the maintainer's phone test. +``` + +Replace the "Removed at the end" paragraph + +``` +`fragment-ktx`, `recyclerview`, `recyclerview-selection`, `swiperefreshlayout`, +`modernandroidpreferences`, the view-binding helpers (`FragmentViewBindingDelegate`, +`FragmentExtensions`), all layout XML files, and Material Components (Views) if nothing still +needs it. +``` + +with + +``` +`recyclerview`, `recyclerview-selection`, `swiperefreshlayout`, `modernandroidpreferences`, +`preference`, `constraintlayout`, fastscroll, view binding and its helpers +(`FragmentViewBindingDelegate`, `FragmentExtensions`), `BasePGPActivity` and every layout XML +file but two. Kept: `oreo_autofill_dataset.xml` (fill suggestions are `RemoteViews`, drawn by the +system) and `git_credential_layout.xml` (the git credential prompt), with Material Components +(Views): the window themes, the git operations' dialogs and the snackbars still need it. Moving +those to Compose is a separate change. `fragment-ktx` stays declared as the version pin of +`androidx.fragment`. +``` + +In "Branching", replace + +``` +to `origin` as a backup. CI is not extended to the branch: the local pre-commit check from +AGENTS.md runs before every commit, and CI runs at the merge. +``` + +with + +``` +to `origin` as a backup. CI is not extended to the branch: the local pre-commit check from +AGENTS.md runs before every commit, and CI runs at the merge. `master` stays at its last release +until the maintainer has tested a release build of `compose` on the phone and says go; then +`compose` merges with one signed `--no-ff` merge commit and is deleted. +``` + +In "Testing", replace + +``` +- Before merge: the full device checklist from AGENTS.md (clone over SSH, PGP import, decrypt, + TOTP, create and push, conflict recovery, hard reset, fingerprint unlock, Firefox autofill), + then the maintainer tests on the phone. +``` + +with + +``` +- Before merge: the full device checklist from AGENTS.md (clone over SSH, PGP import, decrypt, + TOTP, create and push, conflict recovery, hard reset, fingerprint unlock, Firefox autofill), + then the maintainer tests a release build of `compose` on the phone; the merge waits for the + maintainer's go. +``` + +- [ ] **Step 6: CONTRIBUTING.md** + +Replace + +``` +- For inflating a Fragment or Activity view, always use the [`viewBinding`](https://github.com/android-password-store/Android-Password-Store/blob/3a16d77db24b6d79d9d27ce3207d72a7a879f38a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt) extension. +``` + +with + +``` +- The UI is Jetpack Compose (Material 3): screens are stateless composables driven by a ViewModel. Do not add XML layouts, Fragments or view binding. +``` + +- [ ] **Step 7: Check, commit, push `compose`** + +Docs only: Task 6 Step 1 already ran the full CI set on the code, so no Gradle run here. + +```bash +grep -nw -e '`compose`' -e 'Temporary exception' -e 'DecryptActivity' -e 'installDebug' AGENTS.md +grep -c 'RUN_DATE' AGENTS.md docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md +git add AGENTS.md CONTRIBUTING.md docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md +git commit -m "docs: record the finished Compose overhaul + +AGENTS.md drops the compose branch exception, describes the Compose UI +and its activities, updates the emulator notes (serial pinning, prefixed +helpers, Compose nodes, which windows are FLAG_SECURE) and the last +device run, and records why Material Components (Views) and +fragment-ktx stay. The spec is marked complete with what was removed +and kept, and says the merge waits for the maintainer's phone test; +CONTRIBUTING.md no longer asks for view binding. + +Co-Authored-By: Claude Opus 5.5 " +git push +git status -sb | head -1 +``` +Expected: the first grep shows only `:app:installDebug` (`-w` keeps `AutofillDecryptActivity` from matching `DecryptActivity`; no exception bullet left); the placeholder count is `0` for both files; the push reaches every `origin` push URL; `## compose...origin/compose` with nothing ahead. If the push fails with "agent refused operation", the smartcard needs the maintainer: ask them to run `! git push`. + +--- + +### Task 8: Maintainer gate: phone test of a `compose` release build + +The agent builds and verifies the APK and hands it over. The agent never connects to, installs on or reads the maintainer's phone or tablet. + +- [ ] **Step 1: Signed release APK from `compose`, with `master`'s version** + +`compose` still carries 0.1.4 (code 104); the phone runs 0.1.5 (code 105), and Android refuses a downgrade. `master`'s version file is applied only for this build and restored at once, so nothing is committed: + +```bash +git switch compose && git status --short +git show master:app/version.properties > app/version.properties +./gradlew :app:assembleRelease +git checkout -- app/version.properties +git status --short +APK=$(find app/build/outputs/apk/release -name '*.apk' | head -1); echo "$APK" +sha256sum "$APK" +SDK=$(sed -n 's/^sdk.dir=//p' local.properties) +"$(ls -d "$SDK"/build-tools/* | tail -1)/apksigner" verify --print-certs "$APK" | grep -i 'SHA-256' +"$(ls -d "$SDK"/build-tools/* | tail -1)/../../cmdline-tools/latest/bin/apkanalyzer" manifest version-code "$APK" 2>/dev/null || true +``` +Expected: `git status --short` clean before the build and again after the restore; the signer's SHA-256 digest is `725016669e482f9ac371dee8df19a2a1b34fa76d1af05b2a23784725b1b49c83` (the certificate in AGENTS.md "Releases"); version code `105` if `apkanalyzer` is installed (otherwise skip that line). Never print the keystore password. + +- [ ] **Step 2: Hand over and wait** + +Report the APK path and its SHA-256 to the maintainer with this checklist (the maintainer installs it over 0.1.5; same signing key, data kept): + +1. App lock (if enabled), list, folders and breadcrumbs, search, pull to sync against the real server. +2. Open entries: password, username, OTP code and countdown, copies cleared after the timeout; screenshots of the entry view are blocked; the list screenshots normally after a passphrase dialog (an emulator oddity to confirm on the phone). +3. Edit and create an entry, sync, check it on the desktop with `pass`. +4. Settings: every category opens; the owed checks: clearing a saved HTTPS password or SSH passphrase really clears it, and the autofill instructions dialog's OK opens the system picker. +5. Firefox autofill (phone or tablet): the step 7 tablet checklist (`2026-11-04-compose-autofill.md`, Task 6 Step 10), still owed. Report its date for AGENTS.md. +6. Anything Task 6 recorded as "for the maintainer" (TOTP by eye, the `.gpg-id` toast). + +Findings are fixed on `compose` (normal commits, the full CI set from AGENTS.md, push), and Step 1 is repeated for a new build. If the maintainer reports the Firefox autofill date, update AGENTS.md's "Last Firefox autofill run" line on `compose` in a `docs:` commit. **Stop here until the maintainer explicitly says go for the merge.** + +--- + +### Task 9: Merge `compose` into `master` and push (only after the maintainer's go) + +- [ ] **Step 1: Both branches current** + +```bash +git status --short # clean (HANDOFF.md is ignored) +git fetch origin +git switch master +git merge --ff-only origin/master +git log --oneline -1 +git log --oneline master..compose | wc -l +git log --oneline compose..master +``` +Expected: clean tree; `master` at `0ad0b1ca` (`release: HardenedPass 0.1.5`) or later; about 82 commits on `compose` only; `compose..master` lists `0ad0b1ca`, `ad3c63f6` and `fd3909e1` (the last two are already on `compose` as cherry-picks), plus any newer `master` commit (a public suffix list run, say). + +- [ ] **Step 2: Merge without committing** + +```bash +git merge --no-ff --no-commit compose +git status --short | grep -v '^[AMDR] ' +``` +Expected: `UU AGENTS.md` only. + +- [ ] **Step 3: Resolve AGENTS.md** + +The conflict is around the bullet before the sshj bullet in "Verified facts and gotchas". Keep the `compose` side of the hunk in full: the `LaunchActivity` bullet + +``` +- `LaunchActivity` is exported and forwards `FILE_PATH` to `MainActivity` (not exported) only + after `isFileInRepo` checks it canonically resolves inside the store; `MainActivity` opens it as + `EntryRoute`, whose destination checks it again before decrypting. +``` + +and the Material Components bullet that Task 7 Step 4 inserted right after it (it sits inside the same conflict hunk, on the `compose` side, and must not be dropped). Delete `master`'s and the base's `DecryptActivity` versions with all markers. Rule for any other hunk: the `compose` text wins, except the public suffix list "Last run:" line, which keeps `master`'s date (`2026-11-03`, or a later one if `master` ran it since). + +```bash +grep -c '^<<<<<<<\|^=======\|^>>>>>>>\|^|||||||' AGENTS.md +grep -n 'Last run:' AGENTS.md +grep -c 'KeyFactory.Ed25519' AGENTS.md +grep -c 'Material Components (Views) stays' AGENTS.md +git diff compose -- AGENTS.md +git add AGENTS.md +git diff --cached --stat compose +``` +Expected: `0` markers; `Last run: 2026-11-03` (or later); the sshj bullet once (`1`); the Material Components bullet once (`1`); against `compose`, AGENTS.md differs only in the "Last run:" line; the staged tree differs from `compose` in `AGENTS.md`, `CHANGELOG.md` and `app/version.properties` only (`master`'s 0.1.5 release, plus whatever newer `master` commit Step 1 listed). + +- [ ] **Step 4: Full CI set on the merge result, then commit** + +```bash +CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug :app:assembleRelease \ + lint metalavaCheckCompatibilityRelease +git commit -m "Merge branch 'compose': Compose UI overhaul + +Every screen moves from XML layouts, Views and Fragments to Jetpack +Compose with Material 3, in one MainActivity with a NavHost: the list +with breadcrumbs, search, pull to sync and multi-select; the entry view +with OTP countdown; the editor with generator sheet and folder picker; +setup (welcome, git server and clone, SSH key, PGP keys); settings, git +config, commit log and proxy; and the autofill picker, publisher +warning and decryption prompts. LaunchActivity stays the only exported +activity. Fixes found on the way reach master with it, among them +symlink-safe deletes, the proxy user name, screen lock gated switches, +the Android 14 app lock and screen lock prompts, and the autofill +passphrase cache rule. The View-era dependencies and view binding are +gone; Material Components (Views) stays for the git dialogs. + +Co-Authored-By: Claude Opus 5.5 " +git log --format='%h %G? %s' -1 +git log --oneline --graph -3 +``` +Expected: `BUILD SUCCESSFUL`; the merge commit shows `G` (signed); the graph shows two parents. If the hook rejects the message, treat it as correct (AGENTS.md "Personal-data git hooks"), fix the message and commit again. + +- [ ] **Step 5: Push and watch CI** + +```bash +git push origin master +git status -sb | head -1 +git ls-remote github:danixland/HardenedPass.git refs/heads/master +git rev-parse HEAD +for i in $(seq 20); do + ID=$(gh run list --repo danixland/HardenedPass --workflow CI --commit "$(git rev-parse HEAD)" \ + --json databaseId --jq '.[0].databaseId') + [ -n "$ID" ] && break; sleep 15 +done; echo "run: $ID" +gh run watch "$ID" --repo danixland/HardenedPass --exit-status +``` +Expected: the push reaches both push URLs (own server and the GitHub mirror); `## master...origin/master` with nothing ahead; the GitHub `master` hash equals `HEAD`; the CI run for this exact commit appears within five minutes and `gh run watch` exits `0`. If no run appears, report it (the workflow may not have triggered). Never fix anything on GitHub itself; a CI failure is fixed locally on `master` and pushed. No tag, no release. + +--- + +### Task 10: Delete `compose` + +- [ ] **Step 1: Maintainer gate** + +The maintainer's go in Task 8 covers the deletion once Task 9's CI run is green, unless the maintainer asked to keep the branch. Report the merge hash and CI result first. + +- [ ] **Step 2: Delete the branch locally and on both remotes** + +```bash +git switch master +git branch -d compose +git push origin --delete compose +git fetch --prune origin +git branch -a +git ls-remote --heads origin +git ls-remote --heads github:danixland/HardenedPass.git +``` +Expected: `-d` succeeds (the branch is merged; never `-D`); the delete reaches both push URLs; afterwards only `master` exists locally, as `remotes/origin/master`, on the own server and on GitHub. AGENTS.md already says `master` is the only branch. Update the local `HANDOFF.md` (never staged or committed). + +--- + +## Self-review notes + +- **Spec coverage for step 8:** "Removed at the end": `recyclerview`, `recyclerview-selection`, `modernandroidpreferences` (Task 3), `swiperefreshlayout` (already gone), `FragmentViewBindingDelegate` and `FragmentExtensions` (Task 1), layouts (only `oreo_autofill_dataset.xml` and `git_credential_layout.xml` remain, Task 4 and Decisions), `fragment-ktx` kept as a version pin and Material Components kept, both recorded (Decisions, Task 7 Steps 4 and 5). "Branching": the AGENTS.md exception bullet goes (Task 7 Step 1), the spec says the merge waits for the phone test (Task 7 Step 5), CI runs at the merge (Task 9 Step 5). "Testing": full CI set (Tasks 6, 9), the emulator checklist pinned to `emulator-5554` (Task 6 Steps 3 to 9), Firefox autofill and the phone test before the merge (Task 8). "Migration order" step 8: delete, AGENTS.md, merge (Tasks 1 to 9). +- **Step 7 final review leftovers, each verified:** the three unused Gradle dependencies plus preference, modernandroidpreferences and constraintlayout (Tasks 3, 4); `FragmentExtensions.kt` and `FragmentViewBindingDelegate.kt`, view binding (Tasks 1, 3); `stableId`, `navigateBack`, `canNavigateBack`, `NavigationStackEntry.recyclerViewState`, `navigateTo`'s `Parcelable` (Task 1); `BasePGPActivity` with its helpers moved first and the `.gpg-id` snackbar changed to a Toast (Task 2); `MaterialAlertDialogBuilder` users kept, with Material Components (maintainer decision 1); kept-for-good resources unchanged. +- **Found in the survey beyond the list:** the four slide animators, `map_preference_widget_switch.xml`, `attrs.xml`, the toolbar and bottom sheet styles, stale `app/lint.xml` ignores, `UnusedIds` and `FragmentFieldInjection` lint disables, dead `AndroidExtensions` helpers, `SearchResult.isFiltered`, `ListMode.DirectoriesOnly`, `PasswordItem`'s `parent` overloads, `@JvmStatic` and `"NAME"` extra, `sentry.properties`, two "legacy activities" comments in `ListDestination.kt`, the view binding bullet in `CONTRIBUTING.md`, and AGENTS.md's "Decrypt screens" and `installDebug` lines. No manifest, ProGuard or `lint-baseline.xml` change is needed. +- **Review findings applied (2026-11-04):** `fragment-ktx` kept as the `androidx.fragment` 1.8.4 pin with a `dependencyInsight` check, `recyclerview` left to transitive resolution (Task 3); Task 6 helpers in a sourced scratchpad file, `e2e_` prefixed, with a serial guard at the top of every step; the CI run found by commit and retried (Task 9 Step 5); the Material Components bullet kept inside the conflict hunk (Task 9 Step 3); `-w` grep for `DecryptActivity` (Task 7 Step 7); the ViewModel's internal `navigateTo` calls in Task 1's expected grep; the Toast on the application context (Task 2); the dependency line count; the full FLAG_SECURE list (proxy screen, generator sheet) in AGENTS.md and no redundant full CI for the docs commit (Task 7); the upstream leftovers (Task 1). +- **Merge rehearsal:** `git merge-tree --write-tree master compose` at `9f4e4f12` conflicts only in AGENTS.md (the `LaunchActivity` bullet); the PSL date merges to `2026-11-03` by itself. Task 7 Step 1 uses `master`'s exact branch line, so that hunk merges cleanly too. +- **Security:** the autofill decryption keeps `FLAG_SECURE` from the first line after `super.onCreate` (Task 2, reviewed); the `FILE_PATH` key is unchanged and still forwarded only after `isFileInRepo`; `.gpg-id` parsing is unchanged apart from the message channel; the credential prompt keeps its password input (Task 4, reviewed, emulator Step 9). No secret enters a log, Bundle or saved state. The test APK for the phone is signed with the release key and verified against the AGENTS.md certificate (Task 8); the keystore password is never printed. +- **Type and name consistency:** `getParentPath(fullPath: String, repositoryPath: String): String`, `getLongName(fullPath: String, repositoryPath: String, basename: String): String` and `Context.getPGPIdentifiers(subDir: String): List?` (Task 2) are used with these signatures by `EntryDestination`, `AutofillDecryptActivity`, `StoreActions`, `PasswordItem` and `EditorDestination`. `LaunchActivity.EXTRA_FILE_PATH` is read by `MainActivity` and written by `PasswordItem` and `LaunchActivity`. `navigateTo(newDirectory: File)` (Task 1) matches `ListDestination`'s call. `PasswordItem(name, type, file, rootDir)` (Task 1) matches the two remaining factories. +- **Build order:** each of Tasks 1 to 5 builds and lints on its own (Task 1 deletes the animators with their last user; Task 3 removes dependencies after Tasks 1 and 2 removed their users; Task 4 removes ConstraintLayout with its last user). + +## Out of scope + +- **Moving the git operations' View dialogs and the snackbars to Compose,** and with it dropping Material Components (Views): a later change on `master` (maintainer decision 1). +- **`compose-ui-tooling` as `implementation`** in `app` and `ui/compose`: it belongs in `debugImplementation`, but that is a size fix unrelated to the overhaul. +- **The LeakCanary bools** in `values/bools.xml` (ignored by `app/lint.xml`), the `plumber-android-startup` dependency and the `sentry` package rule in `.github/renovate.json5`: untouched. +- **The stale "settings > git utils" wording** in `git_pull_rebase_fail_error` (the row is "Local Git config & utilities"): a string change in every locale, later. +- **CHANGELOG.md:** written at the next release (maintainer decision 7). +- **No release, no tag** in this step. + +## Maintainer decisions (2026-11-04) + +1. **Material Components (Views) stays** in step 8; the git dialogs and snackbars move to Compose later on `master`. AGENTS.md records why (Task 7 Step 4). +2. **Survey findings are in scope** as planned (Tasks 1 to 3), with the review's additions: the upstream leftovers in Task 1 and `fragment-ktx` kept as the `androidx.fragment` version pin. +3. **Locales in step 8:** 22 strings in eight locales, 20 renamed to "HardenedPass" and 2 German ones to "Repository" (Task 5). +4. **Credential layout:** `LinearLayout`, our `constraintlayout` dependency dropped (Task 4). +5. **Merge method:** one signed `--no-ff` merge commit (Task 9). +6. **Phone test before merge:** `master` stays at 0.1.5 until the maintainer, having tested a release build of `compose` on the phone (plus the owed Firefox autofill check), says go (Task 8). The agent never touches the phone. Then the merge (Task 9) and the branch deletion (Task 10). The spec's Branching and Testing text say so (Task 7 Step 5). +7. **No CHANGELOG entry** in step 8. -- cgit v1.2.3 From 35b2c6ab50a7b8e141f88f9ae88570ec43e6fa7b Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 4 Nov 2026 17:35:40 +0100 Subject: refactor(app): drop dead Fragment, view and navigation stack helpers The Compose screens use no Fragments or view binding, so FragmentExtensions (with its four slide animators) and FragmentViewBindingDelegate have no users, nor do resolveAttribute, isPermissionGranted, launchActivity and the unused Modifier.conditional. SearchableRepositoryViewModel keeps one navigateTo: the Compose list owns the back stack, so the RecyclerView-era navigation stack, stableId, isFiltered and the unused DirectoriesOnly mode go. PasswordItem loses the parent overloads, @JvmStatic and the unread NAME extra, and the leftover sentry.properties goes too. Co-Authored-By: Claude Opus 5.5 --- .../passwordstore/data/password/PasswordItem.kt | 26 ++------ .../app/passwordstore/ui/list/ListDestination.kt | 11 ++-- .../util/extensions/AndroidExtensions.kt | 31 ---------- .../util/extensions/FragmentExtensions.kt | 40 ------------- .../util/extensions/FragmentViewBindingDelegate.kt | 70 ---------------------- .../viewmodel/SearchableRepositoryViewModel.kt | 50 ++-------------- app/src/main/res/animator/slide_in_left.xml | 15 ----- app/src/main/res/animator/slide_in_right.xml | 15 ----- app/src/main/res/animator/slide_out_left.xml | 15 ----- app/src/main/res/animator/slide_out_right.xml | 15 ----- sentry.properties | 2 - 11 files changed, 14 insertions(+), 276 deletions(-) delete mode 100644 app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt delete mode 100644 app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt delete mode 100644 app/src/main/res/animator/slide_in_left.xml delete mode 100644 app/src/main/res/animator/slide_in_right.xml delete mode 100644 app/src/main/res/animator/slide_out_left.xml delete mode 100644 app/src/main/res/animator/slide_out_right.xml delete mode 100644 sentry.properties diff --git a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt index 765e886d..999ad050 100644 --- a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt +++ b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt @@ -10,13 +10,8 @@ import app.passwordstore.ui.crypto.BasePGPActivity import app.passwordstore.ui.main.LaunchActivity import java.io.File -data class PasswordItem( - val name: String, - val parent: PasswordItem? = null, - val type: Char, - val file: File, - val rootDir: File, -) : Comparable { +data class PasswordItem(val name: String, val type: Char, val file: File, val rootDir: File) : + Comparable { val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "") @@ -41,7 +36,6 @@ data class PasswordItem( /** Creates an [Intent] to launch this [PasswordItem] through the authentication process. */ fun createAuthEnabledIntent(context: Context): Intent { val intent = Intent(context, LaunchActivity::class.java) - intent.putExtra("NAME", toString()) intent.putExtra("FILE_PATH", file.absolutePath) intent.action = LaunchActivity.ACTION_DECRYPT_PASS return intent @@ -52,24 +46,12 @@ data class PasswordItem( const val TYPE_CATEGORY = 'c' const val TYPE_PASSWORD = 'p' - @JvmStatic - fun newCategory(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem { - return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir) - } - - @JvmStatic fun newCategory(name: String, file: File, rootDir: File): PasswordItem { - return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir) - } - - @JvmStatic - fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem { - return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir) + return PasswordItem(name, TYPE_CATEGORY, file, rootDir) } - @JvmStatic fun newPassword(name: String, file: File, rootDir: File): PasswordItem { - return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir) + return PasswordItem(name, TYPE_PASSWORD, file, rootDir) } } } diff --git a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt index adae3649..a9228484 100644 --- a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt @@ -58,10 +58,9 @@ private enum class ListDialog { /** * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the - * folder picker, the legacy activities and the store operations. [onOpenFolder] takes a path - * relative to the root, [onOpenEntry] an entry file; [onNewEntry] opens the editor in this folder, - * [onClone] the git server screen for a clone, [onSettings] the settings; [onNoStore] runs when the - * store is gone. + * folder picker and the store operations. [onOpenFolder] takes a path relative to the root, + * [onOpenEntry] an entry file; [onNewEntry] opens the editor in this folder, [onClone] the git + * server screen for a clone, [onSettings] the settings; [onNoStore] runs when the store is gone. */ @Composable fun ListDestination( @@ -89,7 +88,7 @@ fun ListDestination( // Declared before the collection below on purpose: effects start in order, so the ViewModel // already points at this folder when the first rows are built (no flash of the store root). LaunchedEffect(path, query) { - viewModel.navigateTo(dir, pushPreviousLocation = false) + viewModel.navigateTo(dir) if (query.isNotBlank()) viewModel.search(query.trim()) } val rows by viewModel.rows.collectAsStateWithLifecycle(ListRows()) @@ -100,7 +99,7 @@ fun ListDestination( if (path.isNotEmpty() && !dir.isDirectory) onOpenFolder("") else viewModel.forceRefresh() } - // Every return to this folder (back from a child, or from a legacy activity) re-reads it. + // Every return to this folder (back from a child, or from another activity) re-reads it. LifecycleResumeEffect(Unit) { if (activity.checkStore()) refresh() else onNoStore() resumes++ 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 bad55de4..6528334d 100644 --- a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt +++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt @@ -8,7 +8,6 @@ package app.passwordstore.util.extensions import android.app.KeyguardManager import android.content.ClipboardManager import android.content.Context -import android.content.Intent import android.content.SharedPreferences import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo @@ -17,12 +16,8 @@ import android.content.pm.PackageManager.ApplicationInfoFlags import android.content.pm.PackageManager.PackageInfoFlags import android.os.Build import android.util.Base64 -import android.util.TypedValue import android.view.View import android.view.autofill.AutofillManager -import androidx.activity.ComponentActivity -import androidx.compose.ui.Modifier -import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.fragment.app.FragmentActivity import app.passwordstore.BuildConfig @@ -56,13 +51,6 @@ val Context.keyguardManager: KeyguardManager val Context.sharedPrefs: SharedPreferences get() = getSharedPreferences("${BuildConfig.APPLICATION_ID}_preferences", 0) -/** Resolve [attr] from the [Context]'s theme */ -fun Context.resolveAttribute(attr: Int): Int { - val typedValue = TypedValue() - this.theme.resolveAttribute(attr, typedValue, true) - return typedValue.data -} - /** * Commit changes to the store from a [FragmentActivity] using a custom implementation of * [GitOperation] @@ -90,11 +78,6 @@ suspend fun FragmentActivity.commitChange(message: String): Result ComponentActivity.launchActivity(clazz: Class) { - startActivity(Intent(this, clazz).setAction(Intent.ACTION_VIEW)) -} - /** Simplifies the common `getString(key, null) ?: defaultValue` case slightly */ fun SharedPreferences.getString(key: String): String? = getString(key, null) @@ -134,12 +112,3 @@ fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): Ap getApplicationInfo(packageName, flags) } } - -/** Allows conditionally applying the given [modifier] if [isEnabled] is `true`. */ -fun Modifier.conditional(isEnabled: Boolean, modifier: Modifier.() -> Modifier): Modifier { - return if (isEnabled) { - then(modifier()) - } else { - this - } -} diff --git a/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt deleted file mode 100644 index 2dd3af0c..00000000 --- a/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.util.extensions - -import androidx.annotation.IdRes -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.commit -import app.passwordstore.R - -/** Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally. */ -fun Fragment.isPermissionGranted(permission: String): Boolean { - return requireActivity().isPermissionGranted(permission) -} - -/** Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity] */ -fun Fragment.finish() = requireActivity().finish() - -/** - * Perform a [commit] on this [FragmentManager] with custom animations and adding the - * [destinationFragment] to the fragment backstack - */ -fun FragmentManager.performTransactionWithBackStack( - destinationFragment: Fragment, - @IdRes containerViewId: Int = android.R.id.content, -) { - commit { - addToBackStack(destinationFragment.tag) - setCustomAnimations( - R.animator.slide_in_left, - R.animator.slide_out_left, - R.animator.slide_in_right, - R.animator.slide_out_right, - ) - replace(containerViewId, destinationFragment) - } -} diff --git a/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt b/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt deleted file mode 100644 index 0050f2e5..00000000 --- a/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.util.extensions - -import android.view.LayoutInflater -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.viewbinding.ViewBinding -import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KProperty - -/** - * Imported from - * https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c - */ -class FragmentViewBindingDelegate( - val fragment: Fragment, - val viewBindingFactory: (View) -> T, -) : ReadOnlyProperty { - - private var binding: T? = null - - init { - fragment.lifecycle.addObserver( - object : DefaultLifecycleObserver { - override fun onCreate(owner: LifecycleOwner) { - fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner -> - viewLifecycleOwner.lifecycle.addObserver( - object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - binding = null - } - } - ) - } - } - } - ) - } - - override fun getValue(thisRef: Fragment, property: KProperty<*>): T { - val binding = binding - if (binding != null) { - return binding - } - - val lifecycle = fragment.viewLifecycleOwner.lifecycle - if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { - throw IllegalStateException( - "Should not attempt to get bindings when Fragment views are destroyed." - ) - } - - return viewBindingFactory(thisRef.requireView()).also { this.binding = it } - } -} - -fun Fragment.viewBinding(viewBindingFactory: (View) -> T) = - FragmentViewBindingDelegate(this, viewBindingFactory) - -inline fun AppCompatActivity.viewBinding( - crossinline bindingInflater: (LayoutInflater) -> T -) = unsafeLazy { bindingInflater.invoke(layoutInflater) } diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt index 25ea2cf7..5f35644d 100644 --- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -6,7 +6,6 @@ package app.passwordstore.util.viewmodel import android.app.Application import android.content.SharedPreferences -import android.os.Parcelable import androidx.annotation.VisibleForTesting import androidx.lifecycle.AndroidViewModel import app.passwordstore.data.password.PasswordItem @@ -68,9 +67,6 @@ private fun PasswordItem.Companion.makeComparator( ) } -val PasswordItem.stableId: String - get() = file.absolutePath - enum class FilterMode { NoFilter, StrictDomain, @@ -85,7 +81,6 @@ enum class SearchMode { enum class ListMode { FilesOnly, - DirectoriesOnly, AllEntries, } @@ -171,7 +166,7 @@ constructor( ) ) - data class SearchResult(val passwordItems: List, val isFiltered: Boolean) + data class SearchResult(val passwordItems: List) val searchResult = searchActionFlow @@ -187,8 +182,6 @@ constructor( when (searchAction.listMode) { ListMode.FilesOnly -> listResultFlow.filter { it.isFile }.flowOn(dispatcherProvider.io()) - ListMode.DirectoriesOnly -> - listResultFlow.filter { it.isDirectory }.flowOn(dispatcherProvider.io()) ListMode.AllEntries -> listResultFlow } val passwordList = @@ -244,7 +237,7 @@ constructor( .map { it.second } } } - SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter) + SearchResult(passwordList) } .flowOn(dispatcherProvider.io()) @@ -288,55 +281,22 @@ constructor( .map { result -> listRows(result.passwordItems.map { it.file }, currentDir.value) } .flowOn(dispatcherProvider.io()) - data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?) - - private val navigationStack = ArrayDeque() - - fun navigateTo( - newDirectory: File = root, - listMode: ListMode = ListMode.AllEntries, - recyclerViewState: Parcelable? = null, - pushPreviousLocation: Boolean = true, - ) { + /** Lists [newDirectory]; the Compose list keeps the back stack (one route per folder). */ + fun navigateTo(newDirectory: File) { if (!newDirectory.exists()) return require(newDirectory.isDirectory) { "Can only navigate to a directory" } - if (pushPreviousLocation) { - navigationStack.addFirst(NavigationStackEntry(_currentDir.value, recyclerViewState)) - } searchActionFlow.update { makeSearchAction( filter = "", baseDirectory = newDirectory, filterMode = FilterMode.NoFilter, searchMode = SearchMode.InCurrentDirectoryOnly, - listMode = listMode, + listMode = ListMode.AllEntries, ) } _currentDir.update { newDirectory } } - val canNavigateBack - get() = navigationStack.isNotEmpty() - - /** - * Navigate back to the last location on the [navigationStack] and restore a cached scroll - * position if possible. - * - * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached. - */ - fun navigateBack(): Parcelable? { - if (!canNavigateBack) return null - val (oldDir, oldRecyclerViewState) = navigationStack.removeFirst() - navigateTo(oldDir, pushPreviousLocation = false) - return oldRecyclerViewState - } - - fun reset() { - navigationStack.clear() - forceUpdateOnNextSearchAction() - navigateTo(pushPreviousLocation = false) - } - fun search( filter: String, baseDirectory: File? = null, diff --git a/app/src/main/res/animator/slide_in_left.xml b/app/src/main/res/animator/slide_in_left.xml deleted file mode 100644 index 355aeefb..00000000 --- a/app/src/main/res/animator/slide_in_left.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/animator/slide_in_right.xml b/app/src/main/res/animator/slide_in_right.xml deleted file mode 100644 index 685bda4d..00000000 --- a/app/src/main/res/animator/slide_in_right.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/animator/slide_out_left.xml b/app/src/main/res/animator/slide_out_left.xml deleted file mode 100644 index 06c3f076..00000000 --- a/app/src/main/res/animator/slide_out_left.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/animator/slide_out_right.xml b/app/src/main/res/animator/slide_out_right.xml deleted file mode 100644 index e5c61bdc..00000000 --- a/app/src/main/res/animator/slide_out_right.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - diff --git a/sentry.properties b/sentry.properties deleted file mode 100644 index 47a8fadc..00000000 --- a/sentry.properties +++ /dev/null @@ -1,2 +0,0 @@ -defaults.project=password-store-google-play -defaults.org=password-store -- cgit v1.2.3 From 104c07f0bcc37d87ddc371d06827ff93ee3fe504 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 4 Nov 2026 17:37:46 +0100 Subject: refactor(app): fold BasePGPActivity into the autofill decryption AutofillDecryptActivity was its last subclass: it now sets FLAG_SECURE and injects the settings, crypto repository and dispatchers itself. The path and clipboard helpers move to CryptoExtensions.kt as top-level functions (a test pins the path helpers), and the FILE_PATH extra key moves, unchanged, to LaunchActivity. An invalid or short .gpg-id ID is reported with a toast: the snackbar never showed in the autofill decryption, which closes its window at once. Co-Authored-By: Claude Opus 5.5 --- .../passwordstore/data/password/PasswordItem.kt | 6 +- .../ui/autofill/AutofillDecryptActivity.kt | 16 +- .../app/passwordstore/ui/crypto/BasePGPActivity.kt | 181 --------------------- .../passwordstore/ui/crypto/CryptoExtensions.kt | 149 +++++++++++++++++ .../app/passwordstore/ui/entry/EntryDestination.kt | 2 +- .../java/app/passwordstore/ui/list/StoreActions.kt | 2 +- .../app/passwordstore/ui/main/LaunchActivity.kt | 8 +- .../java/app/passwordstore/ui/main/MainActivity.kt | 3 +- .../ui/crypto/CryptoExtensionsTest.kt | 27 +++ 9 files changed, 201 insertions(+), 193 deletions(-) delete mode 100644 app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt create mode 100644 app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt diff --git a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt index 999ad050..5d32108f 100644 --- a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt +++ b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt @@ -6,7 +6,7 @@ package app.passwordstore.data.password import android.content.Context import android.content.Intent -import app.passwordstore.ui.crypto.BasePGPActivity +import app.passwordstore.ui.crypto.getLongName import app.passwordstore.ui.main.LaunchActivity import java.io.File @@ -15,7 +15,7 @@ data class PasswordItem(val name: String, val type: Char, val file: File, val ro val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "") - val longName = BasePGPActivity.getLongName(fullPathToParent, rootDir.absolutePath, toString()) + val longName = getLongName(fullPathToParent, rootDir.absolutePath, toString()) override fun equals(other: Any?): Boolean { return (other is PasswordItem) && (other.file == file) @@ -36,7 +36,7 @@ data class PasswordItem(val name: String, val type: Char, val file: File, val ro /** Creates an [Intent] to launch this [PasswordItem] through the authentication process. */ fun createAuthEnabledIntent(context: Context): Intent { val intent = Intent(context, LaunchActivity::class.java) - intent.putExtra("FILE_PATH", file.absolutePath) + intent.putExtra(LaunchActivity.EXTRA_FILE_PATH, file.absolutePath) intent.action = LaunchActivity.ACTION_DECRYPT_PASS return intent } diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt index 39bf8cff..3f0fb5ef 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt @@ -8,11 +8,14 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.IntentSender +import android.content.SharedPreferences import android.os.Build import android.os.Bundle +import android.view.WindowManager import android.view.autofill.AutofillManager import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.appcompat.app.AppCompatActivity import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -25,12 +28,14 @@ import androidx.lifecycle.lifecycleScope import app.passwordstore.Application.Companion.screenWasOff import app.passwordstore.R import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.data.crypto.CryptoRepository import app.passwordstore.data.crypto.PGPPassphraseCache import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.injection.prefs.SettingsPreferences import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.ui.crypto.BasePGPActivity import app.passwordstore.ui.crypto.getPGPIdentifiers +import app.passwordstore.ui.crypto.getParentPath import app.passwordstore.ui.entry.PassphraseDialog import app.passwordstore.ui.main.MainActivity import app.passwordstore.ui.setup.SetupStart @@ -38,6 +43,7 @@ import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.autofill.AutofillResponseBuilder +import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.asLog import app.passwordstore.util.features.Feature.EnablePGPPassphraseCache import app.passwordstore.util.features.Features @@ -64,8 +70,11 @@ import logcat.logcat * FLAG_SECURE window shows only the "no keys" and passphrase dialogs. */ @AndroidEntryPoint -class AutofillDecryptActivity : BasePGPActivity() { +class AutofillDecryptActivity : AppCompatActivity() { + @SettingsPreferences @Inject lateinit var settings: SharedPreferences + @Inject lateinit var repository: CryptoRepository + @Inject lateinit var dispatcherProvider: DispatcherProvider @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory @Inject lateinit var features: Features @Inject lateinit var passphraseCache: PGPPassphraseCache @@ -97,6 +106,9 @@ class AutofillDecryptActivity : BasePGPActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Before anything is drawn: the passphrase dialog's parent stays out of screenshots and + // recents. + window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) // ponytail: a recreation (theme or locale change; rotation and resizing do not recreate, see // the manifest) cancels the fill instead of resuming a half-done flow; the user taps the field // again. Keep the flow's state in a ViewModel if this ever matters. diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt deleted file mode 100644 index e40585d5..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ /dev/null @@ -1,181 +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.ClipData -import android.content.Intent -import android.content.SharedPreferences -import android.os.Build -import android.os.Bundle -import android.os.PersistableBundle -import android.view.WindowManager -import androidx.annotation.CallSuper -import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.FragmentActivity -import app.passwordstore.R -import app.passwordstore.crypto.PGPIdentifier -import app.passwordstore.data.crypto.CryptoRepository -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.injection.prefs.SettingsPreferences -import app.passwordstore.util.coroutines.DispatcherProvider -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.services.ClipboardService -import app.passwordstore.util.settings.Constants -import app.passwordstore.util.settings.PreferenceKeys -import dagger.hilt.android.AndroidEntryPoint -import java.io.File -import javax.inject.Inject - -@Suppress("Registered") -@AndroidEntryPoint -open class BasePGPActivity : AppCompatActivity() { - - /** [SharedPreferences] instance used by subclasses to persist settings */ - @SettingsPreferences @Inject lateinit var settings: SharedPreferences - @Inject lateinit var repository: CryptoRepository - @Inject lateinit var dispatcherProvider: DispatcherProvider - - /** - * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots or - * recent apps screen. - */ - @CallSuper - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) - } - - companion object { - - const val EXTRA_FILE_PATH = "FILE_PATH" - - /** Gets the relative path to the repository */ - fun getRelativePath(fullPath: String, repositoryPath: String): String = - fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") - - /** Gets the Parent path, relative to the repository */ - fun getParentPath(fullPath: String, repositoryPath: String): String { - val relativePath = getRelativePath(fullPath, repositoryPath) - val index = relativePath.lastIndexOf("/") - return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/" - .replace("/+".toRegex(), "/") - } - - /** /path/to/store/social/facebook.gpg -> social/facebook */ - @JvmStatic - fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { - var relativePath = getRelativePath(fullPath, repositoryPath) - return if (relativePath.isNotEmpty() && relativePath != "/") { - // remove preceding '/' - relativePath = relativePath.substring(1) - if (relativePath.endsWith('/')) { - relativePath + basename - } else { - "$relativePath/$basename" - } - } else { - basename - } - } - } -} - -/** - * Copies provided [text] to the clipboard, flagged sensitive. Below Android 11 it also shows a - * snackbar, which can be disabled by passing [showSnackbar] as false. - */ -fun FragmentActivity.copyTextToClipboard( - text: String?, - showSnackbar: Boolean = true, - @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text, -) { - val clipboard = clipboard ?: return - val clip = ClipData.newPlainText("pgp_handler_result_pm", text) - clip.description.extras = - PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) } - clipboard.setPrimaryClip(clip) - if (showSnackbar && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - snackbar(message = resources.getString(snackbarTextRes)) - } -} - -/** - * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] and starts - * off an instance of [ClipboardService] to provide a way of clearing the clipboard. - */ -fun FragmentActivity.copyPasswordToClipboard(password: String?) { - copyTextToClipboard(password) - val clearAfter = - sharedPrefs.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() - ?: Constants.DEFAULT_DECRYPTION_TIMEOUT - - if (clearAfter != 0) { - val service = - Intent(this, ClipboardService::class.java).apply { - action = ClipboardService.ACTION_START - putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter) - } - startForegroundService(service) - } -} - -/** - * Get a list of available [PGPIdentifier]s for the current password repository. This method throws - * when no identifiers were able to be parsed. If this method returns null, it means that an invalid - * identifier was encountered and further execution must stop to let the user correct the problem. - */ -fun FragmentActivity.getPGPIdentifiers(subDir: String): List? { - val repoRoot = PasswordRepository.getRepositoryDirectory() - val gpgIdentifierFile = - File(repoRoot, subDir).findTillRoot(".gpg-id", repoRoot) - ?: File(repoRoot, ".gpg-id").apply { createNewFile() } - val gpgIdentifiers = - gpgIdentifierFile - .readLines() - .filter { it.isNotBlank() } - .map { line -> - PGPIdentifier.fromString(line) - ?: run { - // The line being empty means this is most likely an empty `.gpg-id` - // file we created. Skip the validation so we can make the user add a - // real ID. - if (line.isEmpty()) return@run - // Apparently `gpg-id` being the first line is also acceptable? - if (line == "gpg-id") return@run - if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) { - snackbar(message = resources.getString(R.string.short_gpg_id)) - } else { - snackbar(message = resources.getString(R.string.invalid_gpg_id)) - } - return null - } - } - .filterIsInstance() - if (gpgIdentifiers.isEmpty()) { - error("Failed to parse identifiers from .gpg-id: ${gpgIdentifierFile.readText()}") - } - return gpgIdentifiers -} - -@Suppress("ReturnCount") -private fun File.findTillRoot(fileName: String, rootPath: File): File? { - val gpgFile = File(this, fileName) - if (gpgFile.exists()) return gpgFile - - if (this.absolutePath == rootPath.absolutePath) { - return null - } - val parent = parentFile - return if (parent != null && parent.exists()) { - parent.findTillRoot(fileName, rootPath) - } else { - null - } -} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt new file mode 100644 index 00000000..fbaa534f --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt @@ -0,0 +1,149 @@ +/* + * 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.ClipData +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.PersistableBundle +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.fragment.app.FragmentActivity +import app.passwordstore.R +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.data.repo.PasswordRepository +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.services.ClipboardService +import app.passwordstore.util.settings.Constants +import app.passwordstore.util.settings.PreferenceKeys +import java.io.File + +/** Gets the relative path to the repository */ +private fun getRelativePath(fullPath: String, repositoryPath: String): String = + fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") + +/** Gets the Parent path, relative to the repository */ +fun getParentPath(fullPath: String, repositoryPath: String): String { + val relativePath = getRelativePath(fullPath, repositoryPath) + val index = relativePath.lastIndexOf("/") + return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/" + .replace("/+".toRegex(), "/") +} + +/** /path/to/store/social/facebook.gpg -> social/facebook */ +fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { + var relativePath = getRelativePath(fullPath, repositoryPath) + return if (relativePath.isNotEmpty() && relativePath != "/") { + // remove preceding '/' + relativePath = relativePath.substring(1) + if (relativePath.endsWith('/')) { + relativePath + basename + } else { + "$relativePath/$basename" + } + } else { + basename + } +} + +/** + * Copies provided [text] to the clipboard, flagged sensitive. Below Android 11 it also shows a + * snackbar, which can be disabled by passing [showSnackbar] as false. + */ +fun FragmentActivity.copyTextToClipboard( + text: String?, + showSnackbar: Boolean = true, + @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text, +) { + val clipboard = clipboard ?: return + val clip = ClipData.newPlainText("pgp_handler_result_pm", text) + clip.description.extras = + PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) } + clipboard.setPrimaryClip(clip) + if (showSnackbar && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + snackbar(message = resources.getString(snackbarTextRes)) + } +} + +/** + * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] and starts + * off an instance of [ClipboardService] to provide a way of clearing the clipboard. + */ +fun FragmentActivity.copyPasswordToClipboard(password: String?) { + copyTextToClipboard(password) + val clearAfter = + sharedPrefs.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() + ?: Constants.DEFAULT_DECRYPTION_TIMEOUT + + if (clearAfter != 0) { + val service = + Intent(this, ClipboardService::class.java).apply { + action = ClipboardService.ACTION_START + putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter) + } + startForegroundService(service) + } +} + +/** + * Get a list of available [PGPIdentifier]s for the current password repository. This method throws + * when no identifiers were able to be parsed. If this method returns null, an invalid identifier + * was encountered and reported with a toast: further execution must stop to let the user correct + * the problem. + */ +fun Context.getPGPIdentifiers(subDir: String): List? { + val repoRoot = PasswordRepository.getRepositoryDirectory() + val gpgIdentifierFile = + File(repoRoot, subDir).findTillRoot(".gpg-id", repoRoot) + ?: File(repoRoot, ".gpg-id").apply { createNewFile() } + val gpgIdentifiers = + gpgIdentifierFile + .readLines() + .filter { it.isNotBlank() } + .map { line -> + PGPIdentifier.fromString(line) + ?: run { + // The line being empty means this is most likely an empty `.gpg-id` + // file we created. Skip the validation so we can make the user add a + // real ID. + if (line.isEmpty()) return@run + // Apparently `gpg-id` being the first line is also acceptable? + if (line == "gpg-id") return@run + val message = + if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) R.string.short_gpg_id + else R.string.invalid_gpg_id + // A toast on the application context, not a snackbar: the autofill decryption + // finishes its window right after, and the toast must outlive it. + Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show() + return null + } + } + .filterIsInstance() + if (gpgIdentifiers.isEmpty()) { + error("Failed to parse identifiers from .gpg-id: ${gpgIdentifierFile.readText()}") + } + return gpgIdentifiers +} + +@Suppress("ReturnCount") +private fun File.findTillRoot(fileName: String, rootPath: File): File? { + val gpgFile = File(this, fileName) + if (gpgFile.exists()) return gpgFile + + if (this.absolutePath == rootPath.absolutePath) { + return null + } + val parent = parentFile + return if (parent != null && parent.exists()) { + parent.findTillRoot(fileName, rootPath) + } else { + null + } +} diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt index e5341320..02b112f4 100644 --- a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt @@ -43,10 +43,10 @@ import androidx.hilt.navigation.compose.hiltViewModel import app.passwordstore.R import app.passwordstore.data.passfile.Totp import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.BasePGPActivity.Companion.getParentPath import app.passwordstore.ui.crypto.copyPasswordToClipboard import app.passwordstore.ui.crypto.copyTextToClipboard import app.passwordstore.ui.crypto.getPGPIdentifiers +import app.passwordstore.ui.crypto.getParentPath import app.passwordstore.ui.main.LaunchActivity import app.passwordstore.ui.main.MainActivity import app.passwordstore.ui.setup.SetupStart diff --git a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt index 2e148401..3bdede73 100644 --- a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt +++ b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt @@ -9,7 +9,7 @@ import android.content.Context import androidx.core.content.edit import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.BasePGPActivity.Companion.getLongName +import app.passwordstore.ui.crypto.getLongName import app.passwordstore.ui.git.base.BaseGitActivity import app.passwordstore.util.autofill.AutofillMatcher import app.passwordstore.util.extensions.base64 diff --git a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt index 6f07a51a..c0af5742 100644 --- a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt @@ -12,7 +12,6 @@ import android.os.Looper import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.BasePGPActivity import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result import app.passwordstore.util.extensions.sharedPrefs @@ -61,10 +60,10 @@ class LaunchActivity : AppCompatActivity() { // This activity is exported, so the file path is untrusted input: only forward it when it // points at a file inside the password store. MainActivity is not exported and trusts it. val repoDir = PasswordRepository.getRepositoryDirectory() - val filePath = intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH) + val filePath = intent.getStringExtra(EXTRA_FILE_PATH) val intentToStart = Intent(this, MainActivity::class.java) if (intent.action == ACTION_DECRYPT_PASS && isFileInRepo(filePath, repoDir)) - intentToStart.putExtra(BasePGPActivity.EXTRA_FILE_PATH, filePath) + intentToStart.putExtra(EXTRA_FILE_PATH, filePath) // A plain start replaces the task, so an instance already open (one whose store vanished, say) // is not left below the new one. An entry opens on top, back returns to where the user was. else intentToStart.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) @@ -77,6 +76,9 @@ class LaunchActivity : AppCompatActivity() { const val ACTION_DECRYPT_PASS = "DECRYPT_PASS" + /** The entry to open; home screen shortcuts carry this exact key, never change it. */ + const val EXTRA_FILE_PATH = "FILE_PATH" + /** True when [path] resolves, after following `..` and symlinks, to a file under [repoDir]. */ fun isFileInRepo(path: String?, repoDir: File): Boolean { if (path.isNullOrEmpty()) return false diff --git a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt index a381ca84..ce5fb1df 100644 --- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt @@ -21,7 +21,6 @@ import androidx.navigation.toRoute import app.passwordstore.data.password.PasswordItem import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.ui.crypto.BasePGPActivity import app.passwordstore.ui.editor.EditorDestination import app.passwordstore.ui.editor.EditorForm import app.passwordstore.ui.editor.editorForm @@ -84,7 +83,7 @@ class MainActivity : BaseGitActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val filePath = intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH) + val filePath = intent.getStringExtra(LaunchActivity.EXTRA_FILE_PATH) val setup = intent.getStringExtra(EXTRA_SETUP)?.let { name -> SetupStart.entries.firstOrNull { it.name == name } diff --git a/app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt b/app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt new file mode 100644 index 00000000..fee6bf72 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.crypto + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Pins the path helpers' behavior across their move out of BasePGPActivity. */ +class CryptoExtensionsTest { + + @Test + fun parentPathIsRelativeToTheStore() { + assertEquals("/social/", getParentPath("/store/social/facebook.gpg", "/store")) + assertEquals("/", getParentPath("/store/example.org.gpg", "/store")) + } + + @Test + fun longNameJoinsFolderAndName() { + // StoreActions passes absolute folders, PasswordItem its path relative to the store. + assertEquals("work/x", getLongName("/store/work", "/store", "x")) + assertEquals("social/facebook", getLongName("/social/", "/store", "facebook")) + assertEquals("x", getLongName("/store", "/store", "x")) + } +} -- cgit v1.2.3 From 4e771dd5e04bf40abfd3db16801865a5d1f953a2 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 4 Nov 2026 17:40:54 +0100 Subject: build(app): drop the View-era dependencies and view binding Nothing uses preference, recyclerview, recyclerview-selection, fastscroll or modernandroidpreferences since the Compose screens replaced the Views, nor view binding. fragment-ktx stays as the pin that keeps androidx.fragment at 1.8.4. The preference widget override, the multiselect attribute, the toolbar and bottom sheet styles and the lint ignores for deleted layouts go. Lint checks unused ids again. Co-Authored-By: Claude Opus 5.5 --- app/build.gradle.kts | 6 +----- app/lint.xml | 5 ----- app/src/main/res/layout/map_preference_widget_switch.xml | 13 ------------- app/src/main/res/values/attrs.xml | 10 ---------- app/src/main/res/values/styles_material3.xml | 12 ------------ app/src/main/res/values/themes_material3.xml | 8 -------- .../kotlin/app/passwordstore/gradle/ApplicationPlugin.kt | 5 +---- .../src/main/kotlin/app/passwordstore/gradle/LintConfig.kt | 4 ---- gradle/libs.versions.toml | 5 ----- 9 files changed, 2 insertions(+), 66 deletions(-) delete mode 100644 app/src/main/res/layout/map_preference_widget_switch.xml delete mode 100644 app/src/main/res/values/attrs.xml delete mode 100644 app/src/main/res/values/styles_material3.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5f7a8ee8..053d7478 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core.ktx) implementation(libs.androidx.documentfile) + // Pins androidx.fragment (FragmentActivity, BiometricPrompt): appcompat alone resolves 1.5.4. implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.bundles.androidxLifecycle) @@ -70,9 +71,6 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.material) implementation(libs.androidx.navigation.compose) - implementation(libs.androidx.preference) - implementation(libs.androidx.recyclerview) - implementation(libs.androidx.recyclerviewSelection) implementation(libs.compose.ui.tooling) implementation(libs.dagger.hilt.android) @@ -84,13 +82,11 @@ dependencies { implementation(libs.aps.sublimeFuzzy) implementation(libs.aps.zxingAndroidEmbedded) - implementation(libs.thirdparty.fastscroll) implementation(libs.thirdparty.jgit) { exclude(group = "org.apache.httpcomponents", module = "httpclient") } implementation(libs.thirdparty.kotlinResult) implementation(libs.thirdparty.logcat) - implementation(libs.thirdparty.modernAndroidPrefs) implementation(libs.thirdparty.leakcanary.plumber) implementation(libs.thirdparty.sshj) implementation(libs.thirdparty.bouncycastle.bcprov) diff --git a/app/lint.xml b/app/lint.xml index 8834cbd7..44b84e59 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -5,7 +5,6 @@ - @@ -20,10 +19,6 @@ - - - - diff --git a/app/src/main/res/layout/map_preference_widget_switch.xml b/app/src/main/res/layout/map_preference_widget_switch.xml deleted file mode 100644 index 566e72d4..00000000 --- a/app/src/main/res/layout/map_preference_widget_switch.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml deleted file mode 100644 index a1d3c974..00000000 --- a/app/src/main/res/values/attrs.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/values/styles_material3.xml b/app/src/main/res/values/styles_material3.xml deleted file mode 100644 index 329905e1..00000000 --- a/app/src/main/res/values/styles_material3.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/values/themes_material3.xml b/app/src/main/res/values/themes_material3.xml index 0ce3796f..0ec74a45 100644 --- a/app/src/main/res/values/themes_material3.xml +++ b/app/src/main/res/values/themes_material3.xml @@ -10,8 +10,6 @@ @bool/light_status_bar ?android:colorBackground ?android:colorBackground - @style/APSThemeM3.Toolbar - @style/APSThemeM3.BottomSheetDialog @@ -43,12 +41,6 @@ true - -