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 --- docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'docs/superpowers') 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 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 (limited to 'docs/superpowers') 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 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 (limited to 'docs/superpowers') 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 ca69bd81057fc8a9f65ff7e2f1d874697df8a6cc Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 4 Nov 2026 18:08:01 +0100 Subject: 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, waiting for user storage after boot, FAB content-desc, text field clearing, run-as quoting) and the last device runs, 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. The cleanup plan's emulator pass gets the harness fixes found while running it. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 54 ++++++++++++++++------ CONTRIBUTING.md | 2 +- .../plans/2026-11-04-compose-cleanup.md | 14 +++--- .../specs/2026-11-02-compose-ui-overhaul-design.md | 21 ++++++--- 4 files changed, 63 insertions(+), 28 deletions(-) (limited to 'docs/superpowers') diff --git a/AGENTS.md b/AGENTS.md index f2e32456..47028103 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,12 @@ Modules: `app` (UI, git sync, autofill service), `autofill-parser`, `crypto:comm `crypto:pgpainless`, `format:common`, `passgen:diceware`, `passgen:random`, `ui:compose`, `coroutine-utils`. Build conventions live in `build-logic/`. +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. + `applicationId` is `xyz.danix.hardenedpass` (debug: `.debug` suffix, label "HardenedPass Debug"), so it installs next to upstream's `app.passwordstore` and never updates over it. The Kotlin package stays `app.passwordstore`; renaming it is churn with no user-visible effect. @@ -21,11 +27,7 @@ Keep "GrapheneOS" out of the app name: it is that project's trademark. ## Repository layout and remotes -- `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. +- `master` is the only branch. Upstream's `develop` was renamed; do not recreate it. - `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 @@ -69,10 +71,23 @@ in `local.properties`: $ANDROID_HOME/emulator/emulator -avd aps-test -no-window -no-audio -no-boot-anim \ -no-snapshot-save -gpu swiftshader_indirect & - ./gradlew installDebug - -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. + ./gradlew :app:installDebug + +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. FABs ("Import PGP key", "New +entry") carry only `content-desc`, and the list's search bar is itself an `EditText`. 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. After boot, wait until the keyguard is dismissed and user +storage is unlocked before `adb uninstall` or `push-keys`: earlier, uninstall fails with +`DELETE_FAILED_INTERNAL_ERROR` and `/sdcard/Download` does not exist yet. In a Compose text +field, wait about a second after the tap before clearing it (Ctrl+A, DEL), or the clear misses. +`adb shell run-as sh -c '...'` needs the whole command quoted as one `adb` argument +(`adb shell "run-as sh -c '...'"`), or the device shell splits it. Fingerprint: `adb emu finger touch 1` after enrolling one in Settings (needs a lock PIN: `adb shell locksettings set-pin 1234` on the emulator only). Disable animations (`settings put global animator_duration_scale 0`, same for `transition_` and `window_`) @@ -85,8 +100,11 @@ worktree with `~/.android/debug.keystore`, so an in-place upgrade between them n re-signing. 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: +`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: - `adb reverse tcp:2222 tcp:2222` lets the device clone `ssh://git@127.0.0.1:2222/...` from the e2e server over USB; serve a local login page the same way (`python3 -m http.server` on @@ -102,9 +120,10 @@ real device with a trusted browser (e.g. Firefox) instead: - Only read screens of the debug app, the test page and the autofill UI; the device holds the owner's real data. -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. +Last full emulator run (2026-11-04, 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-04 (fill, passphrase +cache rule, no keys flow, picker, settings dialog), all passed. ## Verified facts and gotchas @@ -122,6 +141,13 @@ passed. - `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. +- 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). - sshj gets Ed25519 keys from Bouncy Castle: `SshjConfig.kt` pins `KeyFactory.Ed25519` to it at provider position 1. Conscrypt from Android 14 mainline updates serves that factory with keys sshj maps to `KeyType.UNKNOWN` ("Don't know how to encode key"), breaking every Ed25519 host diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f83d413..f681cf52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ We bundle a [`ignore-revs-file`](https://git-scm.com/docs/git-blame#Documentatio ## Source code conventions - Unless you're absolutely sure what you're doing, always prefer the [`unsafeLazy`](https://github.com/android-password-store/Android-Password-Store/blob/f870dd49138d63d9803783458abf1356176851db/app/src/main/java/app/passwordstore/util/extensions/Extensions.kt#L68) method over Kotlin's inbuilt `lazy` for lazily evaluated properties. -- 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. +- The UI is Jetpack Compose (Material 3): screens are stateless composables driven by a ViewModel. Do not add XML layouts, Fragments or view binding. ## Building the project diff --git a/docs/superpowers/plans/2026-11-04-compose-cleanup.md b/docs/superpowers/plans/2026-11-04-compose-cleanup.md index a09d545b..1e23ed22 100644 --- a/docs/superpowers/plans/2026-11-04-compose-cleanup.md +++ b/docs/superpowers/plans/2026-11-04-compose-cleanup.md @@ -1003,7 +1003,7 @@ 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_field() { e2e_tap 'class="android.widget.EditText"' "$1"; sleep 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]*"'; } @@ -1044,6 +1044,8 @@ until [ "$(adb shell getprop sys.boot_completed | tr -d '\r')" = 1 ]; do sleep 2 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 +until [ "$(adb shell getprop sys.user.0.ce_available | tr -d '\r')" = true ] && \ + adb shell test -d /sdcard/Download; 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 @@ -1051,7 +1053,7 @@ 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`.) +(`set-pin` fails harmlessly when the AVD already has the PIN. The wait matters: before the keyguard is dismissed and user storage is unlocked, `adb uninstall` fails with `DELETE_FAILED_INTERNAL_ERROR` and `/sdcard/Download` does not exist. `installDebug` and `push-keys` honor `ANDROID_SERIAL`.) - [ ] **Step 3: Clone over SSH, PGP import** @@ -1063,10 +1065,10 @@ 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_tap 'content-desc="Import PGP key"'; 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.) +Expected: `MainActivity` on top with the cloned store (`example.org`, `work`, `otp`). (FABs expose their label as `content-desc`, not `text`, in the dump.) - [ ] **Step 4: Decrypt** @@ -1097,7 +1099,7 @@ The OTP screen never goes idle (no `uiautomator dump` on it) and `screencap` is ```bash source SCRATCHPAD/e2e.sh [ "$ANDROID_SERIAL" = emulator-5554 ] || exit 1 -e2e_tap 'text="New entry"'; e2e_tap 'text="Create new password"'; e2e_secure +e2e_tap 'content-desc="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"' @@ -1244,7 +1246,7 @@ Expected: the View credential dialog shows the message, a password field (`passw ```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' +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"' 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 3282c270..0404b2fb 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 @@ -1,6 +1,6 @@ # Compose UI overhaul: design -Date: 2026-11-02. Status: approved, not started. +Date: 2026-11-02. Status: complete (2026-11-04); merges into `master` after the maintainer's phone test. ## Goal @@ -138,10 +138,14 @@ order (both removed) becomes folders first, and the unused OpenKeychain SSH key ### Removed at the end -`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. +`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`. ## Security @@ -162,7 +166,9 @@ Work happens on a temporary `compose` branch, an explicit exception to the maste AGENTS.md (recorded there when the branch is created, removed at merge). `master` stays releasable for the monthly public suffix list update and hotfixes to 0.1.4. The branch is pushed 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. +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. ## Migration order @@ -190,4 +196,5 @@ graph through their old intents. (`scripts/e2e-env.sh`). - 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. + then the maintainer tests a release build of `compose` on the phone; the merge waits for the + maintainer's go. -- cgit v1.2.3