aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-11-02-compose-editor.md3295
-rw-r--r--docs/superpowers/plans/2026-11-02-compose-entry.md2149
-rw-r--r--docs/superpowers/plans/2026-11-02-compose-list.md1901
-rw-r--r--docs/superpowers/plans/2026-11-03-compose-settings.md3709
-rw-r--r--docs/superpowers/plans/2026-11-03-compose-setup.md3790
-rw-r--r--docs/superpowers/plans/2026-11-04-compose-autofill.md1864
-rw-r--r--docs/superpowers/plans/2026-11-04-compose-cleanup.md1712
-rw-r--r--docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md97
8 files changed, 18502 insertions, 15 deletions
diff --git a/docs/superpowers/plans/2026-11-02-compose-editor.md b/docs/superpowers/plans/2026-11-02-compose-editor.md
new file mode 100644
index 00000000..dbeb76db
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-02-compose-editor.md
@@ -0,0 +1,3295 @@
+# Compose UI overhaul, step 4: Entry editor. 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 `PasswordCreationActivity`, its generator dialogs, the manual OTP dialog and `SelectFolderActivity` with Compose: an editor destination (`EditRoute`) in `MainActivity`, opened from the list's create sheet and from the entry view's edit action; a generator bottom sheet with random and diceware tabs; OTP from QR (camera or image) and manual; a folder picker dialog shared by the editor and the list's move action. The autofill save flow hosts the same editor. Every save-path check of 0.1.2 stays.
+
+**Architecture:** `EditRoute(dir, name?)` gets an `EditorViewModel` per back stack entry. The form (`EditorForm`: name, folder, password, username, extra) lives in Compose state in that ViewModel only. A new entry starts empty in the list's folder. An edit copies its plaintext once from the `EntryViewModel` of the `EntryRoute` below it on the back stack (`nav.getBackStackEntry<EntryRoute>()`), so plaintext never travels in a route or saved state and no second passphrase prompt is needed. Pure logic (form to pass file, validation, target file, overwrite check, atomic write, OTP URI, generator options) sits in `EditorModel.kt` with unit tests. Saving runs in `activity.lifecycleScope`, like the list's store operations: the ViewModel encrypts (CryptoRepository, unchanged) and writes off the main thread, the destination commits (`commitChange` needs the activity). `FLAG_SECURE` moves out of the destinations into a counted hold in `MainActivity` (`SecureWindow`), so entry and editor can overlap during a transition without clearing it. `AutofillSaveActivity` sets `FLAG_SECURE` for its window and shows `EditorDestination` directly. The legacy editor, its dialogs and layouts, the folder picker activity and fragment, and the now unused `flowbinding` and `swiperefreshlayout` dependencies are deleted.
+
+**Tech Stack:** Kotlin 2.4.21, Compose BOM 2024.09.03 (foundation 1.7.3: `KeyboardOptions(autoCorrectEnabled = ...)`; Material 3 1.3.0: `ModalBottomSheet(properties = ModalBottomSheetProperties(securePolicy = ...))`, `PrimaryTabRow`, `Tab`), navigation-compose 2.8.9 (`getBackStackEntry<T>()`, `popBackStack<T>(inclusive)`), `hilt-navigation-compose` 1.2.0 (`hiltViewModel(viewModelStoreOwner)`), zxing-android-embedded (unchanged), passgen modules (unchanged), kotlin-result 2.0.0. No new dependencies.
+
+Spec: `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`. Earlier plans: `2026-11-02-compose-foundation.md`, `2026-11-02-compose-list.md`, `2026-11-02-compose-entry.md` (same folder).
+
+## Decisions taken in this plan
+
+- **How plaintext reaches the editor: the entry view's ViewModel.** The `EditRoute` composable looks up the `EntryRoute` back stack entry below it and reads `hiltViewModel<EntryViewModel>(that entry).entry`, then the editor copies it into its own ViewModel once (`EditorViewModel.start`, a no-op afterwards). Rejected: nav arguments or saved state (plaintext in a Bundle, forbidden); re-decrypting in the editor (a second passphrase prompt, or a silent dependency on the cache); an activity-scoped holder (outlives the entry). Consequences, all safe: after process death the restored `EditRoute` finds no plaintext and closes, so the entry view asks for the passphrase again (`ponytail:` marked). The entry's auto-close timer may drop its copy while the editor is open; the editor already has its own, and cancelling returns to an entry that then closes itself, as the old decrypt activity did.
+- **Routes and returns.** `EditRoute(dir, name = null)`: `dir` relative to the store root, `name` the entry's file name without `.gpg` (null for a new entry). From the list: `EditRoute(path)`. From the entry view: `EditRoute(folder, name)`. After save: pop to the topmost `ListRoute` (it re-reads the store on resume); with none below (entry opened from a shortcut) the list root replaces the stack, as step 3 decided. After cancel (close icon or system back): pop, back to the list or the entry view. Both go through one guarded path: only the editor on top acts, nothing happens while a save runs, the keyboard is hidden and focus cleared first.
+- **Folder picker: a dialog, not a destination.** `FolderPicker` (breadcrumbs plus subfolder rows with counts, New folder, Select, Cancel) returns its pick through a callback into the caller's own state, so there is no result channel across back stack entries. It shows no secrets; its window inherits `FLAG_SECURE` from the editor below. It replaces `SelectFolderActivity` for the editor and the list's move action. "New folder" creates a folder inside the folder shown and opens it, through the list's own `FolderNameDialog` and `folderNameError` (blank, already a file or folder, outside the store by path components), shared rather than copied (maintainer decision 2).
+- **Autofill save hosts the Compose editor.** `AutofillSaveActivity` stays an activity (the autofill service starts it) but now calls `setContent { EditorDestination(...) }` with the form prefilled from its intent, instead of starting `PasswordCreationActivity` for a result. It sets `FLAG_SECURE`, uses `NoActionBarThemeM3` and `configChanges="orientation|screenSize"`. After saving it records the autofill match and, for "generate password", returns the fill dataset, exactly as before. This is what lets the legacy editor and its dialogs go in this step (open question 1).
+- **FLAG_SECURE is counted.** `MainActivity.holdSecure()` / `releaseSecure()` and a private top-level `SecureWindow(activity)` composable (a `DisposableEffect`) wrap the entry and editor destinations in the `NavHost`. The flag goes on with the first hold and off with the last release, so entry to editor and back never clears it while either is composed. The `EntryDestination`'s own add/clear pair and its `ponytail:` note are removed. The generator sheet and the manual OTP dialog set `SecureFlagPolicy.SecureOn` explicitly; the picker inherits.
+- **Save path, every 0.1.2 check kept, three old gaps closed.** In order: `formError` (empty name, `/` in the name, empty password and extra: same messages); `isInsideRepository` on the target (component-wise since 0.1.2), now checked before anything is created (the old editor created the folder first, so a crafted autofill folder name such as `site/../../x` could create directories outside the store); recipients from the nearest `.gpg-id` (`getPGPIdentifiers`, same snackbars for short or invalid IDs); refusal to overwrite another entry, now also when an edit only moves to another folder (the old check missed that case and overwrote silently); encryption to every recipient, refused when a key is missing (`CryptoRepository.encrypt`, untouched): nothing is written and nothing committed, the folder is not even created; the write goes through a temporary file moved over the target, so a failed write cannot truncate the existing entry; on rename or move the old file is deleted, now also for a move without rename (the old editor left a copy), and its autofill matches and recently-used time follow it; then `git add` and commit with the old messages.
+- **Generator sheet.** One `ModalBottomSheet`, tabs Random and Diceware, the first tab chosen by the existing "Password generator" setting (`PREF_KEY_PWGEN_TYPE`, default diceware). Options, preference files and keys are the old ones (`PasswordGenerator` file for random, `pwgen` for diceware), so stored choices carry over. Every change regenerates (as the old dialogs did), Generate rolls again, Use fills the password. Errors show in the sheet instead of a toast. Generation runs off the main thread (the diceware word list is read on first use). Lengths are capped by the fields (3 digits, 2 for diceware words), `ponytail:` marked.
+- **OTP.** "OTP from QR" opens a small menu: "Scan QR code" (zxing `CaptureActivity`, unchanged, only when the device has a camera) and "Choose an image" (decoded off the main thread). "Manual OTP" opens a dialog with secret and account; spaces in the secret are dropped. The resulting URI line is appended to the extra content on its own line. Both actions hide once the extra content holds a TOTP secret, as before.
+- **Username always shown; "Encrypt username" switch dropped.** The spec puts a username field on the editor; the switch only moved text between the name and username fields for the file-based layout (open question 3).
+- **"Save and copy" kept, in the overflow.** It now copies only after a successful save, through `copyPasswordToClipboard` (sensitive flag and clear timer). The spec's top bar has Save only (open question 4).
+- **"No keys imported" prompt not ported to the editor.** The entry view still has it, and the editor can only edit an entry the entry view decrypted. A new entry on a store without keys fails at encryption with the 0.1.2 refusal ("Could not encrypt the password, nothing was saved: ...").
+- **Keyboard types.** Password and extra content use `KeyboardType.Password` (multi-line for the extras), keeping the old `textVisiblePassword` intent that keyboards do not learn them; Compose has no visible-password type, and the text stays visible unless masked by the reveal toggle. Name and username turn autocorrect off.
+- **Password visibility** starts from the "Show the password" setting, as in the entry view.
+- **Commit failures are not surfaced**, exactly like the list's move and delete (`commitChange` result ignored); a theme or locale change in the middle of a save cancels before the commit, and the next commit picks the file up. `ponytail:` marked.
+
+## Feature parity
+
+### `PasswordCreationActivity`
+
+| Behavior today | Decision |
+|---|---|
+| `FLAG_SECURE` on the whole activity (`BasePGPActivity`) | Port: counted hold while the editor is composed; `AutofillSaveActivity` sets it for its window |
+| Title "New password" / "Edit password", up arrow cancels | Port: "New entry" / "Edit entry" (the app's word), close icon and system back through one guarded close path |
+| Name field, focused on a new entry | Port (focus only on first show, not after recreation) |
+| Directory field: hidden for new entries from the list, editable text for edits and autofill, created with `createDirectories` before any check | Changed: breadcrumb chips plus "Change" (picker) everywhere; the folder is created when the file is written, after the inside-repository check |
+| Username field shown only for the encrypted-username layout or when prefilled | Changed: always shown (spec) |
+| "Encrypt username" switch (file-based layout, new entries) | Drop: both fields are always visible (open question 3) |
+| Password field, visible while typing, masked when prefilled or generated, toggle | Port: reveal toggle, initial state from "Show the password", monospace |
+| "Generate" button opens the classic or diceware dialog per setting | Port: generator icon in the password field opens the sheet; the setting picks the first tab |
+| Extra content, multi-line, `textVisiblePassword` | Port: multi-line, `KeyboardType.Password` |
+| "Add OTP" hidden once the extras hold a TOTP secret | Port (both OTP actions) |
+| OTP: scan QR (camera only), choose an image, enter manually; manual only without a camera | Port: "OTP from QR" menu (scan with a camera, image always), "Manual OTP" dialog |
+| OTP import snackbars (success, failure, no image selected) | Port |
+| Save | Port: top bar |
+| Save and Copy (copied before encrypting, even when the save then failed) | Port in the overflow; copies only after a successful save (open question 4) |
+| `requireKeysExist` "No keys imported" dialog with import | Drop: see decisions; a save without the recipient key is refused and writes nothing |
+| Validation: empty name, `/` in the name, empty password and extra content | Port (`formError`, same strings) |
+| Recipients from the nearest `.gpg-id`, snackbars for short or invalid IDs | Port (`getPGPIdentifiers`) |
+| Encrypt to every recipient, refuse when a key is missing (0.1.2) | Port unchanged (`CryptoRepository.encrypt`) |
+| Encrypt in memory before writing; nothing written or committed on failure (0.1.2) | Port; the target folder is now also left uncreated |
+| Duplicate check for new entries and renames | Port and fix: also for a move to another folder |
+| `isInsideRepository` on the target (after creating the folder) | Port, checked first |
+| File written in place | Changed: temporary file moved over the target |
+| Rename deletes the old file when the name changed | Port and fix: also when only the folder changed |
+| Old file not deletable: dialog, no commit, editor closes | Changed: long snackbar, the new file is committed and the editor closes |
+| Recently-used time carried over on rename | Port (`moveHistory`, shared with the list's folder rename) |
+| Autofill matches of a renamed entry | New: updated like the list's move does (the old editor lost them) |
+| Write error: dialog, editor closes | Changed: dialog, the editor stays open with its content |
+| Commit "Add generated password for ..." / "Edit password for ..." | Port (named after the new location) |
+| Result extras for the caller (created file, name, long name; password and username when generated) | Port as `SaveResult.Saved` for autofill; `MainActivity` returns to the list |
+| Rotation recreates the activity; the fields' text went into the saved instance state | Changed: no recreation (`configChanges`), form in ViewModel memory only, never in saved state |
+
+### Generator dialogs
+
+| Behavior today | Decision |
+|---|---|
+| Random: numerals, symbols, uppercase, lowercase, pronounceable, ambiguous, length; `PasswordGenerator` preferences | Port, same keys and defaults (length 20; 16 when the field is empty) |
+| Diceware: word count, separator; `pwgen` preferences | Port, same keys and defaults (5 words, `-`) |
+| Regenerate on every change, "Generate", OK, Cancel | Port: regenerate on change, "Generate", "Use", dismiss cancels |
+| Generator errors as a toast | Changed: shown in the sheet |
+| Monospace preview | Port |
+| Generation on the main thread | Changed: off the main thread |
+| Dialog window inherits the secure flag | Changed: `SecureOn` explicitly |
+
+### OTP import
+
+| Behavior today | Decision |
+|---|---|
+| Manual dialog: secret, account; `otpauth://totp?secret=...&issuer=...`; a blank secret appended an empty string | Port: OK disabled while the secret is blank; spaces dropped; issuer percent-encoded |
+| zxing `CaptureActivity` launched for a result | Keep as is |
+| QR image decoded on the main thread | Port, off the main thread |
+
+### Folder picker (`SelectFolderActivity`, `SelectFolderFragment`)
+
+| Behavior today | Decision |
+|---|---|
+| Separate activity with a RecyclerView of folders (hidden ones per "Show hidden contents", never `.git`), fast scroller, Select menu item, up arrow | Replace: `FolderPicker` dialog with breadcrumbs (jump up), folder rows with counts, Select, Cancel; same hidden filter; no fast scroller |
+| No way to create a folder (the old edit screen took a typed path instead) | New: "New folder" in the picker, the list's dialog and checks |
+| Used by the list's move action | Port: the list's move uses `FolderPicker` |
+
+### Deleted in this step (Task 7)
+
+`PasswordCreationActivity.kt`, `EditPasswordScreen.kt` (upstream WIP), `PasswordGeneratorDialogFragment.kt`, `DicewarePasswordGeneratorDialogFragment.kt`, `OtpImportDialogFragment.kt`, `SelectFolderActivity.kt`, `SelectFolderFragment.kt`, `PasswordItemRecyclerAdapter.kt`, layouts `password_creation_activity.xml`, `fragment_pwgen.xml`, `fragment_pwgen_diceware.xml`, `fragment_manual_otp_entry.xml`, `select_folder_layout.xml`, `password_recycler_view.xml`, `password_row_layout.xml`, menus `pgp_handler_new_password.xml`, `pgp_handler_select_folder.xml`, drawables `ic_save_24dp.xml`, `ic_save_copy_24dp.xml`, ui:compose `PasswordField.kt` (`PasswordField`, `CopyButton`: only `EditPasswordScreen` used them), the `flowbinding` and `swiperefreshlayout` dependencies (only the deleted dialogs and `password_recycler_view.xml` used them), the two manifest entries and the two baseline issues in the deleted dialogs.
+
+**What remains of `BasePGPActivity`** (for `AutofillDecryptActivity` until step 7): the class with `requireKeysExist`, the injected `settings`, `repository`, `dispatcherProvider` and `FLAG_SECURE` in `onCreate`; the companion's `EXTRA_FILE_PATH` (`LaunchActivity`, `MainActivity`), `getRelativePath`, `getParentPath`, `getLongName` (`PasswordItem`, `StoreActions`, entry and autofill); the `copyTextToClipboard`, `copyPasswordToClipboard` and `getPGPIdentifiers` extensions. Removed: `repoPath`, `fullPath`, `EXTRA_REPO_PATH` (only the editor used them).
+
+**Kept for later steps:** `PasswordDialog` and `dialog_password_entry.xml` (`AutofillDecryptActivity`, step 7); ui:compose `PasswordItem.kt` and `APSAppBar.kt` (unrelated to the editor; `PGPKeyListActivity` uses `APSAppBar`, steps 5 and 8); `recyclerview`, `recyclerview-selection`, `fastscroll`, `SearchableRepositoryAdapter`, `password_row_background.xml` (autofill filter and git log, steps 6 to 8).
+
+## Lessons from steps 2 and 3, designed in
+
+- `MainActivity` keeps `configChanges="orientation|screenSize"`; `AutofillSaveActivity` gets the same for the same reason (a save running in `lifecycleScope` must survive rotation).
+- Slack compose-lint: no composable members on activities (`SecureWindow` is a private top-level function taking the activity); every public composable has `modifier: Modifier = Modifier` as its first optional parameter; no collection parameters (the picker reads its own rows, kept as `ImmutableList`); `EditorViewModel` is a default parameter of `EditorDestination` and only method references and values are passed down.
+- Accessibility: every `IconButton` has a content description (close, more options, show or hide password, generate); the editor has no FAB.
+- Compose 1.7 focus and keyboard: the editor requests focus only on its first showing per ViewModel (name for a new entry, username for an autofill prefill, nothing for an edit); the guarded close path hides the keyboard and clears focus before leaving, so neither the entry view nor the list comes back with a stray keyboard (the list's own return handling from step 2 still applies).
+- Dialogs and sheets are composed only while shown, so each opening starts fresh; the passphrase-style `remember`, never `rememberSaveable`, holds the manual OTP secret and the generated preview.
+- Double submit: `EditorViewModel.save` sets `saving` before its first suspension (`lifecycleScope` runs on `Main.immediate`), Save is disabled while it is set, and it stays set after a success; the list's picker ignores a second Select.
+- Off the main thread: encryption (CryptoRepository), the overwrite check, the write and delete, subfolder listing, QR decoding, password generation. The `.gpg-id` read stays on the main thread inside `getPGPIdentifiers` (it shows snackbars), as in the entry view.
+- One ViewModel per back stack entry (`hiltViewModel()` in the destination).
+
+**Conventions for every task**
+
+- New files get this header:
+ ```kotlin
+ /*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * 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 <noreply@anthropic.com>`.
+- 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: Editor model and folder listing (TDD)
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt`
+- Test: `app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/list/ListModel.kt`
+- Modify: `app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt`
+
+- [ ] **Step 1: Failing tests for the editor model**
+
+`app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import app.passwordstore.R
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.passgen.random.PasswordOption
+import app.passwordstore.util.time.UserClock
+import app.passwordstore.util.totp.UriTotpFinder
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class EditorModelTest {
+
+ private val root = Files.createTempDirectory("aps").toFile()
+
+ @AfterTest
+ fun cleanUp() {
+ root.deleteRecursively()
+ }
+
+ private fun entry(content: String) =
+ PasswordEntry(UserClock(), UriTotpFinder(), content.encodeToByteArray())
+
+ @Test
+ fun editingWithoutChangesKeepsThePassFile() {
+ val files =
+ listOf(
+ "hunter2\nusername: alice\nurl: https://example.org\n",
+ "s3cret\notpauth://totp/example.com:carol?secret=JBSWY3DPEHPK3PXP&issuer=example.com\n",
+ )
+ for (content in files) assertEquals(content, passContent(editorForm(entry(content), "", "x")))
+ }
+
+ @Test
+ fun formFromAnEntrySplitsItsFields() {
+ val form =
+ editorForm(entry("hunter2\nusername: alice\nurl: https://example.org\n"), "work", "mail")
+ assertEquals(EditorForm("mail", "work", "hunter2", "alice", "url: https://example.org\n"), form)
+ }
+
+ @Test
+ fun newEntryContent() {
+ assertEquals(
+ "pw\nusername: bob\nnote: x",
+ passContent(EditorForm(name = "n", password = "pw", username = "bob", extra = "note: x")),
+ )
+ assertEquals("pw\n", passContent(EditorForm(name = "n", password = "pw")))
+ }
+
+ @Test
+ fun formErrors() {
+ assertEquals(R.string.file_toast_text, formError(EditorForm(name = " ", password = "pw")))
+ assertEquals(
+ R.string.invalid_filename_text,
+ formError(EditorForm(name = "a/b", password = "pw")),
+ )
+ assertEquals(R.string.empty_toast_text, formError(EditorForm(name = "a", username = "bob")))
+ assertNull(formError(EditorForm(name = "a", extra = "note")))
+ assertNull(formError(EditorForm(name = "a", password = "pw")))
+ }
+
+ @Test
+ fun entryFileTrimsTheNameAndUsesTheFolder() {
+ assertEquals(File(root, "work/mail.gpg"), entryFile(root, EditorForm(name = " mail ", dir = "work")))
+ assertEquals(File(root, "mail.gpg"), entryFile(root, EditorForm(name = "mail")))
+ }
+
+ @Test
+ fun overwritesOnlyAnotherFile() {
+ val taken = File(root, "a.gpg").apply { writeText("x") }
+ assertFalse(overwrites(File(root, "work/b.gpg"), null))
+ assertTrue(overwrites(taken, null))
+ assertFalse(overwrites(taken, taken))
+ assertTrue(overwrites(taken, File(root, "work/b.gpg")))
+ }
+
+ @Test
+ fun writeEntryCreatesTheFolderAndReplacesTheFile() {
+ val target = File(root, "new/dir/a.gpg")
+ writeEntry(target, byteArrayOf(1))
+ writeEntry(target, byteArrayOf(2, 3))
+ assertContentEquals(byteArrayOf(2, 3), target.readBytes())
+ assertEquals(listOf("a.gpg"), target.parentFile!!.list()!!.toList())
+ }
+
+ @Test
+ fun linesGoOnTheirOwnLine() {
+ assertEquals("otpauth://x", withLine("", "otpauth://x"))
+ assertEquals("a\notpauth://x", withLine("a", "otpauth://x"))
+ assertEquals("a\notpauth://x", withLine("a\n", "otpauth://x"))
+ }
+
+ @Test
+ fun manualTotpUriIsReadBackByTheParser() {
+ val uri = totpUri(" JBSW Y3DP EHPK 3PXP ", "Example Co")!!
+ assertEquals("JBSWY3DPEHPK3PXP", UriTotpFinder().findSecret(uri))
+ assertEquals("Example Co", UriTotpFinder().findIssuer(uri))
+ assertTrue(entry("PLACEHOLDER\n$uri").hasTotp())
+ assertEquals("otpauth://totp?secret=JBSWY3DPEHPK3PXP", totpUri("JBSWY3DPEHPK3PXP", " "))
+ assertNull(totpUri(" ", "Example Co"))
+ }
+
+ @Test
+ fun randomOptionsFollowTheCheckboxes() {
+ assertEquals(listOf(PasswordOption.FullyRandom), RandomSpec().options())
+ assertEquals(
+ listOf(
+ PasswordOption.NoDigits,
+ PasswordOption.AtLeastOneSymbol,
+ PasswordOption.NoUppercaseLetters,
+ PasswordOption.NoLowercaseLetters,
+ PasswordOption.NoAmbiguousCharacters,
+ ),
+ RandomSpec(
+ numerals = false,
+ symbols = true,
+ uppercase = false,
+ lowercase = false,
+ ambiguous = false,
+ pronounceable = true,
+ )
+ .options(),
+ )
+ }
+
+ @Test
+ fun randomPasswordOrWhyNot() {
+ assertEquals(20, randomPassword(RandomSpec()).value.length)
+ assertEquals(
+ R.string.pwgen_no_chars_error,
+ randomPassword(RandomSpec(numerals = false, uppercase = false, lowercase = false)).error,
+ )
+ assertEquals(
+ R.string.pwgen_length_too_short_error,
+ randomPassword(RandomSpec(length = 2)).error,
+ )
+ }
+}
+```
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.editor.EditorModelTest`
+Expected: compilation FAILS with `Unresolved reference 'EditorForm'` (and the other new names).
+
+- [ ] **Step 2: The editor model**
+
+`app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Immutable
+import app.passwordstore.R
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.passgen.random.MaxIterationsExceededException
+import app.passwordstore.passgen.random.NoCharactersIncludedException
+import app.passwordstore.passgen.random.PasswordGenerator
+import app.passwordstore.passgen.random.PasswordLengthTooShortException
+import app.passwordstore.passgen.random.PasswordOption
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.mapError
+import com.github.michaelbull.result.runCatching
+import java.io.File
+import java.io.IOException
+import java.net.URLEncoder
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+
+/**
+ * What the editor edits. [dir] is the folder relative to the store root ("" is the root), [name]
+ * the file name without `.gpg`. Plaintext: held in memory only, never in saved state or a route.
+ */
+@Immutable
+data class EditorForm(
+ val name: String = "",
+ val dir: String = "",
+ val password: String = "",
+ val username: String = "",
+ val extra: String = "",
+)
+
+/** The form for [entry], stored as [name] in [dir], split as the old editor split it. */
+fun editorForm(entry: PasswordEntry, dir: String, name: String): EditorForm =
+ EditorForm(
+ name = name,
+ dir = dir,
+ password = entry.password.orEmpty(),
+ username = entry.username.orEmpty(),
+ extra = entry.extraContentWithoutUsername,
+ )
+
+/** The pass file, as the old editor wrote it: password, `username: ...` when set, the extras. */
+fun passContent(form: EditorForm): String {
+ val username = if (form.username.isEmpty()) "" else "\nusername: ${form.username}"
+ return "${form.password}$username\n${form.extra}"
+}
+
+/** Why [form] cannot be saved, as a string resource, or null when it can. */
+@StringRes
+fun formError(form: EditorForm): Int? =
+ when {
+ form.name.isBlank() -> R.string.file_toast_text
+ '/' in form.name -> R.string.invalid_filename_text
+ form.password.isEmpty() && form.extra.isEmpty() -> R.string.empty_toast_text
+ else -> null
+ }
+
+/** Where [form] is saved in the store at [root]. */
+fun entryFile(root: File, form: EditorForm): File =
+ File(File(root, form.dir), "${form.name.trim()}.gpg")
+
+/** True when saving to [target] would replace a file other than the [original] being edited. */
+fun overwrites(target: File, original: File?): Boolean =
+ target.exists() && target.canonicalPath != original?.canonicalPath
+
+/**
+ * Writes [bytes] to [target], creating its folder, through a temporary file moved over it, so a
+ * failed write never truncates an existing entry.
+ */
+fun writeEntry(target: File, bytes: ByteArray) {
+ val dir = target.parentFile ?: throw IOException("No folder for $target")
+ dir.mkdirs()
+ val temp = File(dir, ".${target.name}.tmp")
+ try {
+ temp.writeBytes(bytes)
+ Files.move(
+ temp.toPath(),
+ target.toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ StandardCopyOption.ATOMIC_MOVE,
+ )
+ } finally {
+ temp.delete()
+ }
+}
+
+/** [extra] with [line] added on a line of its own. */
+fun withLine(extra: String, line: String): String =
+ if (extra.isEmpty() || extra.endsWith('\n')) "$extra$line" else "$extra\n$line"
+
+/**
+ * An `otpauth://totp` URI, as the old manual OTP dialog built it, for a typed [secret] (spaces
+ * dropped: authenticators show it in groups) and an optional [issuer]; null for a blank secret.
+ */
+fun totpUri(secret: String, issuer: String): String? {
+ val key = secret.filterNot(Char::isWhitespace)
+ if (key.isEmpty()) return null
+ fun encode(s: String) = URLEncoder.encode(s, "UTF-8").replace("+", "%20")
+ val tail = if (issuer.isBlank()) "" else "&issuer=${encode(issuer.trim())}"
+ return "otpauth://totp?secret=${encode(key)}$tail"
+}
+
+/** The random generator's options, as its checkboxes show them; defaults are the old dialog's. */
+@Immutable
+data class RandomSpec(
+ val numerals: Boolean = true,
+ val symbols: Boolean = false,
+ val uppercase: Boolean = true,
+ val lowercase: Boolean = true,
+ val ambiguous: Boolean = true,
+ val pronounceable: Boolean = false,
+ val length: Int = 20,
+)
+
+/** The generator's options for this spec, negated where the generator asks what to leave out. */
+fun RandomSpec.options(): List<PasswordOption> =
+ listOfNotNull(
+ PasswordOption.NoDigits.takeUnless { numerals },
+ PasswordOption.AtLeastOneSymbol.takeIf { symbols },
+ PasswordOption.NoUppercaseLetters.takeUnless { uppercase },
+ PasswordOption.NoLowercaseLetters.takeUnless { lowercase },
+ PasswordOption.NoAmbiguousCharacters.takeUnless { ambiguous },
+ PasswordOption.FullyRandom.takeUnless { pronounceable },
+ )
+
+/** A random password for [spec], or the string resource saying why there is none. */
+fun randomPassword(spec: RandomSpec): Result<String, Int> =
+ runCatching { PasswordGenerator.generate(spec.options(), spec.length) }
+ .mapError { e ->
+ when (e) {
+ is MaxIterationsExceededException -> R.string.pwgen_max_iterations_exceeded
+ is NoCharactersIncludedException -> R.string.pwgen_no_chars_error
+ is PasswordLengthTooShortException -> R.string.pwgen_length_too_short_error
+ else -> R.string.pwgen_some_error_occurred
+ }
+ }
+
+/** The diceware generator's options; defaults are the old dialog's. */
+@Immutable data class DicewareSpec(val words: Int = 5, val separator: Char = '-')
+```
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.editor.EditorModelTest`
+Expected: `BUILD SUCCESSFUL`, 11 tests pass. If `manualTotpUriIsReadBackByTheParser` fails on the
+issuer, the parser (`com.eygraber.uri.Uri`) decodes differently from `URLEncoder`: compare with
+`UriTotpFinderTest` and adjust `encode` only, never the test's expectations.
+
+- [ ] **Step 3: Failing test for the picker's folder listing**
+
+In `app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt`, add inside the class, after
+the last test:
+
+```kotlin
+ @Test
+ fun pickerOffersFoldersByName() {
+ folder("b")
+ folder("A")
+ folder(".hidden")
+ folder(".git")
+ file("c.gpg")
+ assertEquals(listOf("A", "b"), subfolders(root, showHidden = false).map { it.name })
+ assertEquals(listOf(".hidden", "A", "b"), subfolders(root, showHidden = true).map { it.name })
+ assertEquals(emptyList<File>(), subfolders(File(root, "missing"), showHidden = false))
+ }
+```
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.list.ListModelTest`
+Expected: compilation FAILS with `Unresolved reference 'subfolders'`.
+
+- [ ] **Step 4: The folder listing**
+
+In `app/src/main/java/app/passwordstore/ui/list/ListModel.kt`, add after `childCount`:
+
+```kotlin
+/**
+ * Folders directly inside [dir] that the folder picker offers, by name: never `.git`, other hidden
+ * folders only with "Show hidden contents" ([showHidden]), as the list filters them.
+ */
+fun subfolders(dir: File, showHidden: Boolean): List<File> =
+ dir
+ .listFiles { f -> f.isDirectory && !f.name.startsWith(".git") && (showHidden || !f.isHidden) }
+ .orEmpty()
+ .sortedBy { it.name.lowercase() }
+```
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.list.ListModelTest`
+Expected: `BUILD SUCCESSFUL`.
+
+- [ ] **Step 5: Checks and commit**
+
+Run: `./gradlew spotlessApply && CI=1 ./gradlew :app:assembleDebug :app:testDebugUnitTest`
+Expected: `BUILD SUCCESSFUL`.
+
+```bash
+git add app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt \
+ app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt \
+ app/src/main/java/app/passwordstore/ui/list/ListModel.kt \
+ app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt
+git commit -m "feat(app): add the editor model and the picker's folder listing
+
+Pure functions for the Compose editor: the form and the pass file it
+writes, validation, the target file, the overwrite check, an atomic
+write, OTP URI building and the random generator's options. The folder
+picker lists subfolders with the list's hidden filter.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: Folder picker dialog; the list's move uses it
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/list/ListDestination.kt`
+- Modify: `app/src/main/res/values/strings.xml`
+- Modify: `app/src/main/res/values-it/strings.xml`
+
+`SelectFolderActivity` stays compiled but unused until Task 7.
+
+- [ ] **Step 1: The picker**
+
+`app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+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.platform.LocalContext
+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.FolderRow
+import app.passwordstore.ui.compose.crumbsFor
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.settings.PreferenceKeys
+import java.io.File
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * Picks a folder of the store at [root], starting at [start] (relative to the root, "" is the
+ * root): breadcrumbs jump up, rows open subfolders, "New folder" creates one inside the folder
+ * shown (the list's validation) and opens it, Select reports the folder shown, relative to the
+ * root. Used by the list's move action and by the editor. Shows no secrets; its window inherits
+ * FLAG_SECURE from the screen below.
+ */
+@Composable
+fun FolderPicker(
+ root: File,
+ start: String,
+ onPick: (relativePath: String) -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ var path by rememberSaveable { mutableStateOf(start) }
+ var naming by rememberSaveable { mutableStateOf(false) }
+ val showHidden = remember {
+ context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)
+ }
+ val rows by
+ produceState<ImmutableList<ListRow>>(persistentListOf(), path, naming) {
+ val dir = File(root, path)
+ value = withContext(Dispatchers.IO) { listRows(subfolders(dir, showHidden), dir).folders }
+ }
+ if (naming) {
+ // The list's own dialog and checks: blank, already a file or folder, outside the store
+ // (component-wise isInsideRepository).
+ val dir = File(root, path)
+ FolderNameDialog(
+ title = stringResource(R.string.title_create_folder),
+ message = null,
+ confirm = stringResource(R.string.button_create),
+ validate = { folderNameError(it, File(dir, it)) },
+ onConfirm = { name ->
+ val folder = File(dir, name).canonicalFile
+ folder.mkdirs()
+ naming = false
+ path = folder.relativeTo(root.canonicalFile).path
+ },
+ onDismiss = { naming = false },
+ )
+ }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ title = { Text(stringResource(R.string.folder_picker_title)) },
+ text = {
+ Column {
+ BreadcrumbBar(
+ crumbsFor(path),
+ stringResource(R.string.list_store_root),
+ onCrumbClick = { path = it },
+ modifier = Modifier.fillMaxWidth().padding(bottom = SpacingMedium),
+ )
+ // ponytail: a plain scrolling column, fine for the folders of one level; a LazyColumn
+ // if a store ever has thousands of subfolders in one folder.
+ Column(Modifier.heightIn(max = 360.dp).verticalScroll(rememberScrollState())) {
+ rows.forEach { row ->
+ key(row.path) {
+ FolderRow(
+ name = row.label,
+ count = row.count,
+ onClick = { path = File(row.path).relativeTo(root).path },
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { onPick(path) }) { Text(stringResource(R.string.crypto_select)) }
+ },
+ dismissButton = {
+ Row {
+ TextButton(onClick = { naming = true }) {
+ Text(stringResource(R.string.folder_picker_new))
+ }
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ }
+ },
+ )
+}
+```
+
+`mkdirs` runs on the main thread, as the list's own "Create folder" does: one directory, no
+listing. An empty folder is not tracked by git, so nothing is committed until an entry lands in
+it, exactly as with the list's "Create folder".
+
+- [ ] **Step 2: Share the list's folder name dialog**
+
+In `app/src/main/java/app/passwordstore/ui/list/ListDestination.kt`, replace
+
+```kotlin
+/** Asks for a folder name; [validate] returns an error string resource or null. */
+@Composable
+private fun FolderNameDialog(
+```
+
+with
+
+```kotlin
+/**
+ * Asks for a folder name; [validate] returns an error string resource or null. Shared with the
+ * folder picker's "New folder".
+ */
+@Composable
+internal fun FolderNameDialog(
+```
+
+(`internal`, not public: Slack's `ComposeModifierMissing` checks public composables only, and the
+dialog has no layout to modify. If lint still flags it, add `modifier: Modifier = Modifier` after
+`onDismiss` and pass it to its `AlertDialog`.)
+
+- [ ] **Step 3: Strings**
+
+In `app/src/main/res/values/strings.xml`, before `</resources>`, add:
+
+```xml
+
+ <!-- Compose folder picker -->
+ <string name="folder_picker_title">Choose a folder</string>
+ <string name="folder_picker_new">New folder</string>
+```
+
+In `app/src/main/res/values-it/strings.xml`, before `</resources>`, add:
+
+```xml
+
+ <!-- Compose folder picker -->
+ <string name="folder_picker_title">Scegli una cartella</string>
+ <string name="folder_picker_new">Nuova cartella</string>
+```
+
+- [ ] **Step 4: The list's move uses the picker**
+
+In `app/src/main/java/app/passwordstore/ui/list/ListDestination.kt`:
+
+Delete these imports:
+
+```kotlin
+import android.app.Activity
+import android.content.Intent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+```
+
+and
+
+```kotlin
+import app.passwordstore.ui.folderselect.SelectFolderActivity
+```
+
+In `enum class ListDialog`, replace
+
+```kotlin
+ Delete,
+```
+
+with
+
+```kotlin
+ Delete,
+ Move,
+```
+
+Delete the whole `moveLauncher` block:
+
+```kotlin
+ 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)
+ refresh()
+ }
+ }
+ }
+
+```
+
+Replace
+
+```kotlin
+ onMove = {
+ moveLauncher.launch(
+ Intent(activity, SelectFolderActivity::class.java)
+ .putExtra("Files", selected.toTypedArray())
+ )
+ },
+```
+
+with
+
+```kotlin
+ onMove = { dialog = ListDialog.Move },
+```
+
+In the `when (dialog)` block, after the `ListDialog.Delete -> Prompt(...)` case (before
+`ListDialog.Clone ->`), add:
+
+```kotlin
+ ListDialog.Move ->
+ FolderPicker(
+ root = root,
+ start = path,
+ onPick = { target ->
+ // One move per pick: a double tap on Select must not move twice.
+ if (dialog == ListDialog.Move) {
+ val files = selected.map(::File)
+ dialog = ListDialog.None
+ selected = emptyList()
+ activity.lifecycleScope.launch {
+ activity.moveItems(files, File(root, target))
+ refresh()
+ }
+ }
+ },
+ onDismiss = { dialog = ListDialog.None },
+ )
+```
+
+In the KDoc of `ListDestination`, replace
+
+```kotlin
+ * 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,
+ * [onOpenEntry] an entry file.
+```
+
+with
+
+```kotlin
+ * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the
+ * folder picker, the legacy activities and the store operations. [onOpenFolder] takes a path
+ * relative to the root, [onOpenEntry] an entry file.
+```
+
+- [ ] **Step 5: Checks and commit**
+
+Run: `./gradlew spotlessApply && CI=1 ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug`
+Expected: `BUILD SUCCESSFUL`. (Full move and "New folder" check on the emulator in Task 8,
+step 9.)
+
+```bash
+git add app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt \
+ app/src/main/java/app/passwordstore/ui/list/ListDestination.kt \
+ app/src/main/res/values/strings.xml app/src/main/res/values-it/strings.xml
+git commit -m "feat(app): add the Compose folder picker for moves
+
+A dialog with breadcrumbs, subfolder rows and New folder replaces the
+legacy folder picker activity for the list's move action; the editor will
+share it. New folder reuses the list's name dialog and checks. A second
+tap on Select no longer moves twice.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: Editor ViewModel
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/list/StoreActions.kt`
+- Modify: `app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt`
+
+- [ ] **Step 1: Shared recently-used move in StoreActions**
+
+In `app/src/main/java/app/passwordstore/ui/list/StoreActions.kt`, replace `renameFolder`:
+
+```kotlin
+/** Renames the folder [from] to [to], keeps its recently-used time, then commits. */
+suspend fun BaseGitActivity.renameFolder(from: File, to: File) {
+ if (!moveFile(from, to)) return
+ 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))
+}
+```
+
+with
+
+```kotlin
+/** Renames the folder [from] to [to], keeps its recently-used time, then commits. */
+suspend fun BaseGitActivity.renameFolder(from: File, to: File) {
+ if (!moveFile(from, to)) return
+ moveHistory(from, to)
+ commitChange(getString(R.string.git_commit_move_text, from.name, to.name))
+}
+
+/** Carries the "recently used" time of [from] over to [to]. */
+fun Context.moveHistory(from: File, to: File) {
+ val history = getSharedPreferences(HISTORY, Context.MODE_PRIVATE)
+ val time = history.getString(from.absolutePath.base64()) ?: return
+ history.edit {
+ remove(from.absolutePath.base64())
+ putString(to.absolutePath.base64(), time)
+ }
+}
+```
+
+- [ ] **Step 2: Diceware generator for ViewModels**
+
+In `app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt`, add the import
+
+```kotlin
+import dagger.hilt.android.components.ViewModelComponent
+```
+
+and replace
+
+```kotlin
+@InstallIn(FragmentComponent::class)
+```
+
+with
+
+```kotlin
+@InstallIn(FragmentComponent::class, ViewModelComponent::class)
+```
+
+(`FragmentComponent` goes with the legacy dialog in Task 7.)
+
+- [ ] **Step 3: The ViewModel**
+
+`app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.graphics.Bitmap
+import android.graphics.ImageDecoder
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
+import androidx.annotation.StringRes
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.content.edit
+import androidx.lifecycle.ViewModel
+import app.passwordstore.R
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.data.crypto.CryptoRepository
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.injection.prefs.PasswordGeneratorPreferences
+import app.passwordstore.passgen.diceware.DicewarePassphraseGenerator
+import app.passwordstore.passgen.random.PasswordOption
+import app.passwordstore.ui.list.moveHistory
+import app.passwordstore.util.autofill.AutofillMatcher
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.settings.PreferenceKeys
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.get
+import com.github.michaelbull.result.runCatching
+import com.google.zxing.BinaryBitmap
+import com.google.zxing.RGBLuminanceSource
+import com.google.zxing.common.HybridBinarizer
+import com.google.zxing.qrcode.QRCodeReader
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.IOException
+import javax.inject.Inject
+import kotlinx.coroutines.withContext
+import logcat.LogPriority.ERROR
+import logcat.asLog
+import logcat.logcat
+
+/** What a save did. */
+sealed interface SaveResult {
+
+ /**
+ * Written to [file] from [form]. [staleOriginal] is the old file of a rename or move, left
+ * behind because it could not be deleted.
+ */
+ data class Saved(val file: File, val form: EditorForm, val staleOriginal: File?) : SaveResult
+
+ /** Nothing was written; [message] (a string resource, formatted with [arg]) says why. */
+ data class Failed(@StringRes val message: Int, val arg: String = "") : SaveResult
+}
+
+/**
+ * The editor's state and work: the [form] (plaintext, in memory only: never in saved state, reset
+ * when this ViewModel is cleared), the generators and their stored options, QR decoding, and the
+ * save (encrypt, write, replace the original). Holds no Context; calls take one and use the
+ * application context.
+ */
+@HiltViewModel
+class EditorViewModel
+@Inject
+constructor(
+ private val repository: CryptoRepository,
+ private val entryFactory: PasswordEntry.Factory,
+ private val diceware: dagger.Lazy<DicewarePassphraseGenerator>,
+ @PasswordGeneratorPreferences private val pwgenPrefs: SharedPreferences,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel() {
+
+ /** The fields being edited. */
+ var form by mutableStateOf(EditorForm())
+
+ /** Set while a save runs, and after one succeeded (the editor is leaving): saves and closes wait. */
+ var saving by mutableStateOf(false)
+ private set
+
+ /** Whether [start] filled the form. */
+ var started = false
+ private set
+
+ /** Fills the form the first time only and returns true then; a recreated activity keeps edits. */
+ fun start(initial: EditorForm): Boolean {
+ if (started) return false
+ started = true
+ form = initial
+ return true
+ }
+
+ /** True when [extra] already holds a TOTP secret (the OTP actions hide), as the old editor did. */
+ fun hasOtp(extra: String): Boolean =
+ entryFactory.create("PLACEHOLDER\n$extra".encodeToByteArray()).hasTotp()
+
+ /** The random generator's options as the old dialog stored them. */
+ fun randomSpec(context: Context): RandomSpec {
+ val prefs = context.randomPrefs()
+ return RandomSpec(
+ numerals = !prefs.getBoolean(PasswordOption.NoDigits.key, false),
+ symbols = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false),
+ uppercase = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false),
+ lowercase = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false),
+ ambiguous = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false),
+ pronounceable = !prefs.getBoolean(PasswordOption.FullyRandom.key, true),
+ length = prefs.getInt(PreferenceKeys.LENGTH, 20),
+ )
+ }
+
+ /** Stores [spec], as the old dialog did on every generation, then generates off the main thread. */
+ suspend fun generateRandom(spec: RandomSpec, context: Context): Result<String, Int> {
+ val options = spec.options()
+ context.randomPrefs().edit {
+ PasswordOption.entries.forEach { putBoolean(it.key, it in options) }
+ putInt(PreferenceKeys.LENGTH, spec.length)
+ }
+ return withContext(dispatcherProvider.default()) { randomPassword(spec) }
+ }
+
+ /** The diceware generator's options as the old dialog stored them. */
+ fun dicewareSpec(): DicewareSpec =
+ DicewareSpec(
+ words = pwgenPrefs.getInt(PreferenceKeys.DICEWARE_LENGTH, 5),
+ separator = pwgenPrefs.getString(PreferenceKeys.DICEWARE_SEPARATOR)?.firstOrNull() ?: '-',
+ )
+
+ /** Stores [spec] and generates a passphrase; the word list is read on first use, off main. */
+ suspend fun generateDiceware(spec: DicewareSpec): String {
+ pwgenPrefs.edit {
+ putInt(PreferenceKeys.DICEWARE_LENGTH, spec.words)
+ putString(PreferenceKeys.DICEWARE_SEPARATOR, spec.separator.toString())
+ }
+ return withContext(dispatcherProvider.io()) {
+ diceware.get().generatePassphrase(spec.words, spec.separator)
+ }
+ }
+
+ /** The text of the QR code in the image at [uri], or null when it has none. Off main. */
+ suspend fun readQr(uri: Uri, context: Context): String? {
+ val resolver = context.applicationContext.contentResolver
+ return withContext(dispatcherProvider.io()) {
+ runCatching {
+ val bitmap =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri))
+ .copy(Bitmap.Config.ARGB_8888, false)
+ } else {
+ @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(resolver, uri)
+ }
+ val pixels = IntArray(bitmap.width * bitmap.height)
+ bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
+ val source = RGBLuminanceSource(bitmap.width, bitmap.height, pixels)
+ QRCodeReader().decode(BinaryBitmap(HybridBinarizer(source))).text
+ }
+ .get()
+ }
+ }
+
+ /**
+ * Encrypts the form to [ids] and writes it to [target], replacing [original] when the entry was
+ * renamed or moved. Nothing is written (not even the folder) when [target] would replace another
+ * entry or when encryption fails; a recipient whose key is missing fails it (see
+ * CryptoRepository.encrypt). The caller checked [formError] and that [target] is inside the
+ * store. Returns null when a save already ran or is running.
+ */
+ suspend fun save(
+ ids: List<PGPIdentifier>,
+ target: File,
+ original: File?,
+ context: Context,
+ ): SaveResult? {
+ if (saving) return null
+ saving = true
+ var result: SaveResult? = null
+ try {
+ result = write(ids, target, original, context.applicationContext)
+ } finally {
+ // Stays set after a success (the editor is leaving); cleared on failure or cancellation.
+ if (result !is SaveResult.Saved) saving = false
+ }
+ return result
+ }
+
+ override fun onCleared() {
+ form = EditorForm()
+ }
+
+ private suspend fun write(
+ ids: List<PGPIdentifier>,
+ target: File,
+ original: File?,
+ app: Context,
+ ): SaveResult {
+ val form = form
+ if (withContext(dispatcherProvider.io()) { overwrites(target, original) })
+ return SaveResult.Failed(R.string.password_creation_duplicate_error)
+ val out = ByteArrayOutputStream()
+ val encrypted = repository.encrypt(ids, passContent(form).byteInputStream(), out)
+ if (!encrypted.isOk) {
+ logcat(ERROR) { encrypted.error.asLog() }
+ return SaveResult.Failed(
+ R.string.password_creation_encrypt_fail_message,
+ encrypted.error.message.orEmpty(),
+ )
+ }
+ return withContext(dispatcherProvider.io()) {
+ try {
+ writeEntry(target, out.toByteArray())
+ } catch (e: IOException) {
+ logcat(ERROR) { e.asLog() }
+ return@withContext SaveResult.Failed(R.string.password_creation_file_write_fail_message)
+ }
+ var stale: File? = null
+ if (original != null && original.canonicalPath != target.canonicalPath) {
+ if (original.delete() || !original.exists()) {
+ AutofillMatcher.updateMatches(app, moveFromTo = mapOf(original to target))
+ app.moveHistory(original, target)
+ } else {
+ stale = original
+ }
+ }
+ SaveResult.Saved(target, form, stale)
+ }
+ }
+
+ private fun Context.randomPrefs(): SharedPreferences =
+ getSharedPreferences(RANDOM_PREFS, Context.MODE_PRIVATE)
+
+ private companion object {
+ /** The old random generator dialog's preferences file. */
+ const val RANDOM_PREFS = "PasswordGenerator"
+ }
+}
+```
+
+- [ ] **Step 4: Checks and commit**
+
+Run: `./gradlew spotlessApply && CI=1 ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug`
+Expected: `BUILD SUCCESSFUL` (Hilt generates the ViewModel factory; nothing uses it yet). If Hilt
+reports a missing binding for `PasswordEntry.Factory` in `ViewModelComponent`, check that
+`TotpModule` still lists `ViewModelComponent` (added in step 3).
+
+```bash
+git add app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt \
+ app/src/main/java/app/passwordstore/ui/list/StoreActions.kt \
+ app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt
+git commit -m "feat(app): add the editor ViewModel
+
+Holds the form in memory only, generates passwords with the stored
+generator options, decodes QR images and saves off the main thread:
+nothing is written when the target would replace another entry or when
+encryption fails, the file is written through a temporary file, and a
+renamed or moved entry takes its autofill matches and recently-used time
+along.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: Editor screen, generator sheet, OTP dialog and strings
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt`
+- Create: `app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.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 `</resources>`, add:
+
+```xml
+
+ <!-- Compose editor -->
+ <string name="editor_new_title">New entry</string>
+ <string name="editor_edit_title">Edit entry</string>
+ <string name="editor_close">Close</string>
+ <string name="editor_folder">Folder</string>
+ <string name="editor_change_folder">Change</string>
+ <string name="editor_otp_qr">OTP from QR</string>
+ <string name="editor_otp_manual">Manual OTP</string>
+ <string name="generator_random">Random</string>
+ <string name="generator_diceware" translatable="false">Diceware</string>
+ <string name="generator_use">Use</string>
+```
+
+In `app/src/main/res/values-it/strings.xml`, before `</resources>`, add:
+
+```xml
+
+ <!-- Compose editor -->
+ <string name="editor_new_title">Nuova voce</string>
+ <string name="editor_edit_title">Modifica voce</string>
+ <string name="editor_close">Chiudi</string>
+ <string name="editor_folder">Cartella</string>
+ <string name="editor_change_folder">Cambia</string>
+ <string name="editor_otp_qr">OTP da QR</string>
+ <string name="editor_otp_manual">OTP manuale</string>
+ <string name="generator_random">Casuale</string>
+ <string name="generator_use">Usa</string>
+```
+
+(`generator_diceware` is not translatable, so it must not appear in `values-it`: lint's
+`ExtraTranslation` would fail. Everything else reuses existing translated strings.)
+
+- [ ] **Step 2: The screen and the OTP dialog**
+
+`app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.window.SecureFlagPolicy
+import app.passwordstore.R
+import app.passwordstore.ui.compose.BreadcrumbBar
+import app.passwordstore.ui.compose.R as composeR
+import app.passwordstore.ui.compose.crumbsFor
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+
+/** Which field takes focus when the editor first opens. */
+enum class EditorFocus {
+ None,
+ Name,
+ Username,
+}
+
+/**
+ * The editor: name, folder (breadcrumbs; they and "Change" open the picker), password with reveal
+ * and generator, username, free-text extra content (the pass format as text) and the OTP actions,
+ * shown while the extras hold no TOTP secret ([showOtpActions]). [onScanQr] is null on a device
+ * without a camera. Only the reveal toggle and the menus keep state here.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EditorScreen(
+ form: EditorForm,
+ editing: Boolean,
+ saving: Boolean,
+ showOtpActions: Boolean,
+ initiallyRevealed: Boolean,
+ focus: EditorFocus,
+ onFormChange: (EditorForm) -> Unit,
+ onClose: () -> Unit,
+ onSave: (copy: Boolean) -> Unit,
+ onChangeFolder: () -> Unit,
+ onGenerate: () -> Unit,
+ onScanQr: (() -> Unit)?,
+ onQrImage: () -> Unit,
+ onManualOtp: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var revealed by remember { mutableStateOf(initiallyRevealed) }
+ var menu by remember { mutableStateOf(false) }
+ var qrMenu by remember { mutableStateOf(false) }
+ val nameFocus = remember { FocusRequester() }
+ val usernameFocus = remember { FocusRequester() }
+ LaunchedEffect(Unit) {
+ when (focus) {
+ EditorFocus.Name -> nameFocus.requestFocus()
+ EditorFocus.Username -> usernameFocus.requestFocus()
+ EditorFocus.None -> Unit
+ }
+ }
+ val field = Modifier.fillMaxWidth().padding(horizontal = SpacingLarge)
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ stringResource(if (editing) R.string.editor_edit_title else R.string.editor_new_title)
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onClose) {
+ Icon(Icons.Filled.Close, stringResource(R.string.editor_close))
+ }
+ },
+ actions = {
+ TextButton(onClick = { onSave(false) }, enabled = !saving) {
+ Text(stringResource(R.string.crypto_save))
+ }
+ Box {
+ IconButton(onClick = { menu = true }) {
+ Icon(Icons.Filled.MoreVert, stringResource(R.string.list_more_options))
+ }
+ DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.crypto_save_and_copy)) },
+ onClick = {
+ menu = false
+ onSave(true)
+ },
+ enabled = !saving,
+ )
+ }
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(bottom = SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingMedium),
+ ) {
+ OutlinedTextField(
+ value = form.name,
+ onValueChange = { onFormChange(form.copy(name = it)) },
+ modifier = field.focusRequester(nameFocus),
+ label = { Text(stringResource(R.string.crypto_name_hint)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next),
+ )
+ Text(
+ stringResource(R.string.editor_folder),
+ field,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ BreadcrumbBar(
+ crumbsFor(form.dir),
+ stringResource(R.string.list_store_root),
+ onCrumbClick = { onChangeFolder() },
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(onClick = onChangeFolder, modifier = Modifier.padding(end = SpacingMedium)) {
+ Text(stringResource(R.string.editor_change_folder))
+ }
+ }
+ OutlinedTextField(
+ value = form.password,
+ onValueChange = { onFormChange(form.copy(password = it)) },
+ modifier = field,
+ label = { Text(stringResource(R.string.crypto_pass_label)) },
+ singleLine = true,
+ textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace),
+ visualTransformation =
+ if (revealed) VisualTransformation.None else PasswordVisualTransformation(),
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Next,
+ ),
+ trailingIcon = {
+ Row {
+ IconButton(onClick = { revealed = !revealed }) {
+ Icon(
+ painterResource(
+ if (revealed) composeR.drawable.baseline_visibility_off_24
+ else composeR.drawable.baseline_visibility_24
+ ),
+ contentDescription =
+ stringResource(
+ if (revealed) R.string.entry_hide_password else R.string.entry_show_password
+ ),
+ )
+ }
+ IconButton(onClick = onGenerate) {
+ Icon(
+ painterResource(R.drawable.ic_password_24px),
+ contentDescription = stringResource(R.string.pwgen_title),
+ )
+ }
+ }
+ },
+ )
+ OutlinedTextField(
+ value = form.username,
+ onValueChange = { onFormChange(form.copy(username = it)) },
+ modifier = field.focusRequester(usernameFocus),
+ label = { Text(stringResource(R.string.crypto_username_hint)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next),
+ )
+ OutlinedTextField(
+ value = form.extra,
+ onValueChange = { onFormChange(form.copy(extra = it)) },
+ modifier = field,
+ label = { Text(stringResource(R.string.crypto_extra_label)) },
+ minLines = 3,
+ // A password keyboard does not learn what is typed, as the old textVisiblePassword.
+ keyboardOptions =
+ KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Password),
+ )
+ if (showOtpActions)
+ Row(field, horizontalArrangement = Arrangement.spacedBy(SpacingMedium)) {
+ Box {
+ OutlinedButton(onClick = { qrMenu = true }) {
+ Icon(painterResource(R.drawable.ic_qr_code_scanner), contentDescription = null)
+ Text(stringResource(R.string.editor_otp_qr), Modifier.padding(start = SpacingMedium))
+ }
+ DropdownMenu(expanded = qrMenu, onDismissRequest = { qrMenu = false }) {
+ if (onScanQr != null)
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.otp_import_qr_code)) },
+ onClick = {
+ qrMenu = false
+ onScanQr()
+ },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.otp_import_from_file)) },
+ onClick = {
+ qrMenu = false
+ onQrImage()
+ },
+ )
+ }
+ }
+ OutlinedButton(onClick = onManualOtp) {
+ Text(stringResource(R.string.editor_otp_manual))
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Asks for a TOTP secret and an optional account (the URI's issuer), as the old manual OTP dialog
+ * did. Both live in [remember] only, and the window is FLAG_SECURE.
+ */
+@Composable
+fun OtpDialog(
+ onAdd: (secret: String, account: String) -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var secret by remember { mutableStateOf("") }
+ var account by remember { mutableStateOf("") }
+ val focus = remember { FocusRequester() }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
+ title = { Text(stringResource(R.string.add_otp)) },
+ text = {
+ Column {
+ OutlinedTextField(
+ value = secret,
+ onValueChange = { secret = it },
+ modifier = Modifier.focusRequester(focus),
+ label = { Text(stringResource(R.string.otp_import_manual_hint_secret)) },
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Next,
+ ),
+ )
+ OutlinedTextField(
+ value = account,
+ onValueChange = { account = it },
+ label = { Text(stringResource(R.string.otp_import_manual_hint_account)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(autoCorrectEnabled = false),
+ )
+ }
+ LaunchedEffect(Unit) { focus.requestFocus() }
+ },
+ confirmButton = {
+ TextButton(onClick = { onAdd(secret, account) }, enabled = secret.isNotBlank()) {
+ Text(stringResource(R.string.dialog_ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
+```
+
+- [ ] **Step 3: The generator sheet**
+
+`app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.ModalBottomSheetProperties
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.PrimaryTabRow
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.window.SecureFlagPolicy
+import app.passwordstore.R
+import app.passwordstore.passgen.random.PasswordGenerator
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.compose.theme.SpacingSmall
+import com.github.michaelbull.result.Result
+
+private const val RANDOM_TAB = 0
+private const val DICEWARE_TAB = 1
+
+/**
+ * The password generator as a bottom sheet: a random tab and a diceware tab with the old dialogs'
+ * options, starting from [random] and [diceware]. [classicFirst] opens the random tab (the
+ * "Password generator" setting). Every change generates again; [onUse] takes the password shown.
+ * The password lives in [remember] only, and the sheet's window is FLAG_SECURE.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GeneratorSheet(
+ classicFirst: Boolean,
+ random: RandomSpec,
+ diceware: DicewareSpec,
+ onRandom: suspend (RandomSpec) -> Result<String, Int>,
+ onDiceware: suspend (DicewareSpec) -> String,
+ onUse: (String) -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var tab by remember { mutableIntStateOf(if (classicFirst) RANDOM_TAB else DICEWARE_TAB) }
+ var randomSpec by remember { mutableStateOf(random) }
+ var dicewareSpec by remember { mutableStateOf(diceware) }
+ var length by remember { mutableStateOf(random.length.toString()) }
+ var words by remember { mutableStateOf(diceware.words.toString()) }
+ var separator by remember { mutableStateOf(diceware.separator.toString()) }
+ var rolls by remember { mutableIntStateOf(0) }
+ var password by remember { mutableStateOf("") }
+ var problem by remember { mutableStateOf<Int?>(null) }
+ LaunchedEffect(tab, randomSpec, dicewareSpec, rolls) {
+ if (tab == RANDOM_TAB) {
+ val result = onRandom(randomSpec)
+ password = if (result.isOk) result.value else ""
+ problem = if (result.isOk) null else result.error
+ } else {
+ password = onDiceware(dicewareSpec)
+ problem = null
+ }
+ }
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ properties = ModalBottomSheetProperties(securePolicy = SecureFlagPolicy.SecureOn),
+ ) {
+ PrimaryTabRow(selectedTabIndex = tab) {
+ Tab(
+ selected = tab == RANDOM_TAB,
+ onClick = { tab = RANDOM_TAB },
+ text = { Text(stringResource(R.string.generator_random)) },
+ )
+ Tab(
+ selected = tab == DICEWARE_TAB,
+ onClick = { tab = DICEWARE_TAB },
+ text = { Text(stringResource(R.string.generator_diceware)) },
+ )
+ }
+ Column(
+ Modifier.verticalScroll(rememberScrollState()).padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingSmall),
+ ) {
+ val shown = problem
+ Text(
+ if (shown == null) password else stringResource(shown),
+ style = MaterialTheme.typography.titleMedium.copy(fontFamily = FontFamily.Monospace),
+ color =
+ if (shown == null) MaterialTheme.colorScheme.onSurface
+ else MaterialTheme.colorScheme.error,
+ )
+ if (tab == RANDOM_TAB) {
+ CheckRow(R.string.pwgen_numerals, randomSpec.numerals) {
+ randomSpec = randomSpec.copy(numerals = it)
+ }
+ CheckRow(R.string.pwgen_symbols, randomSpec.symbols) {
+ randomSpec = randomSpec.copy(symbols = it)
+ }
+ CheckRow(R.string.pwgen_uppercase, randomSpec.uppercase) {
+ randomSpec = randomSpec.copy(uppercase = it)
+ }
+ CheckRow(R.string.pwgen_lowercase, randomSpec.lowercase) {
+ randomSpec = randomSpec.copy(lowercase = it)
+ }
+ CheckRow(R.string.pwgen_pronounceable, randomSpec.pronounceable) {
+ randomSpec = randomSpec.copy(pronounceable = it)
+ }
+ CheckRow(R.string.pwgen_ambiguous, randomSpec.ambiguous) {
+ randomSpec = randomSpec.copy(ambiguous = it)
+ }
+ OutlinedTextField(
+ value = length,
+ onValueChange = { text ->
+ // ponytail: at most 999 characters; the generator has no upper bound of its own.
+ val digits = text.filter(Char::isDigit).take(3)
+ length = digits
+ randomSpec =
+ randomSpec.copy(length = digits.toIntOrNull() ?: PasswordGenerator.DEFAULT_LENGTH)
+ },
+ label = { Text(stringResource(R.string.pwgen_length)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ )
+ } else {
+ OutlinedTextField(
+ value = words,
+ onValueChange = { text ->
+ // ponytail: at most 99 words.
+ val digits = text.filter(Char::isDigit).take(2)
+ words = digits
+ dicewareSpec =
+ dicewareSpec.copy(words = digits.toIntOrNull()?.takeIf { it > 0 } ?: 5)
+ },
+ label = { Text(stringResource(R.string.pwgen_length)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ )
+ OutlinedTextField(
+ value = separator,
+ onValueChange = { text ->
+ separator = text.take(1)
+ dicewareSpec = dicewareSpec.copy(separator = separator.firstOrNull() ?: '-')
+ },
+ label = { Text(stringResource(R.string.pwgen_separator)) },
+ singleLine = true,
+ )
+ }
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(SpacingMedium, Alignment.End),
+ ) {
+ TextButton(onClick = { rolls++ }) { Text(stringResource(R.string.pwgen_generate)) }
+ Button(
+ onClick = { onUse(password) },
+ enabled = password.isNotEmpty() && problem == null,
+ ) {
+ Text(stringResource(R.string.generator_use))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CheckRow(@StringRes label: Int, checked: Boolean, onChange: (Boolean) -> Unit) {
+ Row(
+ Modifier.fillMaxWidth()
+ .toggleable(value = checked, role = Role.Checkbox, onValueChange = onChange),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(checked = checked, onCheckedChange = null)
+ Text(stringResource(label), Modifier.padding(start = SpacingMedium))
+ }
+}
+```
+
+- [ ] **Step 4: Checks and commit**
+
+Run: `./gradlew spotlessApply && CI=1 ./gradlew :app:assembleDebug :app:lintDebug`
+Expected: `BUILD SUCCESSFUL`. If `composeR` does not resolve, check the ui:compose namespace in
+`ui/compose/build.gradle.kts` (`app.passwordstore.ui.compose`). If lint flags `PrimaryTabRow` or
+`ModalBottomSheetProperties` as experimental beyond the existing `@OptIn`, add
+`ExperimentalMaterial3Api` to that `@OptIn` only.
+
+```bash
+git add app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt \
+ app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt \
+ app/src/main/res/values/strings.xml app/src/main/res/values-it/strings.xml
+git commit -m "feat(app): add the Compose editor screen and generator sheet
+
+Stateless editor: name, folder breadcrumbs with Change, password with
+reveal and generator, username, extra content and the OTP actions. The
+generator sheet has random and diceware tabs with the old options; the
+manual OTP dialog and the sheet are FLAG_SECURE.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: Editor destination and navigation
+
+After this task the list and the entry view open the Compose editor. `PasswordCreationActivity`
+is still started by `AutofillSaveActivity` (Task 6).
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt`
+- Modify (replace whole file): `app/src/main/java/app/passwordstore/ui/main/MainActivity.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/list/ListDestination.kt`
+
+- [ ] **Step 1: The destination**
+
+`app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import android.app.Activity
+import android.content.pm.PackageManager
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts.GetContent
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.compose.foundation.layout.Box
+import androidx.compose.material3.AlertDialog
+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.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.fragment.app.FragmentActivity
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.crypto.copyPasswordToClipboard
+import app.passwordstore.ui.crypto.getPGPIdentifiers
+import app.passwordstore.ui.list.FolderPicker
+import app.passwordstore.util.extensions.commitChange
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.extensions.isInsideRepository
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.extensions.snackbar
+import app.passwordstore.util.settings.PreferenceKeys
+import com.google.android.material.snackbar.Snackbar
+import com.google.zxing.integration.android.IntentIntegrator
+import java.io.File
+import kotlinx.coroutines.launch
+
+/**
+ * The editor: wires [EditorScreen] to the [viewModel], the generator sheet, the folder picker, OTP
+ * import and the save path (checks, recipients from `.gpg-id`, encryption, write, git commit).
+ * [initial] fills the form the first time; null means the entry to edit is no longer in memory
+ * (process death) and the editor closes. [original] is the file being edited, null for a new
+ * entry. [generate] opens the generator at once (autofill's "generate password"). [onClose]
+ * leaves without saving; [onSaved] runs after the commit.
+ */
+@Composable
+fun EditorDestination(
+ activity: FragmentActivity,
+ initial: EditorForm?,
+ original: File?,
+ onClose: () -> Unit,
+ onSaved: (SaveResult.Saved) -> Unit,
+ modifier: Modifier = Modifier,
+ generate: Boolean = false,
+ viewModel: EditorViewModel = hiltViewModel(),
+) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ // ponytail: filled once per ViewModel, so a recreated activity keeps the edits; an edit restored
+ // after process death has nothing to fill from (the entry view's plaintext is gone) and closes,
+ // back to the entry view's passphrase prompt.
+ val first = remember { initial != null && viewModel.start(initial) }
+ val lost = !viewModel.started
+ LaunchedEffect(lost) { if (lost) onClose() }
+ if (lost) return
+
+ val keyboard = LocalSoftwareKeyboardController.current
+ val focusManager = LocalFocusManager.current
+ val scope = rememberCoroutineScope()
+ var sheet by rememberSaveable { mutableStateOf(first && generate) }
+ var picker by rememberSaveable { mutableStateOf(false) }
+ var otp by rememberSaveable { mutableStateOf(false) }
+ var failure by remember { mutableStateOf<String?>(null) }
+ val form = viewModel.form
+ val hasOtp = remember(form.extra) { viewModel.hasOtp(form.extra) }
+ val hasCamera = remember {
+ activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
+ }
+ val showPassword = remember {
+ activity.sharedPrefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
+ }
+ val classicFirst = remember {
+ activity.sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "classic"
+ }
+
+ fun say(message: Int) {
+ activity.snackbar(message = activity.getString(message))
+ }
+
+ fun addLine(line: String) {
+ viewModel.form = viewModel.form.copy(extra = withLine(viewModel.form.extra, line))
+ }
+
+ // The one way out without saving: only while no save runs, keyboard and focus dropped first.
+ fun close() {
+ if (viewModel.saving) return
+ keyboard?.hide()
+ focusManager.clearFocus()
+ onClose()
+ }
+
+ fun save(copy: Boolean) {
+ if (viewModel.saving) return
+ val current = viewModel.form
+ val target = entryFile(root, current)
+ val problem =
+ formError(current)
+ ?: R.string.message_error_destination_outside_repo.takeUnless {
+ target.isInsideRepository()
+ }
+ if (problem != null) return say(problem)
+ // Recipients from the nearest .gpg-id, as pass does; null: an invalid ID, already reported.
+ val ids = activity.getPGPIdentifiers(current.dir) ?: return
+ activity.lifecycleScope.launch {
+ when (val result = viewModel.save(ids, target, original, activity)) {
+ null -> Unit
+ is SaveResult.Failed -> failure = activity.getString(result.message, result.arg)
+ is SaveResult.Saved -> {
+ if (copy) activity.copyPasswordToClipboard(result.form.password)
+ result.staleOriginal?.let {
+ activity.snackbar(
+ message =
+ activity.getString(
+ R.string.password_creation_file_delete_fail_message,
+ it.nameWithoutExtension,
+ ),
+ length = Snackbar.LENGTH_LONG,
+ )
+ }
+ // ponytail: commit failures are not shown, as for the list's move and delete; a theme
+ // or locale change in the middle of a save cancels before this commit, and the next
+ // commit picks the file up.
+ activity.commitChange(
+ activity.getString(
+ if (original != null) R.string.git_commit_edit_text
+ else R.string.git_commit_add_text,
+ target.relativeTo(root).path.removeSuffix(".gpg"),
+ )
+ )
+ keyboard?.hide()
+ focusManager.clearFocus()
+ onSaved(result)
+ }
+ }
+ }
+ }
+
+ BackHandler(onBack = ::close)
+
+ val scan =
+ rememberLauncherForActivityResult(StartActivityForResult()) { result ->
+ val text = IntentIntegrator.parseActivityResult(result.resultCode, result.data)?.contents
+ if (result.resultCode == Activity.RESULT_OK && text != null) {
+ addLine("$text\n")
+ say(R.string.otp_import_success)
+ } else {
+ say(R.string.otp_import_failure_generic)
+ }
+ }
+ val image =
+ rememberLauncherForActivityResult(GetContent()) { uri ->
+ if (uri == null) {
+ say(R.string.otp_import_failure_no_selection)
+ } else {
+ scope.launch {
+ val text = viewModel.readQr(uri, activity)
+ if (text != null) {
+ addLine(text)
+ say(R.string.otp_import_success)
+ } else {
+ say(R.string.otp_import_failure_generic)
+ }
+ }
+ }
+ }
+
+ Box(modifier) {
+ EditorScreen(
+ form = form,
+ editing = original != null,
+ saving = viewModel.saving,
+ showOtpActions = !hasOtp,
+ initiallyRevealed = showPassword,
+ focus =
+ when {
+ !first -> EditorFocus.None
+ form.name.isEmpty() -> EditorFocus.Name
+ original == null -> EditorFocus.Username
+ else -> EditorFocus.None
+ },
+ onFormChange = { viewModel.form = it },
+ onClose = ::close,
+ onSave = ::save,
+ onChangeFolder = { picker = true },
+ onGenerate = { sheet = true },
+ onScanQr =
+ if (hasCamera) {
+ {
+ scan.launch(
+ IntentIntegrator(activity)
+ .setOrientationLocked(false)
+ .setBeepEnabled(false)
+ .setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
+ .createScanIntent()
+ )
+ }
+ } else null,
+ onQrImage = { image.launch("image/*") },
+ onManualOtp = { otp = true },
+ )
+ if (sheet)
+ GeneratorSheet(
+ classicFirst = classicFirst,
+ random = viewModel.randomSpec(activity),
+ diceware = viewModel.dicewareSpec(),
+ onRandom = { spec -> viewModel.generateRandom(spec, activity) },
+ onDiceware = viewModel::generateDiceware,
+ onUse = { password ->
+ viewModel.form = viewModel.form.copy(password = password)
+ sheet = false
+ },
+ onDismiss = { sheet = false },
+ )
+ if (picker)
+ FolderPicker(
+ root = root,
+ start = form.dir,
+ onPick = { dir ->
+ viewModel.form = viewModel.form.copy(dir = dir)
+ picker = false
+ },
+ onDismiss = { picker = false },
+ )
+ if (otp)
+ OtpDialog(
+ onAdd = { secret, account ->
+ totpUri(secret, account)?.let(::addLine)
+ otp = false
+ },
+ onDismiss = { otp = false },
+ )
+ failure?.let { message ->
+ AlertDialog(
+ onDismissRequest = { failure = null },
+ title = { Text(stringResource(R.string.password_creation_file_fail_title)) },
+ text = { Text(message) },
+ confirmButton = {
+ TextButton(onClick = { failure = null }) { Text(stringResource(R.string.dialog_ok)) }
+ },
+ )
+ }
+ }
+}
+```
+
+- [ ] **Step 2: MainActivity**
+
+Replace `app/src/main/java/app/passwordstore/ui/main/MainActivity.kt` with:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.main
+
+import android.os.Bundle
+import android.view.WindowManager
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.hilt.navigation.compose.hiltViewModel
+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.editor.EditorDestination
+import app.passwordstore.ui.editor.EditorForm
+import app.passwordstore.ui.editor.editorForm
+import app.passwordstore.ui.entry.EntryDestination
+import app.passwordstore.ui.entry.EntryViewModel
+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, by absolute path. Paths from [LaunchActivity] passed [LaunchActivity.isFileInRepo];
+ * the entry destination checks again before decrypting.
+ */
+@Serializable data class EntryRoute(val filePath: String)
+
+/**
+ * The editor: a new entry in [dir] (relative to the store root, "" is the root), or the entry
+ * [name] (file name without `.gpg`) there. An edit takes its plaintext from the entry view below
+ * it on the back stack, never from the route.
+ */
+@Serializable data class EditRoute(val dir: String, val name: String? = null)
+
+/**
+ * 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
+
+ /** Composed destinations that show secrets; FLAG_SECURE is on while this is above zero. */
+ private var secureHolds = 0
+
+ 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<ListRoute> { entry ->
+ val path = entry.toRoute<ListRoute>().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)) }
+ }
+ },
+ onOpenEntry = { file ->
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ addShortcut(file)
+ nav.navigate(EntryRoute(file.absolutePath))
+ }
+ },
+ onNewEntry = {
+ if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(EditRoute(path))
+ },
+ )
+ }
+ composable<EntryRoute> { entry ->
+ val file = File(entry.toRoute<EntryRoute>().filePath)
+ SecureWindow(this@MainActivity)
+ EntryDestination(
+ activity = this@MainActivity,
+ file = file,
+ onClose = {
+ // Only the entry on top closes: the auto-close timer can race a back press.
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ if (nav.previousBackStackEntry == null) finish() else nav.popBackStack()
+ }
+ },
+ onEdit = {
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ val root = PasswordRepository.getRepositoryDirectory().canonicalFile
+ val dir = file.canonicalFile.parentFile?.relativeTo(root)?.path.orEmpty()
+ nav.navigate(EditRoute(dir, file.nameWithoutExtension))
+ }
+ },
+ )
+ }
+ composable<EditRoute> { entry ->
+ val route = entry.toRoute<EditRoute>()
+ val name = route.name
+ // An edit reads the decrypted entry from the entry view below it. After process
+ // death that ViewModel is new and empty, and the editor closes.
+ val decrypted =
+ if (name == null) null
+ else
+ remember(entry) { runCatching { nav.getBackStackEntry<EntryRoute>() }.getOrNull() }
+ ?.let { hiltViewModel<EntryViewModel>(it).entry }
+ SecureWindow(this@MainActivity)
+ EditorDestination(
+ activity = this@MainActivity,
+ initial =
+ if (name == null) EditorForm(dir = route.dir)
+ else decrypted?.let { editorForm(it, route.dir, name) },
+ original =
+ name?.let { File(File(PasswordRepository.getRepositoryDirectory(), route.dir), "$it.gpg") },
+ onClose = {
+ if (nav.currentBackStackEntry?.id == entry.id) nav.popBackStack()
+ },
+ onSaved = {
+ // Back to the list, which re-reads the store on resume. An entry opened from a
+ // shortcut has no list below it: the list root replaces the stack.
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ if (!nav.popBackStack<ListRoute>(inclusive = false))
+ nav.navigate(ListRoute()) { popUpTo<EntryRoute> { inclusive = true } }
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ /** Counts a destination that shows secrets; the first one turns FLAG_SECURE on. */
+ fun holdSecure() {
+ if (secureHolds++ == 0) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ }
+
+ /** Ends a [holdSecure]; FLAG_SECURE goes off when no secret-showing destination is left. */
+ fun releaseSecure() {
+ if (--secureHolds == 0) window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ }
+
+ /** 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<GitOp> =
+ 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) }
+ }
+
+ /** Adds [file] to the launcher's dynamic shortcuts, as opening it from the list always did. */
+ fun addShortcut(file: File) {
+ val item = entryItem(file)
+ shortcutHandler.addDynamicShortcut(item, item.createAuthEnabledIntent(this))
+ }
+
+ fun pinEntry(file: File) {
+ val item = entryItem(file)
+ shortcutHandler.addPinnedShortcut(item, item.createAuthEnabledIntent(this))
+ }
+
+ fun openSettings() {
+ launchActivity(SettingsActivity::class.java)
+ }
+
+ private fun entryItem(file: File) =
+ PasswordItem.newPassword(file.name, file, PasswordRepository.getRepositoryDirectory())
+}
+
+/**
+ * FLAG_SECURE while composed. Counted in [MainActivity], because in a transition (entry view to
+ * editor and back) the entering destination is composed before the leaving one is disposed.
+ */
+@Composable
+private fun SecureWindow(activity: MainActivity) {
+ DisposableEffect(activity) {
+ activity.holdSecure()
+ onDispose { activity.releaseSecure() }
+ }
+}
+```
+
+(`createEntry` is gone: the list navigates to `EditRoute` itself.)
+
+- [ ] **Step 3: EntryDestination opens the Compose editor**
+
+In `app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt`:
+
+Delete these imports:
+
+```kotlin
+import android.view.WindowManager
+```
+
+```kotlin
+import androidx.compose.runtime.DisposableEffect
+```
+
+```kotlin
+import app.passwordstore.data.passfile.PasswordEntry
+```
+
+```kotlin
+import app.passwordstore.ui.crypto.BasePGPActivity
+```
+
+```kotlin
+import app.passwordstore.ui.crypto.PasswordCreationActivity
+```
+
+Replace the KDoc and signature start:
+
+```kotlin
+/**
+ * One entry: wires [EntryScreen] to the [viewModel], the biometric and passphrase prompts, the
+ * clipboard and the legacy editor. [onClose] leaves the destination; [onSaved] runs after the
+ * editor saved the entry (the host returns to the list).
+ */
+@Composable
+fun EntryDestination(
+ activity: MainActivity,
+ file: File,
+ onClose: () -> Unit,
+ onSaved: () -> Unit,
+```
+
+with
+
+```kotlin
+/**
+ * One entry: wires [EntryScreen] to the [viewModel], the biometric and passphrase prompts and the
+ * clipboard. [onClose] leaves the destination; [onEdit] opens the editor on the decrypted entry.
+ * The host keeps FLAG_SECURE on while this is composed.
+ */
+@Composable
+fun EntryDestination(
+ activity: MainActivity,
+ file: File,
+ onClose: () -> Unit,
+ onEdit: () -> Unit,
+```
+
+Delete:
+
+```kotlin
+ // Secrets stay out of screenshots and recents while this destination is composed.
+ // ponytail: plain add/clear, fine while no other secret-showing destination can overlap this
+ // one in a transition; count in MainActivity once the editor (step 4) becomes a destination.
+ DisposableEffect(Unit) {
+ activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ onDispose { activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }
+ }
+
+```
+
+Replace
+
+```kotlin
+ // ponytail: after process death during key import or the editor, the unlock flow can start
+```
+
+with
+
+```kotlin
+ // ponytail: after process death during key import, the unlock flow can start
+```
+
+Delete:
+
+```kotlin
+ val editor =
+ rememberLauncherForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK) onSaved()
+ }
+```
+
+Replace
+
+```kotlin
+ onEdit = { entry?.let { editor.launch(editIntent(activity, file, root, it)) } },
+```
+
+with
+
+```kotlin
+ onEdit = { if (entry != null) onEdit() },
+```
+
+Delete the whole `editIntent` function with its KDoc:
+
+```kotlin
+/** The legacy editor, filled exactly as the old decrypt screen filled it. */
+private fun editIntent(activity: Activity, file: File, root: File, entry: PasswordEntry): Intent =
+ Intent(activity, PasswordCreationActivity::class.java)
+ .setAction(Intent.ACTION_VIEW)
+ .putExtra(BasePGPActivity.EXTRA_FILE_PATH, getParentPath(file.absolutePath, root.absolutePath))
+ .putExtra(BasePGPActivity.EXTRA_REPO_PATH, root.absolutePath)
+ .putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, file.nameWithoutExtension)
+ .putExtra(PasswordCreationActivity.EXTRA_USERNAME, entry.username)
+ .putExtra(PasswordCreationActivity.EXTRA_PASSWORD, entry.password)
+ .putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, entry.extraContentWithoutUsername)
+ .putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
+
+```
+
+(`Activity`, `Intent`, the result launcher imports and `getParentPath` stay: the key import
+launcher, the share intent and `unlock()` use them.)
+
+- [ ] **Step 4: The list opens the editor**
+
+In `app/src/main/java/app/passwordstore/ui/list/ListDestination.kt`, replace
+
+```kotlin
+ * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the
+ * folder picker, the legacy activities and the store operations. [onOpenFolder] takes a path
+ * relative to the root, [onOpenEntry] an entry file.
+ */
+@Composable
+fun ListDestination(
+ activity: MainActivity,
+ path: String,
+ onOpenFolder: (relativePath: String) -> Unit,
+ onOpenEntry: (File) -> Unit,
+```
+
+with
+
+```kotlin
+ * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the
+ * folder picker, the legacy activities and the store operations. [onOpenFolder] takes a path
+ * relative to the root, [onOpenEntry] an entry file; [onNewEntry] opens the editor in this folder.
+ */
+@Composable
+fun ListDestination(
+ activity: MainActivity,
+ path: String,
+ onOpenFolder: (relativePath: String) -> Unit,
+ onOpenEntry: (File) -> Unit,
+ onNewEntry: () -> Unit,
+```
+
+and replace
+
+```kotlin
+ ifInitialized { activity.createEntry(dir) }
+```
+
+with
+
+```kotlin
+ ifInitialized(onNewEntry)
+```
+
+- [ ] **Step 5: Build and smoke check**
+
+Run: `./gradlew spotlessApply && CI=1 ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug`
+Expected: `BUILD SUCCESSFUL`. If `popBackStack<ListRoute>(inclusive = false)` does not resolve,
+use `nav.popBackStack(ListRoute::class, inclusive = false)`. If Slack lint's
+`ComposeViewModelInjection` flags the `hiltViewModel<EntryViewModel>(it)` call in the `NavHost`
+builder, move the lookup into a private top-level `@Composable fun editedEntry(nav:
+NavHostController, entry: NavBackStackEntry): PasswordEntry?` in `MainActivity.kt` with a
+`@Suppress("ComposeViewModelInjection")` and a one-line reason (it reads another destination's
+ViewModel, it does not own one).
+
+With the emulator from Task 8 running and the store cloned (`export ANDROID_SERIAL=emulator-5554`
+first; a phone may be connected), `./gradlew :app:installDebug`, open the app, tap "New entry",
+"Create new password": the Compose editor opens (`adb exec-out uiautomator dump /dev/tty | grep -o
+'text="Save"'`); the close icon returns to the list. Full checks are in Task 8.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt \
+ app/src/main/java/app/passwordstore/ui/main/MainActivity.kt \
+ app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt \
+ app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
+git commit -m "feat(app): open the Compose editor from the list and entry view
+
+EditRoute replaces the legacy editor for new entries and edits. An edit
+copies its plaintext from the entry view's ViewModel below it, never
+through the route or saved state. Saving returns to the list, closing
+returns to where the editor was opened. FLAG_SECURE is now counted in
+MainActivity, so moving between the entry view and the editor never
+clears it.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: Autofill save hosts the Compose editor
+
+**Files:**
+- Modify (replace whole file): `app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt`
+- Modify: `app/src/main/AndroidManifest.xml`
+
+- [ ] **Step 1: The activity**
+
+Replace `app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt` with:
+
+```kotlin
+/*
+ * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package app.passwordstore.ui.autofill
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.os.Bundle
+import android.view.WindowManager
+import android.view.autofill.AutofillManager
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.os.bundleOf
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.ui.editor.EditorDestination
+import app.passwordstore.ui.editor.EditorForm
+import app.passwordstore.ui.editor.SaveResult
+import app.passwordstore.util.autofill.AutofillMatcher
+import app.passwordstore.util.autofill.AutofillPreferences
+import app.passwordstore.util.autofill.AutofillResponseBuilder
+import app.passwordstore.util.extensions.unsafeLazy
+import com.github.androidpasswordstore.autofillparser.AutofillAction
+import com.github.androidpasswordstore.autofillparser.Credentials
+import com.github.androidpasswordstore.autofillparser.FormOrigin
+import dagger.hilt.android.AndroidEntryPoint
+import logcat.LogPriority.ERROR
+import logcat.logcat
+
+/**
+ * Saves credentials the autofill service caught (or generates a password for a form) in the
+ * Compose editor, prefilled from the form's origin, then records the autofill match and, for a
+ * generated password, fills it in.
+ */
+@AndroidEntryPoint
+class AutofillSaveActivity : AppCompatActivity() {
+
+ companion object {
+
+ private const val EXTRA_FOLDER_NAME = "app.passwordstore.autofill.oreo.ui.EXTRA_FOLDER_NAME"
+ private const val EXTRA_PASSWORD = "app.passwordstore.autofill.oreo.ui.EXTRA_PASSWORD"
+ private const val EXTRA_NAME = "app.passwordstore.autofill.oreo.ui.EXTRA_NAME"
+ private const val EXTRA_SHOULD_MATCH_APP =
+ "app.passwordstore.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP"
+ private const val EXTRA_SHOULD_MATCH_WEB =
+ "app.passwordstore.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB"
+ private const val EXTRA_GENERATE_PASSWORD =
+ "app.passwordstore.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD"
+
+ private var saveRequestCode = 1
+
+ fun makeSaveIntentSender(
+ context: Context,
+ credentials: Credentials?,
+ formOrigin: FormOrigin,
+ ): IntentSender {
+ val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false)
+ // Prevent directory traversals
+ val sanitizedIdentifier =
+ identifier.replace('\\', '_').replace('/', '_').trimStart('.').takeUnless { it.isBlank() }
+ ?: formOrigin.identifier
+ val directoryStructure = AutofillPreferences.directoryStructure(context)
+ val folderName =
+ directoryStructure.getSaveFolderName(
+ sanitizedIdentifier = sanitizedIdentifier,
+ username = credentials?.username,
+ )
+ val fileName =
+ directoryStructure.getSaveFileName(
+ username = credentials?.username,
+ identifier = identifier,
+ )
+ val intent =
+ Intent(context, AutofillSaveActivity::class.java).apply {
+ putExtras(
+ bundleOf(
+ EXTRA_FOLDER_NAME to folderName,
+ EXTRA_NAME to fileName,
+ EXTRA_PASSWORD to credentials?.password,
+ EXTRA_SHOULD_MATCH_APP to
+ formOrigin.identifier.takeIf { formOrigin is FormOrigin.App },
+ EXTRA_SHOULD_MATCH_WEB to
+ formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web },
+ EXTRA_GENERATE_PASSWORD to (credentials == null),
+ )
+ )
+ }
+ return PendingIntent.getActivity(
+ context,
+ saveRequestCode++,
+ intent,
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ .intentSender
+ }
+ }
+
+ private val formOrigin by unsafeLazy {
+ val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP)
+ val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB)
+ if (shouldMatchApp != null && shouldMatchWeb == null) {
+ FormOrigin.App(shouldMatchApp)
+ } else if (shouldMatchApp == null && shouldMatchWeb != null) {
+ FormOrigin.Web(shouldMatchWeb)
+ } else {
+ null
+ }
+ }
+
+ private val generate by unsafeLazy { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // The whole window is the editor: keep it out of screenshots and recents.
+ window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ val initial =
+ EditorForm(
+ name = intent.getStringExtra(EXTRA_NAME).orEmpty(),
+ dir = intent.getStringExtra(EXTRA_FOLDER_NAME).orEmpty().trim('/'),
+ password = intent.getStringExtra(EXTRA_PASSWORD).orEmpty(),
+ )
+ setContent {
+ APSTheme {
+ EditorDestination(
+ activity = this,
+ initial = initial,
+ original = null,
+ onClose = {
+ setResult(RESULT_CANCELED)
+ finish()
+ },
+ onSaved = ::finishSaved,
+ generate = generate,
+ )
+ }
+ }
+ }
+
+ /** Records the autofill match and, for a generated password, returns it to fill the form. */
+ private fun finishSaved(saved: SaveResult.Saved) {
+ formOrigin?.let { AutofillMatcher.addMatchFor(this, it, saved.file) }
+ val resultIntent =
+ if (generate) {
+ val clientState =
+ intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
+ ?: run {
+ logcat(ERROR) { "AutofillSaveActivity started without EXTRA_CLIENT_STATE" }
+ finish()
+ return
+ }
+ val username =
+ saved.form.username.ifEmpty { null }
+ ?: AutofillPreferences.directoryStructure(this).getUsernameFor(saved.file)
+ val credentials = Credentials(username, saved.form.password, null)
+ val fillInDataset =
+ AutofillResponseBuilder.makeFillInDataset(
+ this,
+ credentials,
+ clientState,
+ AutofillAction.Generate,
+ )
+ Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
+ } else {
+ // The password came from the form: there is nothing to fill.
+ Intent()
+ }
+ setResult(RESULT_OK, resultIntent)
+ finish()
+ }
+}
+```
+
+- [ ] **Step 2: Manifest**
+
+In `app/src/main/AndroidManifest.xml`, replace
+
+```xml
+ <activity
+ android:name=".ui.autofill.AutofillSaveActivity"
+ android:exported="false"
+ android:theme="@style/NoBackgroundThemeM3" />
+```
+
+with
+
+```xml
+ <!-- ponytail: rotation must not cancel a save running in lifecycleScope, as in MainActivity. -->
+ <activity
+ android:name=".ui.autofill.AutofillSaveActivity"
+ android:configChanges="orientation|screenSize"
+ android:exported="false"
+ android:theme="@style/NoActionBarThemeM3"
+ android:windowSoftInputMode="adjustResize" />
+```
+
+(The old transparent theme hid a trampoline that started the editor; the activity now is the
+editor.)
+
+- [ ] **Step 3: Checks and commit**
+
+Run: `./gradlew spotlessApply && CI=1 ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug`
+Expected: `BUILD SUCCESSFUL`. `grep -rn PasswordCreationActivity app/src/main/java` now lists only
+`PasswordCreationActivity.kt` and the three legacy dialogs.
+
+```bash
+git add app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt \
+ app/src/main/AndroidManifest.xml
+git commit -m "feat(app): host the Compose editor in the autofill save flow
+
+AutofillSaveActivity shows the editor itself, prefilled from the form
+and FLAG_SECURE, instead of starting the legacy editor for a result. The
+autofill match and the generated-password fill work as before.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 7: Remove the legacy editor, generators and folder picker
+
+**Files:**
+- Delete: the files listed in "Deleted in this step" above
+- Modify: `app/src/main/AndroidManifest.xml`
+- Modify: `app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt`
+- Modify: `app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt`
+- Modify: `app/build.gradle.kts`, `gradle/libs.versions.toml`
+- Modify: `app/lint-baseline.xml`
+- Modify: `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`
+
+- [ ] **Step 1: Delete the files**
+
+```bash
+git rm app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt \
+ app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt \
+ app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt \
+ app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt \
+ app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt \
+ app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt \
+ app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt \
+ app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt \
+ app/src/main/res/layout/password_creation_activity.xml \
+ app/src/main/res/layout/fragment_pwgen.xml \
+ app/src/main/res/layout/fragment_pwgen_diceware.xml \
+ app/src/main/res/layout/fragment_manual_otp_entry.xml \
+ app/src/main/res/layout/select_folder_layout.xml \
+ app/src/main/res/layout/password_recycler_view.xml \
+ app/src/main/res/layout/password_row_layout.xml \
+ app/src/main/res/menu/pgp_handler_new_password.xml \
+ app/src/main/res/menu/pgp_handler_select_folder.xml \
+ app/src/main/res/drawable/ic_save_24dp.xml \
+ app/src/main/res/drawable/ic_save_copy_24dp.xml \
+ ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt
+```
+
+- [ ] **Step 2: Manifest**
+
+In `app/src/main/AndroidManifest.xml`, delete
+
+```xml
+ <activity
+ android:name=".ui.crypto.PasswordCreationActivity"
+ android:exported="false"
+ android:label="@string/new_password_title"
+ android:windowSoftInputMode="adjustResize" />
+
+```
+
+and
+
+```xml
+ <activity
+ android:name=".ui.folderselect.SelectFolderActivity"
+ android:exported="false" />
+```
+
+- [ ] **Step 3: BasePGPActivity**
+
+In `app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt`, delete
+
+```kotlin
+ /** Full path to the repository */
+ val repoPath by unsafeLazy { intent.getStringExtra("REPO_PATH")!! }
+
+ /** Full path to the password file being worked on */
+ val fullPath by unsafeLazy { intent.getStringExtra("FILE_PATH")!! }
+
+```
+
+the line
+
+```kotlin
+ const val EXTRA_REPO_PATH = "REPO_PATH"
+```
+
+and the import
+
+```kotlin
+import app.passwordstore.util.extensions.unsafeLazy
+```
+
+- [ ] **Step 4: Diceware only for ViewModels**
+
+In `app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt`, replace
+
+```kotlin
+@InstallIn(FragmentComponent::class, ViewModelComponent::class)
+```
+
+with
+
+```kotlin
+@InstallIn(ViewModelComponent::class)
+```
+
+and delete the import `import dagger.hilt.android.components.FragmentComponent`.
+
+- [ ] **Step 5: Unused dependencies**
+
+In `app/build.gradle.kts`, delete the lines
+
+```kotlin
+ implementation(libs.androidx.swiperefreshlayout)
+```
+
+```kotlin
+ implementation(libs.thirdparty.flowbinding.android)
+```
+
+In `gradle/libs.versions.toml`, delete the lines
+
+```toml
+flowbinding = "1.2.0"
+```
+
+```toml
+androidx-swiperefreshlayout = "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0"
+```
+
+```toml
+thirdparty-flowbinding-android = { module = "io.github.reactivecircus.flowbinding:flowbinding-android", version.ref = "flowbinding" }
+```
+
+- [ ] **Step 6: Lint baseline**
+
+`app/lint-baseline.xml` has two `SetTextI18n` issues located in the deleted generator dialogs.
+Remove both `<issue>` blocks:
+
+```bash
+perl -0pi -e 's/\n <issue\n id="SetTextI18n"\n(?:(?!<\/issue>).)*?GeneratorDialogFragment\.kt(?:(?!<\/issue>).)*?<\/issue>\n//gs' app/lint-baseline.xml
+grep -c GeneratorDialogFragment app/lint-baseline.xml
+```
+Expected: `0`, and `git diff app/lint-baseline.xml` shows exactly two removed `<issue>` blocks.
+
+- [ ] **Step 7: Nothing refers to the removed code**
+
+```bash
+grep -rnwE 'PasswordCreationActivity|PasswordGeneratorDialogFragment|DicewarePasswordGeneratorDialogFragment|OtpImportDialogFragment|SelectFolderActivity|SelectFolderFragment|PasswordItemRecyclerAdapter|EditPasswordScreen|CopyButton|EXTRA_REPO_PATH|repoPath|fullPath|flowbinding|swiperefreshlayout' app/src ui/compose/src app/build.gradle.kts gradle/libs.versions.toml
+grep -rnE 'PasswordCreationActivityBinding|FragmentPwgen|FragmentManualOtpEntryBinding|SelectFolderLayoutBinding|PasswordRecyclerViewBinding|R\.menu\.pgp_handler_(new_password|select_folder)|ic_save(_copy)?_24dp|ui\.compose\.PasswordField' app/src ui/compose/src
+```
+Expected: no output from either.
+
+- [ ] **Step 8: The spec**
+
+In `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`, section "Editor", replace
+
+```
+- "OTP from QR" and "OTP manual" actions.
+```
+
+with
+
+```
+- "OTP from QR" (camera or image) and "OTP manual" actions.
+- "Save and copy" in the overflow. Saving returns to the list; closing returns to where the
+ editor was opened. The folder picker is a dialog, shared with the list's move action. The
+ autofill save flow (`AutofillSaveActivity`) hosts the same editor.
+```
+
+and in section "Security", replace
+
+```
+- `FLAG_SECURE` is set on the window while the entry view or editor destination is shown and
+ cleared when leaving it, so secrets stay out of screenshots and recents.
+```
+
+with
+
+```
+- `FLAG_SECURE` is set on the window while the entry view or editor destination is shown and
+ cleared when leaving it, so secrets stay out of screenshots and recents. It is counted, so
+ moving between the two never clears it.
+```
+
+- [ ] **Step 9: 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 unused resources, they were used only by the
+deleted files (likely candidates: `new_password_title`, `directory_hint`, `path_toast_text`,
+`crypto_encrypt_username_label`, `pwd_generate_button`, `otp_import_manual_entry`,
+`pwgen_include`, `select_folder`-style strings, `password_row_background` only if
+`oreo_autofill_filter_row.xml` no longer uses it): confirm with `grep -rn` that nothing else uses
+them, then delete them from every `values*/strings.xml` (a string left only in a translation is an
+`ExtraTranslation` error) or from `res/drawable`, and re-run. If lint reports an issue in a file
+from Tasks 1 to 6, fix it there and include it in this commit.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add -A app/src/main app/build.gradle.kts gradle/libs.versions.toml app/lint-baseline.xml \
+ ui/compose/src docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md
+git commit -m "refactor(app): remove the legacy editor, generators and folder picker
+
+The Compose editor, generator sheet, OTP dialog and folder picker
+replace PasswordCreationActivity, the generator and manual OTP dialog
+fragments, SelectFolderActivity with its fragment and adapter, their
+layouts and menus, and upstream's unused EditPasswordScreen with the
+PasswordField it used. flowbinding and swiperefreshlayout had no other
+users. BasePGPActivity keeps what autofill decryption still needs.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 8: 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. The PIN `1234` below is the emulator's own test
+PIN, never the owner's.
+
+- [ ] **Step 1: Server, emulator, unlock, 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
+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 xyz.danix.hardenedpass.debug || true
+./gradlew :app:installDebug
+adb shell pm grant xyz.danix.hardenedpass.debug android.permission.CAMERA
+scripts/e2e-env.sh push-keys
+OUT=$(mktemp -d)
+```
+
+(`ANDROID_HOME` is the `sdk.dir` from `local.properties`. `push-keys` only after the unlock.
+`installDebug` and `adb` both honor `ANDROID_SERIAL`. The uninstall also avoids a signature
+mismatch between checkouts, see AGENTS.md. The camera grant keeps zxing's permission prompt out
+of step 6; if `pm grant` says the permission is not requested, drop it and answer the prompt.)
+
+Helpers for this shell. `center`, `tap` and `field` take an optional match index (1 = first);
+in the editor the text fields are, in order, 1 name, 2 password, 3 username, 4 extra content.
+`cmd clipboard` is not available on this image: clipboard checks paste into the list's search
+field (keycode 279) instead.
+
+```bash
+ui() { adb exec-out uiautomator dump /dev/tty; }
+center() { local b; b=$(ui | grep -o "<node [^>]*$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 ))"; }
+tap() { local c; c=$(center "$1" "$2") && 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; }
+field() { tap 'class="android.widget.EditText"' "$1"; adb shell input keycombination 113 29; adb shell input keyevent 67; adb shell input text "$2"; }
+value() { ui | grep -o '<node [^>]*class="android.widget.EditText"[^>]*>' | sed -n "${1}p" | grep -o ' text="[^"]*"' | cut -d'"' -f2; }
+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; }
+secure() { adb shell dumpsys window windows | awk -v a="${1:-ui[.]main[.]MainActivity}" '/Window #/{w=($0 ~ a)} w && /fl=/{print (/SECURE/ ? "secure" : "not secure"); w=0}'; }
+pass() { tap 'class="android.widget.EditText"'; adb shell input text "$1"; tap 'text="OK"'; sleep 2; }
+store() { adb shell run-as xyz.danix.hardenedpass.debug ls -a "files/store/$1"; }
+srv() { docker exec -u git aps-e2e-git git -C /srv/git/store.git "$@"; }
+reveal() { srv show "master:$1.gpg" > "$OUT/x.gpg"; GNUPGHOME=.e2e/gnupg gpg -q --batch --pinentry-mode loopback --passphrase test -d "$OUT/x.gpg"; }
+```
+
+- [ ] **Step 2: Onboarding and clone**
+
+`open`, then 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" (the toggle may need a
+second tap before it shows as selected), 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`; `seen 'text="example.org"'` prints `ok`;
+`secure` prints `not secure`.
+
+- [ ] **Step 3: New entry in the root, random generator, Save and copy**
+
+```bash
+tap 'text="New entry"'; tap 'text="Create new password"'
+top; seen 'text="Save"'; seen 'text="New entry"'; secure
+adb shell input text site.example.net # the name field has focus
+tap 'content-desc="Generate Password"'; tap 'text="Random"'
+seen 'text="Numerals"'; seen 'text="Pronounceable"'; seen 'text="Ambiguous"'
+tap 'text="Pronounceable"' # an option change regenerates
+adb exec-out screencap -p > "$OUT/sheet.png"
+tap 'text="Use"'
+PW=$(value 2); echo "${#PW} $PW"
+field 3 erin
+tap 'content-desc="More options"'; tap 'text="Save and Copy"'
+sleep 3; top; seen 'text="site.example.net"'; secure
+tap 'text="Search"'; adb shell input keyevent 279; seen "text=\"$PW\""
+adb shell input keyevent 4; adb shell input keyevent 4
+```
+Expected: the editor is a `MainActivity` destination (no `PasswordCreationActivity`), `secure`;
+the sheet shows the random options; `$OUT/sheet.png` is black (open it); the password field holds
+a 20-character password (`20 ...`); after saving, the list shows `site.example.net`, the window is
+`not secure`, and the paste puts the same password in the search field (copied with the clear
+timer).
+
+- [ ] **Step 4: New entry in a subfolder, diceware generator**
+
+```bash
+tap 'text="work"'; tap 'text="New entry"'; tap 'text="Create new password"'
+seen 'text="work"' # folder crumb
+adb shell input text vpn
+tap 'content-desc="Generate Password"'; tap 'text="Diceware"'
+seen 'text="Separator"'
+field 2 _ # in the sheet: 1 words, 2 separator
+ui | grep -o 'text="[^"]*_[^"]*"'
+tap 'text="Use"'; DW=$(value 2); echo "$DW"
+tap 'text="Save"'; sleep 3; seen 'text="vpn"'; secure
+```
+Expected: the editor shows the `work` crumb; the sheet's preview turns into five words joined by
+`_`; the password field holds it; `vpn` is listed in `work`; `not secure`.
+
+- [ ] **Step 5: Edit: rename, move with the picker, new password; cancel back to the entry**
+
+```bash
+adb shell input keyevent 4 # root
+tap 'text="example.org"'; pass test; seen 'text="hunter2"'; secure
+tap 'content-desc="Edit password"'
+seen 'text="Edit entry"'; seen 'text="example.org"'; seen 'text="hunter2"'; seen 'text="alice"'
+seen 'url: https://example.org'; secure
+tap 'content-desc="Close"'; seen 'text="hunter2"'; secure # cancel: back on the entry view
+tap 'content-desc="Edit password"'
+field 1 example-login
+tap 'text="Change"'; seen 'text="Choose a folder"'; top
+tap 'text="work"'; tap 'text="Select"'; seen 'text="work"'
+field 2 n3w-pass
+tap 'text="Save"'; sleep 3
+top; seen 'text="otp"'; seen 'text="example.org"'; secure
+store; store work
+tap 'text="work"'; tap 'text="example-login"'; pass test
+seen 'text="n3w-pass"'; seen 'text="alice"'; seen 'text="https://example.org"'
+adb shell input keyevent 4
+```
+Expected: the editor opens filled in and `secure`; Close returns to the decrypted entry view, still
+`secure` (entry to editor to entry never cleared the flag); the picker is a dialog over
+`MainActivity`; after Save the root list is back (`ok: text="otp"`), `MISSING:
+text="example.org"`, `not secure`; `store` lists no `example.org.gpg` at the root and `store work`
+lists `example-login.gpg`; the moved entry decrypts with the new password, its username and url.
+
+- [ ] **Step 6: OTP from QR (menu only) and manual OTP**
+
+```bash
+tap 'text="vpn"'; pass test; tap 'content-desc="Edit password"'
+tap 'text="OTP from QR"'; seen 'text="Scan QR code"'; seen 'text="Choose an image"'
+tap 'text="Choose an image"'; sleep 2; top
+adb shell input keyevent 4; sleep 1; seen 'text="No image file was selected"'
+tap 'text="OTP from QR"'; tap 'text="Scan QR code"'; sleep 3; top
+adb shell input keyevent 4; sleep 1; seen 'text="Failed to import TOTP configuration"'
+tap 'text="Manual OTP"'; seen 'text="Add OTP"'
+adb shell input text JBSWY3DPEHPK3PXP # the secret field has focus
+tap 'class="android.widget.EditText"' 2; adb shell input text example.net
+tap 'text="OK"'
+seen 'otpauth://totp?secret=JBSWY3DPEHPK3PXP&amp;issuer=example.net'; seen 'text="Manual OTP"'
+tap 'text="Save"'; sleep 3; seen 'text="vpn"'
+```
+Expected: the QR menu offers both sources; "Choose an image" opens the system picker
+(`com.android.documentsui` or `com.google.android.documentsui` on top) and backing out shows the
+"no image" snackbar; "Scan QR code" opens zxing's `CaptureActivity`, backing out shows the failure
+snackbar. QR decoding itself cannot be exercised on the emulator (no QR in front of its virtual
+camera, no QR image on the device); the maintainer checks it on the phone. The manual dialog adds
+the URI line to the extra content and both OTP actions disappear (`MISSING: text="Manual OTP"`).
+The OTP entry view never goes idle, so its code is checked on the server in step 11, not here.
+
+- [ ] **Step 7: Missing recipient refusal (0.1.2)**
+
+```bash
+FPR=$(cat .e2e/seed/.gpg-id)
+printf '%s\nMissing Key <missing@example.org>\n' "$FPR" | adb exec-in "run-as xyz.danix.hardenedpass.debug sh -c 'mkdir -p files/store/missing && cat > files/store/missing/.gpg-id'"
+adb shell input keyevent 4; sleep 1 # root, re-read on resume
+tap 'text="missing"'; tap 'text="New entry"'; tap 'text="Create new password"'
+adb shell input text nope; field 2 x
+tap 'text="Save"'; sleep 2
+seen 'text="Error"'; ui | grep -o 'nothing was saved[^"]*'
+tap 'text="OK"'; seen 'text="nope"'
+tap 'content-desc="Close"'; sleep 1; seen 'text="nope"'
+store missing
+adb shell run-as xyz.danix.hardenedpass.debug rm -r files/store/missing
+adb shell input keyevent 4
+```
+Expected: saving shows the "Error" dialog with "Could not encrypt the password, nothing was saved:
+No key found with id: ..." naming the missing recipient; OK leaves the editor open with its
+content; Close returns to the folder without a `nope` entry; `store missing` lists only `.`, `..`
+and `.gpg-id` (no file, not even an empty one). The folder is removed again before the sync.
+
+- [ ] **Step 8: Cancel paths and rotation mid-edit**
+
+```bash
+tap 'text="New entry"'; tap 'text="Create new password"'
+adb shell input text cancel-test; tap 'content-desc="Close"'; sleep 1
+seen 'text="cancel-test"'; secure
+tap 'text="New entry"'; tap 'text="Create new password"'
+adb shell input text rot-test; field 2 abc123; field 4 line1
+adb shell settings put system accelerometer_rotation 0; adb shell settings put system user_rotation 1; sleep 2
+seen 'text="rot-test"'; seen 'text="abc123"'; seen 'text="line1"'; secure
+adb shell settings put system user_rotation 0; sleep 2; seen 'text="rot-test"'
+for i in 1 2; do ui | grep -q 'text="Save"' && adb shell input keyevent 4; sleep 1; done
+seen 'text="rot-test"'; secure; store
+```
+Expected: Close discards (`MISSING: text="cancel-test"`, `not secure`); after rotation the editor
+still shows all three values and is `secure`; system back (after the keyboard, if it was up)
+discards too (`MISSING: text="rot-test"`, `not secure`), and `store` lists neither file.
+
+- [ ] **Step 9: List move with the new picker**
+
+```bash
+hold 'text="site.example.net"'; tap 'content-desc="Move"'
+seen 'text="Choose a folder"'; top
+tap 'text="work"'; tap 'content-desc="Password store"' # into a folder, back to the root
+tap 'text="New folder"'; seen 'text="Create folder"'
+tap 'text="Create"'; seen 'Category name can' # blank
+adb shell input text work; tap 'text="Create"'; seen 'text="A folder by that name already exists"'
+field 1 ../outside; tap 'text="Create"'; seen 'text="Destination must be within the repository"'
+field 1 archive; tap 'text="Create"'; seen 'text="archive"'; seen 'text="Choose a folder"'
+store; store archive; adb shell run-as xyz.danix.hardenedpass.debug ls files
+tap 'content-desc="Password store"'
+tap 'text="otp"'; tap 'text="Select"'; sleep 2
+seen 'text="site.example.net"'; seen 'text="archive"'
+tap 'text="otp"'; seen 'text="site.example.net"'
+adb shell input keyevent 4
+```
+Expected: the picker is a dialog over `MainActivity` (no `SelectFolderActivity`), its breadcrumbs
+jump back to the root. "New folder" opens the list's "Create folder" dialog over the picker and
+refuses, with the list's messages, a blank name, an existing folder (`work` at the root) and a
+name that resolves outside the store (`../outside`); `archive` is created and the picker shows it
+(crumb `archive`, still the picker); `store` lists `archive`, `store archive` lists only `.` and
+`..`, and `ls files` shows no `outside` next to `store`. Back at the root crumb, Select on `otp` moves
+the entry: gone from the root (`MISSING: text="site.example.net"`), `archive` listed in the
+root, the entry listed in `otp`. (`archive` stays empty, so git never sees it and step 11's
+server tree is unchanged.)
+
+- [ ] **Step 10: Autofill save flow (if `adb root` works on this image)**
+
+```bash
+if adb root | grep -q 'cannot run as root'; then echo "skip: no adb root"; else
+ adb wait-for-device; sleep 2
+ adb shell am start -n xyz.danix.hardenedpass.debug/app.passwordstore.ui.autofill.AutofillSaveActivity \
+ --es app.passwordstore.autofill.oreo.ui.EXTRA_FOLDER_NAME example.net \
+ --es app.passwordstore.autofill.oreo.ui.EXTRA_NAME dave \
+ --es app.passwordstore.autofill.oreo.ui.EXTRA_PASSWORD from-the-form \
+ --es app.passwordstore.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB example.net
+ sleep 3; top; seen 'text="dave"'; seen 'text="from-the-form"'; seen 'text="example.net"'
+ secure 'AutofillSaveActivity'
+ tap 'text="Save"'; sleep 3; top
+ adb shell am start -n xyz.danix.hardenedpass.debug/app.passwordstore.ui.autofill.AutofillSaveActivity \
+ --es app.passwordstore.autofill.oreo.ui.EXTRA_FOLDER_NAME example.net \
+ --es app.passwordstore.autofill.oreo.ui.EXTRA_NAME frank \
+ --ez app.passwordstore.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD true
+ sleep 3; seen 'text="Use"'; tap 'text="Use"'; tap 'text="Save"'; sleep 3; top
+ adb unroot; adb wait-for-device; sleep 2
+fi
+open; tap 'text="example.net"'; seen 'text="dave"'; seen 'text="frank"'; adb shell input keyevent 4
+```
+Expected (with root): `AutofillSaveActivity` on top showing the editor prefilled with `dave`,
+`from-the-form` and the `example.net` crumb, `secure`; Save finishes it. The second start opens
+with the generator sheet already up; Use and Save finish it (no autofill client state from the
+shell, so it only logs that and closes; the file is still saved and committed). The list then
+shows `example.net` with `dave` and `frank`. Without root, the step prints `skip` and the list
+check is skipped too. Either way the real flow is the maintainer's check, not part of this run
+(maintainer decision 1): on the tablet with Firefox, submit a login form and save it from the
+autofill prompt, then use "Generate password" on a sign-up form; both open this editor, save,
+and the generated password is filled in. Record the result in the session handoff.
+
+- [ ] **Step 11: Sync, and what the server got**
+
+```bash
+adb shell input swipe 540 700 540 1800 400; sleep 8
+srv log --format=%s
+srv ls-tree -r --name-only master
+reveal otp/site.example.net; echo ---
+reveal work/example-login; echo ---
+reveal work/vpn; echo ---
+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))'
+```
+Expected: no error dialog. The log shows, above `Initial test store`: `Add generated password
+for site.example.net using HardenedPass.`, `Add generated password for work/vpn using
+HardenedPass.`, `Edit password for work/example-login using HardenedPass.`, `Edit password for
+work/vpn using HardenedPass.`, `Rename site.example.net to otp/site.example.net.` (plus the two
+`example.net/...` additions when step 10 ran). The tree has no `example.org.gpg`, no
+`missing/`, no `rot-test.gpg` or `cancel-test.gpg`, no `.tmp` file. Decrypted:
+`otp/site.example.net` is `$PW`, then `username: erin`; `work/example-login` is `n3w-pass`,
+`username: alice`, `url: https://example.org`; `work/vpn` is `$DW` then
+`otpauth://totp?secret=JBSWY3DPEHPK3PXP&issuer=example.net`. (The Python line prints the current
+code for that secret, for a by-eye comparison if wanted: open `work/vpn` and take an emulator
+console screenshot, as in step 3's plan.)
+
+- [ ] **Step 12: Tear down and push**
+
+```bash
+adb shell settings put system user_rotation 0; adb shell settings put system accelerometer_rotation 1
+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
+rm -rf "$OUT"
+git push
+```
+
+Expected: push succeeds to every `origin` push URL.
+
+---
+
+## Self-review notes
+
+- **Spec coverage for step 4:** `EditRoute` is a Compose destination in `MainActivity`, opened from
+ the create sheet in the current folder and from the entry view's edit action (Task 5); after save
+ the list, after cancel back where it came from (Task 5, checked in Task 8 steps 3, 5, 8). Top bar
+ close, title, Save; name; folder chips with Change opening the picker; password with reveal and
+ generator; generator bottom sheet with random and diceware tabs and the old options; username;
+ free-text extras; OTP from QR and manual (Tasks 4, 5). Strings in `values/` and `values-it/`
+ ("voce" for entry; Tasks 2, 4). Folder picker shared with the list's move (Tasks 2, 5; checked
+ in steps 5 and 9, "New folder" with its refusals in step 9). `FLAG_SECURE` counted across entry and editor, explicit on the sheet and the
+ OTP dialog, on for the autofill editor (Tasks 4 to 6; steps 3, 5, 8, 10). Plaintext only in
+ ViewModel memory; edits copy it from the entry view's ViewModel (Tasks 3, 5). Save path keeps
+ every check and closes three gaps (Task 3; steps 5, 7, 11). Autofill save keeps working, now on
+ the Compose editor (Task 6; step 10 and the maintainer's device check). Deletions and what is
+ left of `BasePGPActivity` (Task 7). TDD for the pure logic (Task 1). Full local CI (Task 7).
+ Emulator check of every scenario asked for (Task 8).
+- **Parity summary:** ported: secure window, titles, name focus, password reveal and monospace,
+ generators with all options and stored preferences, OTP scan, image and manual import with
+ snackbars, OTP actions hidden when a secret exists, validation messages, `.gpg-id` recipients,
+ missing-key refusal, nothing written on failed encryption, duplicate check, inside-repository
+ check, rename with recently-used time, commit messages, autofill result. Changed: folder chips
+ and picker instead of a path field, username always shown, password visibility from the setting,
+ Save and copy in the overflow and only after success, generator errors in the sheet, write
+ errors keep the editor open, an undeletable old file is a snackbar, rotation keeps the form in
+ memory instead of saved state. Fixed: overwrite on a move, copy left behind on a move, folder
+ created before the inside check, failed write truncating an entry, autofill matches lost on
+ rename. Dropped: "Encrypt username" switch, the editor's "No keys imported" prompt.
+- **Deliberate shortcuts (`ponytail:`):** an edit restored after process death closes (Task 5);
+ commit failures not shown and a mid-save theme change committing later (Task 5); the picker's
+ plain scrolling column (Task 2); generator length caps (Task 4); `AutofillSaveActivity`'s
+ `configChanges` (Task 6).
+- **Type and name consistency:** `EditorForm`, `editorForm`, `passContent`, `formError`,
+ `entryFile`, `overwrites`, `writeEntry`, `withLine`, `totpUri`, `RandomSpec.options`,
+ `randomPassword`, `DicewareSpec` (Task 1) are used with the same signatures in Tasks 3 and 5;
+ `SaveResult.Saved(file, form, staleOriginal)` in Tasks 3, 5, 6; `EditorViewModel.start`,
+ `started`, `saving`, `hasOtp`, `randomSpec`, `generateRandom`, `dicewareSpec`,
+ `generateDiceware`, `readQr`, `save` (Task 3) match their calls in Task 5; `FolderPicker(root,
+ start, onPick, onDismiss)` in Tasks 2 and 5, `FolderNameDialog` and `folderNameError` shared by
+ the list and the picker; `ListDestination(..., onNewEntry)` and
+ `EntryDestination(..., onEdit)` match `MainActivity` (Task 5).
+
+## Maintainer decisions (2026-11-03)
+
+1. Autofill save moves to the Compose editor in this step (Task 6 as planned). The tablet and
+ Firefox check of the real autofill save and generate flows is the maintainer's; Task 8, step 10
+ says so.
+2. The folder picker gets a "New folder" action: it creates a folder inside the folder the picker
+ shows, with the list's validation (blank, inside the repository by path components, already
+ exists), then shows it so Select picks it. The list's `FolderNameDialog` (now `internal`) and
+ `folderNameError` are reused, not duplicated. New string `folder_picker_new` in `values/` and
+ `values-it/`. Applied in Task 2, checked in Task 8, step 9.
+3. "Encrypt username" switch: dropped, as planned.
+4. "Save and copy": kept in the overflow menu, copies only after a successful save, as planned.
diff --git a/docs/superpowers/plans/2026-11-02-compose-entry.md b/docs/superpowers/plans/2026-11-02-compose-entry.md
new file mode 100644
index 00000000..bd4314ca
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-02-compose-entry.md
@@ -0,0 +1,2149 @@
+# Compose UI overhaul, step 3: Entry view. 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 `DecryptActivity` with a Compose entry view behind `MainActivity`'s `EntryRoute`: header (avatar, name, folder), top bar (back, edit, overflow), a first group with password, username and one-time code (countdown bar), a second group with the extra fields. The list navigates to it, `LaunchActivity` shortcuts start on it. Decryption, the passphrase cache, biometric unlock, clipboard handling and the auto-close timer behave as before.
+
+**Architecture:** `EntryRoute(filePath)` (absolute path) gets its own `EntryViewModel` through `hiltViewModel()`. The ViewModel ports `DecryptActivity`'s unlock flow (cache, passphrase prompt, retries, decryption off the main thread, auto-close timer) and holds the decrypted `PasswordEntry` in Compose state only: never in saved state or a route, dropped by the timer and when the back stack entry is popped (its ViewModel is cleared). The activity-bound parts stay in the destination composable: the biometric prompt (needs the `FragmentActivity`), the PGP key import and editor launchers, the clipboard and share intents, and `FLAG_SECURE`, set on the window while the destination is composed. The screen itself is a stateless composable fed by a small pure model (`EntryFields`). `BasePGPActivity` keeps what `PasswordCreationActivity` and `AutofillDecryptActivity` need; its clipboard and `.gpg-id` helpers become top-level `FragmentActivity` extensions in the same file, so `MainActivity` can call them too and the subclasses compile unchanged. `DecryptActivity` and everything only it used are deleted.
+
+**Tech Stack:** Kotlin 2.4.21, Compose BOM 2024.09.03 (Material 3 1.3.0: `TopAppBar`, `LinearProgressIndicator(progress = { })`, `AlertDialog(properties = ...)`), navigation-compose 2.8.9 (type-safe `popUpTo<T>`), `hilt-navigation-compose` 1.2.0, androidx.biometric (unchanged), kotlinx-collections-immutable. No new dependencies.
+
+Spec: `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`. Earlier plans: `docs/superpowers/plans/2026-11-02-compose-foundation.md`, `docs/superpowers/plans/2026-11-02-compose-list.md`.
+
+## Decisions taken in this plan
+
+- **Entry state lives in a ViewModel scoped to the route's back stack entry.** Reasons: Hilt injects the crypto stack there (the destination composable cannot `@Inject`); `viewModelScope` is cancelled exactly when the entry is popped, which is also when the decrypted entry must go; a theme or locale change (which still recreates `MainActivity`) keeps the decrypted view instead of prompting again. Rotation does not recreate at all (`configChanges="orientation|screenSize"`, kept). The ViewModel takes a `Context` per call (`applicationContext`) and holds no `Context` field (lint `StaticFieldLeak`).
+- **FLAG_SECURE:** the entry destination adds the flag in a `DisposableEffect` and clears it on dispose. In this step no two secret-showing destinations are ever composed together (entry to entry transitions do not exist), so a plain add/clear pair is enough; it is `ponytail:` marked so step 4 counts instead if the editor destination can overlap the entry during a transition. The list is not `FLAG_SECURE` today (only `BasePGPActivity` and `PasswordDialog` set it) and stays so. The passphrase dialog sets `SecureFlagPolicy.SecureOn` explicitly.
+- **Path check:** routes from the list come from the app's own listing, routes from `LaunchActivity` passed `isFileInRepo`. The destination checks `isFileInRepo` once more before decrypting (the spec asks the entry route to be validated) and closes otherwise.
+- **Back and close:** system back is the `NavHost` default (pop to the list, or leave the app when the entry is the start destination). The top bar back, the auto-close timer, a cancelled prompt and too many wrong passphrases all call one `onClose`: pop when there is a previous entry, else `finish()`. It acts only when its entry is on top, so the timer cannot race a back press into a double pop. The list's tap uses the same "only from the top entry" guard as folders, so a double tap does not push the entry twice.
+- **Edit:** the top bar edit button starts the legacy `PasswordCreationActivity` for a result, with exactly the extras `DecryptActivity` passed. On `RESULT_OK` (saved) the entry route is popped and the list shows, refreshed by its resume effect; when the entry was the start destination (shortcut), the list root replaces it instead of finishing. No reopen, no second passphrase prompt. Cancelling the editor returns to the unchanged view. (Maintainer decision 3.)
+- **Password visibility:** the password row starts revealed or masked per the existing "Show the password" setting (`SHOW_PASSWORD`, default on, as the old screen did); the eye toggles it. (Maintainer decision 2.)
+- **Clipboard:** the password and one-time code copy buttons use `copyPasswordToClipboard` (sensitive flag plus the `ClipboardService` clear timer, the mechanism the old "Copy password" menu item used). Username, extra fields and the name use `copyTextToClipboard` (sensitive flag, no timer), as before. (Maintainer decision 1.) "Automatically copy password" (`COPY_ON_DECRYPT`) is kept.
+- **OTP countdown:** `Totp` gains `period`, so the bar shows `remainingTime / period`. The label keeps the existing `otp_label_format` ("OTP (expires in %ds)"), which also gives screen readers the countdown the bar cannot.
+- **Overflow menu:** "Copy name" (the old screen copied the name on a long press of the title, which a top bar has no room for) and "Share as plaintext" (chooser, as before). The old "Copy password" item is dropped: the password row has the same button.
+- **Passphrase dialog:** a Compose `AlertDialog` replaces `PasswordDialog` here (same title, field, "Clear cached password on screen-off" box, "Wrong password" error, Enter submits). The typed passphrase is held in `remember`, never `rememberSaveable`. `PasswordDialog` stays for `AutofillDecryptActivity` until step 7.
+- **`BasePGPActivity`:** `copyTextToClipboard`, `copyPasswordToClipboard` and `getPGPIdentifiers` move out of the class into top-level `FragmentActivity` extensions in `BasePGPActivity.kt` (same package, so `PasswordCreationActivity` needs no change; `AutofillDecryptActivity` gains one import). `copyPasswordToClipboard` reads `sharedPrefs` instead of the injected `settings`; both are the same preferences file. The `name` property, used only by `DecryptActivity`, is removed. Everything else stays for `PasswordCreationActivity` (step 4) and `AutofillDecryptActivity` (step 7).
+- **Small fixes, recorded:** a cached passphrase that fails is not tried again (the old flow looped on it forever); after the third failed attempt the screen closes instead of calling `finish()` and still showing the dialog; dismissing the "No keys imported" dialog closes the entry instead of leaving an empty screen; reading the file and `isPasswordProtected` run on the IO dispatcher.
+
+## Feature parity (old `DecryptActivity` / `BasePGPActivity` behavior)
+
+| Behavior today | Decision |
+|---|---|
+| `FLAG_SECURE` on the whole decrypt activity | Port: on while the entry destination is composed, off on the list |
+| Title = entry name; header with folder path and name | Port: header with letter avatar, name, folder ("Password store" at the root) |
+| Long press on the name copies it | Port as overflow "Copy name" |
+| Biometric prompt "Unlock passphrase cache" when the cache feature is on and the device can authenticate | Port unchanged (`BiometricAuthenticator`, same title) |
+| Biometric cancelled, failed or unavailable: passphrase prompt | Port |
+| `requireKeysExist`: "No keys imported" dialog, Import opens `PGPKeyImportActivity`, success continues, failure finishes | Port as Compose dialog; dismissing it now closes the entry |
+| `.gpg-id` lookup up to the store root, snackbars for short or invalid IDs | Port (`getPGPIdentifiers` extension, same messages) |
+| Cache cleared on first use after app start or screen off when "Automatically clear" is on | Port |
+| Cached passphrase used after a successful biometric prompt | Port; a cached passphrase that fails is not retried (old code looped) |
+| Key without passphrase: decrypt with an empty one | Port |
+| `PasswordDialog`: field, cache box (only with the cache on), "Wrong password" error, Enter submits, `FLAG_SECURE`, cancel finishes | Port as Compose dialog (`SecureOn`, passphrase only in `remember`); cancel closes the entry. `PasswordDialog` kept for autofill |
+| At most 3 passphrase prompts | Port (now really closes after the third) |
+| Passphrase cached after a successful prompt following a biometric success; "clear on screen off" choice saved | Port |
+| Decryption on the IO dispatcher | Port (`CryptoRepository.decrypt` unchanged; file read and key check also moved off main) |
+| Fields: password (eye toggle, `SHOW_PASSWORD` initial state, JetBrains Mono, tap copies), OTP (label with seconds, copy), username (copy), free-form extras (copy) | Port in the spec's order: password, username, OTP in the first group; extras in the second. Monospace kept for the password. Password and one-time code copies start the clear timer |
+| OTP refreshed every second | Port (`PasswordEntry.totp` flow) plus a countdown bar |
+| "Automatically copy password" on decrypt | Port |
+| Auto-close after "Password copy timeout" (`GENERAL_SHOW_TIME`, default 60 s, 0 = never) | Port: the ViewModel drops the entry and the destination closes (leaves the app when it was the start destination) |
+| Menu: Edit (legacy editor, then finish) | Port as top bar icon; saving returns to the list (the list root when opened from a shortcut), cancelling returns to the view |
+| Menu: Share as plaintext (always a chooser) | Port (overflow) |
+| Menu: Copy password | Drop: duplicate of the password row's copy button |
+| Up arrow | Port (top bar back) |
+| Rotation recreates the screen and asks again | Changed: rotation keeps the view (`configChanges`); theme or locale change keeps it too (ViewModel) |
+| Dynamic shortcut added when opened from the list | Port (`MainActivity.addShortcut`) |
+| `DecryptActivity`, `decrypt_layout.xml`, `item_field.xml`, `menu/pgp_handler.xml`, `drawable/divider.xml`, `FieldItemAdapter`, `FieldItem`, upstream's unused `ViewPasswordScreen.kt` | Delete (Task 6) |
+| `BasePGPActivity`: `repoPath`, `fullPath`, key import, injected `settings`, `repository`, `dispatcherProvider`, `FLAG_SECURE`, path helpers in the companion | Keep for `PasswordCreationActivity` and `AutofillDecryptActivity` |
+| `BasePGPActivity.name` | Delete (only `DecryptActivity` used it) |
+| Text selection of field values (old `textIsSelectable`) | Dropped: the copy buttons set the sensitive clipboard flag, selection toolbar copies would not |
+| `TotpModule` | Also installed in `ViewModelComponent` (needed for `PasswordEntry.Factory` in the ViewModel) |
+
+Left for later steps because other legacy screens still use them: `PasswordDialog` and `dialog_password_entry.xml` (`AutofillDecryptActivity`, step 7), `EditPasswordScreen.kt`, `PasswordField.kt`/`CopyButton`, `APSAppBar.kt` and the ui:compose `PasswordItem.kt` (unused upstream WIP, step 4 or 8), `BasePGPActivity` (steps 4 and 7).
+
+## Lessons from step 2, designed in
+
+- `android:configChanges="orientation|screenSize"` on `MainActivity` stays; the plan relies on it only for rotation. Work that must survive a theme change lives in `viewModelScope`, not `activity.lifecycleScope`.
+- Slack compose-lint: no composable members on the activity (`EntryDestination` and `EntryScreen` are top-level and take the activity as a parameter, like `ListDestination`); every public composable has `modifier: Modifier = Modifier` as its first optional parameter; the only collection parameter is inside `EntryFields` (`ImmutableList`, class marked `@Immutable`); the ViewModel is not passed down.
+- Accessibility: every `IconButton` has a content description (back, edit, more options, show or hide password, copy); there is no FAB on this screen. `LetterAvatar` clears its own semantics, so the name is read once.
+- Focus: the entry screen has no text field; the passphrase dialog requests focus inside its own window, so nothing is left focused when the list comes back (the list's own return handling from step 2 then applies).
+- Decryption and entry file IO never run on the main thread.
+
+**Conventions for every task**
+
+- New files get this header:
+ ```kotlin
+ /*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * 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 <noreply@anthropic.com>`.
+- 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: OTP period and the entry model (TDD)
+
+**Files:**
+- Modify: `format/common/src/main/kotlin/app/passwordstore/data/passfile/Totp.kt`
+- Modify: `format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt`
+- Modify: `format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt`
+- Create: `app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt`
+- Test: `app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt`
+
+- [ ] **Step 1: Failing test for the period**
+
+In `PasswordEntryTest.kt`, inside `generatesOtpFromTotpUri`, after
+`assertEquals(30.seconds, otp.remainingTime)` add:
+
+```kotlin
+ assertEquals(30.seconds, otp.period)
+```
+
+Run: `./gradlew :format:common:test --tests app.passwordstore.data.passfile.PasswordEntryTest`
+Expected: compilation FAILS with `Unresolved reference 'period'`.
+
+- [ ] **Step 2: Add the period**
+
+Replace the body of `Totp.kt` after the package line with:
+
+```kotlin
+import kotlin.time.Duration
+
+/** A TOTP code, how long it stays valid, and the full period it was generated for. */
+public data class Totp(
+ public val value: String,
+ public val remainingTime: Duration,
+ public val period: Duration,
+)
+```
+
+In `PasswordEntry.kt`, `calculateTotp`, replace
+
+```kotlin
+ .mapBoth({ code -> Ok(Totp(code, remainingTime)) }, ::Err)
+```
+
+with
+
+```kotlin
+ .mapBoth({ code -> Ok(Totp(code, remainingTime, totpPeriod.seconds)) }, ::Err)
+```
+
+(`totpPeriod` is a `Long`; `kotlin.time.Duration.Companion.seconds` is already imported.)
+
+Run: `./gradlew :format:common:test`
+Expected: `BUILD SUCCESSFUL`, all format tests pass. (`format:common` has no metalava API file;
+only `autofill-parser` does, so the public constructor change needs no API update.)
+
+- [ ] **Step 3: Failing test for the entry model**
+
+`app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.entry
+
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.data.passfile.Totp
+import app.passwordstore.util.time.UserClock
+import app.passwordstore.util.totp.UriTotpFinder
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+
+class EntryModelTest {
+
+ private fun entry(content: String) =
+ PasswordEntry(UserClock(), UriTotpFinder(), content.encodeToByteArray())
+
+ @Test
+ fun firstGroupAndExtrasInFileOrder() {
+ val fields =
+ entryFields(entry("hunter2\nusername: alice\nurl: https://example.org\npin: 1234\n"))
+ assertEquals("hunter2", fields.password)
+ assertEquals("alice", fields.username)
+ assertEquals(listOf("url" to "https://example.org", "pin" to "1234"), fields.extras)
+ }
+
+ @Test
+ fun blankPasswordAndMissingUsernameAreLeftOut() {
+ val fields = entryFields(entry("\nnote: none\n"))
+ assertNull(fields.password)
+ assertNull(fields.username)
+ assertEquals(listOf("note" to "none"), fields.extras)
+ }
+
+ @Test
+ fun otpUriIsNotAnExtraField() {
+ val fields =
+ entryFields(
+ entry(
+ "s3cret\notpauth://totp/example.com:carol?secret=JBSWY3DPEHPK3PXP&issuer=example.com\n"
+ )
+ )
+ assertEquals("s3cret", fields.password)
+ assertTrue(fields.extras.isEmpty())
+ }
+
+ @Test
+ fun fractionLeftOfTheOtpPeriod() {
+ assertEquals(1f, Totp("123456", 30.seconds, 30.seconds).fractionLeft())
+ assertEquals(0.5f, Totp("123456", 15.seconds, 30.seconds).fractionLeft())
+ }
+}
+```
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.entry.EntryModelTest`
+Expected: compilation FAILS with `Unresolved reference 'entryFields'` and `'fractionLeft'`.
+
+- [ ] **Step 4: The model**
+
+`app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.entry
+
+import androidx.compose.runtime.Immutable
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.data.passfile.Totp
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+/**
+ * What the entry view shows. [password] and [username] open the first group (the one-time code
+ * joins them from the entry's TOTP flow); [extras] are the remaining `key: value` lines in file
+ * order, plus lines that are not `key: value` under the parser's "Extra Content" key. Blank values
+ * are null, so their rows are not shown.
+ */
+@Immutable
+data class EntryFields(
+ val password: String?,
+ val username: String?,
+ val extras: ImmutableList<Pair<String, String>>,
+)
+
+fun entryFields(entry: PasswordEntry): EntryFields =
+ EntryFields(
+ password = entry.password?.takeIf { it.isNotBlank() },
+ username = entry.username?.takeIf { it.isNotBlank() },
+ extras = entry.extraContent.toList().toImmutableList(),
+ )
+
+/** Share of the code's lifetime left: 1 right after it was generated, toward 0 at expiry. */
+fun Totp.fractionLeft(): Float = (remainingTime / period).toFloat()
+```
+
+(`PasswordEntry.extraContent` already leaves out the password line, the first username line and
+TOTP lines, and is a `LinkedHashMap`, so file order holds.)
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.entry.EntryModelTest`
+Expected: `BUILD SUCCESSFUL`, 4 tests passed.
+
+- [ ] **Step 5: Build and commit**
+
+Run: `./gradlew spotlessApply && CI=1 ./gradlew :format:common:test :app:assembleDebug :app:testDebugUnitTest`
+Expected: `BUILD SUCCESSFUL`.
+
+```bash
+git add format/common/src app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt \
+ app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt
+git commit -m "feat(app): add the entry view model and the OTP period
+
+Totp now carries its period, so the entry view can draw how much of the
+code's lifetime is left. EntryFields groups a decrypted entry the way the
+spec lays it out.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: PGP clipboard and identifier helpers become activity extensions
+
+`MainActivity` is not a `BasePGPActivity`, but the entry view needs the same clipboard and
+`.gpg-id` behavior. Moving the three helpers out of the class, unchanged, into top-level
+`FragmentActivity` extensions in the same file keeps one implementation. Calls inside
+`BasePGPActivity` subclasses resolve to the extensions with the activity as implicit receiver,
+so `PasswordCreationActivity` and `DecryptActivity` (same package) compile unchanged.
+
+**Files:**
+- Modify (replace whole file): `app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt`
+
+- [ ] **Step 1: BasePGPActivity**
+
+Replace `app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt` with:
+
+```kotlin
+/*
+ * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.crypto
+
+import android.content.ClipData
+import android.content.Intent
+import android.content.SharedPreferences
+import android.os.Build
+import android.os.Bundle
+import android.os.PersistableBundle
+import android.view.WindowManager
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.annotation.CallSuper
+import androidx.annotation.StringRes
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.R
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.data.crypto.CryptoRepository
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.ui.pgp.PGPKeyImportActivity
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.extensions.clipboard
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.extensions.snackbar
+import app.passwordstore.util.extensions.unsafeLazy
+import app.passwordstore.util.services.ClipboardService
+import app.passwordstore.util.settings.Constants
+import app.passwordstore.util.settings.PreferenceKeys
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dagger.hilt.android.AndroidEntryPoint
+import java.io.File
+import javax.inject.Inject
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+@Suppress("Registered")
+@AndroidEntryPoint
+open class BasePGPActivity : AppCompatActivity() {
+
+ /** Full path to the repository */
+ val repoPath by unsafeLazy { intent.getStringExtra("REPO_PATH")!! }
+
+ /** Full path to the password file being worked on */
+ val fullPath by unsafeLazy { intent.getStringExtra("FILE_PATH")!! }
+
+ /**
+ * Name of the password file
+ *
+ * Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org
+ */
+ val name: String by unsafeLazy { File(fullPath).nameWithoutExtension }
+
+ /** Action to invoke if [keyImportAction] succeeds. */
+ private var onKeyImport: (() -> Unit)? = null
+ private val keyImportAction =
+ registerForActivityResult(StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ onKeyImport?.invoke()
+ onKeyImport = null
+ } else {
+ finish()
+ }
+ }
+
+ /** [SharedPreferences] instance used by subclasses to persist settings */
+ @SettingsPreferences @Inject lateinit var settings: SharedPreferences
+ @Inject lateinit var repository: CryptoRepository
+ @Inject lateinit var dispatcherProvider: DispatcherProvider
+
+ /**
+ * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots or
+ * recent apps screen.
+ */
+ @CallSuper
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
+ }
+
+ /**
+ * Function to execute [onKeysExist] only if there are PGP keys imported in the app's key manager.
+ */
+ fun requireKeysExist(onKeysExist: () -> Unit) {
+ lifecycleScope.launch {
+ val hasKeys = repository.hasKeys()
+ if (!hasKeys) {
+ withContext(dispatcherProvider.main()) {
+ MaterialAlertDialogBuilder(this@BasePGPActivity)
+ .setTitle(resources.getString(R.string.no_keys_imported_dialog_title))
+ .setMessage(resources.getString(R.string.no_keys_imported_dialog_message))
+ .setPositiveButton(resources.getString(R.string.button_label_import)) { _, _ ->
+ onKeyImport = onKeysExist
+ keyImportAction.launch(Intent(this@BasePGPActivity, PGPKeyImportActivity::class.java))
+ }
+ .show()
+ }
+ } else {
+ onKeysExist()
+ }
+ }
+ }
+
+ companion object {
+
+ const val EXTRA_FILE_PATH = "FILE_PATH"
+ const val EXTRA_REPO_PATH = "REPO_PATH"
+
+ /** Gets the relative path to the repository */
+ fun getRelativePath(fullPath: String, repositoryPath: String): String =
+ fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
+
+ /** Gets the Parent path, relative to the repository */
+ fun getParentPath(fullPath: String, repositoryPath: String): String {
+ val relativePath = getRelativePath(fullPath, repositoryPath)
+ val index = relativePath.lastIndexOf("/")
+ return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/"
+ .replace("/+".toRegex(), "/")
+ }
+
+ /** /path/to/store/social/facebook.gpg -> social/facebook */
+ @JvmStatic
+ fun getLongName(fullPath: String, repositoryPath: String, basename: String): String {
+ var relativePath = getRelativePath(fullPath, repositoryPath)
+ return if (relativePath.isNotEmpty() && relativePath != "/") {
+ // remove preceding '/'
+ relativePath = relativePath.substring(1)
+ if (relativePath.endsWith('/')) {
+ relativePath + basename
+ } else {
+ "$relativePath/$basename"
+ }
+ } else {
+ basename
+ }
+ }
+ }
+}
+
+/**
+ * Copies provided [text] to the clipboard, flagged sensitive. Below Android 11 it also shows a
+ * snackbar, which can be disabled by passing [showSnackbar] as false.
+ */
+fun FragmentActivity.copyTextToClipboard(
+ text: String?,
+ showSnackbar: Boolean = true,
+ @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text,
+) {
+ val clipboard = clipboard ?: return
+ val clip = ClipData.newPlainText("pgp_handler_result_pm", text)
+ clip.description.extras =
+ PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) }
+ clipboard.setPrimaryClip(clip)
+ if (showSnackbar && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ snackbar(message = resources.getString(snackbarTextRes))
+ }
+}
+
+/**
+ * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] and
+ * starts off an instance of [ClipboardService] to provide a way of clearing the clipboard.
+ */
+fun FragmentActivity.copyPasswordToClipboard(password: String?) {
+ copyTextToClipboard(password)
+ val clearAfter =
+ sharedPrefs.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull()
+ ?: Constants.DEFAULT_DECRYPTION_TIMEOUT
+
+ if (clearAfter != 0) {
+ val service =
+ Intent(this, ClipboardService::class.java).apply {
+ action = ClipboardService.ACTION_START
+ putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter)
+ }
+ startForegroundService(service)
+ }
+}
+
+/**
+ * Get a list of available [PGPIdentifier]s for the current password repository. This method
+ * throws when no identifiers were able to be parsed. If this method returns null, it means that an
+ * invalid identifier was encountered and further execution must stop to let the user correct the
+ * problem.
+ */
+fun FragmentActivity.getPGPIdentifiers(subDir: String): List<PGPIdentifier>? {
+ val repoRoot = PasswordRepository.getRepositoryDirectory()
+ val gpgIdentifierFile =
+ File(repoRoot, subDir).findTillRoot(".gpg-id", repoRoot)
+ ?: File(repoRoot, ".gpg-id").apply { createNewFile() }
+ val gpgIdentifiers =
+ gpgIdentifierFile
+ .readLines()
+ .filter { it.isNotBlank() }
+ .map { line ->
+ PGPIdentifier.fromString(line)
+ ?: run {
+ // The line being empty means this is most likely an empty `.gpg-id`
+ // file we created. Skip the validation so we can make the user add a
+ // real ID.
+ if (line.isEmpty()) return@run
+ // Apparently `gpg-id` being the first line is also acceptable?
+ if (line == "gpg-id") return@run
+ if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) {
+ snackbar(message = resources.getString(R.string.short_gpg_id))
+ } else {
+ snackbar(message = resources.getString(R.string.invalid_gpg_id))
+ }
+ return null
+ }
+ }
+ .filterIsInstance<PGPIdentifier>()
+ if (gpgIdentifiers.isEmpty()) {
+ error("Failed to parse identifiers from .gpg-id: ${gpgIdentifierFile.readText()}")
+ }
+ return gpgIdentifiers
+}
+
+@Suppress("ReturnCount")
+private fun File.findTillRoot(fileName: String, rootPath: File): File? {
+ val gpgFile = File(this, fileName)
+ if (gpgFile.exists()) return gpgFile
+
+ if (this.absolutePath == rootPath.absolutePath) {
+ return null
+ }
+ val parent = parentFile
+ return if (parent != null && parent.exists()) {
+ parent.findTillRoot(fileName, rootPath)
+ } else {
+ null
+ }
+}
+```
+
+(`name` stays until Task 6: `DecryptActivity` still uses it.)
+
+- [ ] **Step 2: Autofill imports the moved helper**
+
+In `AutofillDecryptActivity.kt`, after `import app.passwordstore.ui.crypto.PasswordDialog` add:
+
+```kotlin
+import app.passwordstore.ui.crypto.getPGPIdentifiers
+```
+
+- [ ] **Step 3: Build, lint, commit**
+
+Run: `./gradlew spotlessApply && CI=1 ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug`
+Expected: `BUILD SUCCESSFUL`. If the compiler reports an unresolved `getPGPIdentifiers`,
+`copyTextToClipboard` or `copyPasswordToClipboard` in a file outside `ui/crypto/`, add the
+matching `import app.passwordstore.ui.crypto.<name>` there (the grep below should show none).
+
+```bash
+grep -rnE 'copyTextToClipboard|copyPasswordToClipboard|getPGPIdentifiers' app/src/main --include=*.kt | grep -v '/ui/crypto/'
+```
+Expected: only the `AutofillDecryptActivity.kt` import and call.
+
+```bash
+git add app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt \
+ app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt
+git commit -m "refactor(app): make the PGP clipboard and .gpg-id helpers extensions
+
+The Compose entry view lives in MainActivity, which is not a
+BasePGPActivity. The helpers move unchanged to FragmentActivity
+extensions in the same file, so the existing subclasses keep calling
+them as before.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: Entry ViewModel
+
+Ports `DecryptActivity`'s unlock flow, minus the parts that need the activity (biometric prompt,
+`.gpg-id` snackbars, launchers, clipboard), which the destination does in Task 5.
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt`
+
+- [ ] **Step 1: The ViewModel**
+
+`app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.entry
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.content.edit
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.Application.Companion.screenWasOff
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.data.crypto.CryptoRepository
+import app.passwordstore.data.crypto.PGPPassphraseCache
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.features.Feature.EnablePGPPassphraseCache
+import app.passwordstore.util.features.Features
+import app.passwordstore.util.settings.Constants
+import app.passwordstore.util.settings.PreferenceKeys
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.io.ByteArrayOutputStream
+import java.io.File
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import logcat.LogPriority.ERROR
+import logcat.asLog
+import logcat.logcat
+
+/** The dialog the entry destination shows, if any. */
+enum class EntryPrompt {
+ None,
+ NoKeys,
+ Passphrase,
+ WrongPassphrase,
+}
+
+/**
+ * Decrypts one entry as the old decrypt screen did: passphrase cache, passphrase prompt with
+ * retries, then the auto-close timer. The decrypted [entry] lives only in memory: never in saved
+ * state or a route argument. The timer drops it, and so does popping the destination (its back
+ * stack entry clears this ViewModel). Holds no Context; calls take one and use the application
+ * context.
+ */
+@HiltViewModel
+class EntryViewModel
+@Inject
+constructor(
+ private val repository: CryptoRepository,
+ private val entryFactory: PasswordEntry.Factory,
+ private val passphraseCache: PGPPassphraseCache,
+ private val features: Features,
+ @SettingsPreferences private val settings: SharedPreferences,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel() {
+
+ var entry by mutableStateOf<PasswordEntry?>(null)
+ private set
+
+ var prompt by mutableStateOf(EntryPrompt.None)
+ private set
+
+ /** Set when the destination must close: timer, cancelled prompt, too many wrong passphrases. */
+ var closed by mutableStateOf(false)
+ private set
+
+ /** Initial state of the passphrase dialog's "clear on screen off" box. */
+ var clearCache by mutableStateOf(true)
+ private set
+
+ val cacheEnabled: Boolean
+ get() = features.isEnabled(EnablePGPPassphraseCache)
+
+ private var started = false
+ private var copyOnDecrypt = false
+ private var retries = 0
+ private var auth: BiometricResult = BiometricResult.CanceledByUser
+ private var ids: List<PGPIdentifier> = emptyList()
+ private var file: File? = null
+
+ /** True the first time only: a recreated activity must not run the unlock flow again. */
+ fun start(): Boolean {
+ if (started) return false
+ started = true
+ return true
+ }
+
+ /** False, with the "no keys" prompt up, while no PGP key is imported. */
+ suspend fun checkKeys(): Boolean {
+ val hasKeys = repository.hasKeys()
+ if (!hasKeys) prompt = EntryPrompt.NoKeys
+ return hasKeys
+ }
+
+ /** Continues after the biometric prompt (or without one) with its [auth] result. */
+ fun unlock(file: File, ids: List<PGPIdentifier>, auth: BiometricResult, context: Context) {
+ this.file = file
+ this.ids = ids
+ this.auth = auth
+ val app = context.applicationContext
+ viewModelScope.launch { next(isError = false, app) }
+ }
+
+ fun submit(passphrase: String, clearCache: Boolean, context: Context) {
+ prompt = EntryPrompt.None
+ this.clearCache = clearCache
+ val app = context.applicationContext
+ viewModelScope.launch {
+ if (decrypt(passphrase, app) && auth is BiometricResult.Success) {
+ runCatching {
+ passphraseCache.cachePassphrase(app, ids.first(), passphrase)
+ settings.edit { putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, clearCache) }
+ }
+ .onFailure { e -> logcat { e.asLog() } }
+ }
+ }
+ }
+
+ fun dismissPrompt() {
+ prompt = EntryPrompt.None
+ }
+
+ fun cancel() {
+ prompt = EntryPrompt.None
+ closed = true
+ }
+
+ /** True once after a decryption when "Automatically copy password" is on. */
+ fun takeCopyOnDecrypt(): Boolean = copyOnDecrypt.also { copyOnDecrypt = false }
+
+ override fun onCleared() {
+ entry = null
+ }
+
+ private suspend fun next(isError: Boolean, context: Context) {
+ when (auth) {
+ // Handled inside the biometric prompt.
+ BiometricResult.Retry -> Unit
+ is BiometricResult.Success -> {
+ // Clear the cache on first use after app start or after the screen was off; this also
+ // purges a stale cache after caching was disabled in the PGP settings.
+ clearCache = settings.getBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, true)
+ if (screenWasOff && clearCache) {
+ passphraseCache.clearAllCachedPassphrases(context)
+ screenWasOff = false
+ }
+ // A cached passphrase that just failed is not tried again (the old screen looped on it).
+ val cached =
+ if (isError) null else passphraseCache.retrieveCachedPassphrase(context, ids.first())
+ if (cached != null) decrypt(cached, context) else ask(isError, context)
+ }
+ else -> ask(isError, context)
+ }
+ }
+
+ private suspend fun ask(isError: Boolean, context: Context) {
+ if (retries >= MAX_RETRIES) {
+ closed = true
+ return
+ }
+ retries++
+ if (withContext(dispatcherProvider.io()) { repository.isPasswordProtected(ids) }) {
+ prompt = if (isError) EntryPrompt.WrongPassphrase else EntryPrompt.Passphrase
+ } else {
+ decrypt(passphrase = "", context)
+ }
+ }
+
+ /** Decrypts off the main thread; a failure goes back to [next] with an error. */
+ private suspend fun decrypt(passphrase: String, context: Context): Boolean {
+ val source = file ?: return false
+ val bytes = withContext(dispatcherProvider.io()) { source.readBytes() }
+ val out = ByteArrayOutputStream()
+ val result = repository.decrypt(passphrase, ids, bytes.inputStream(), out)
+ if (!result.isOk) {
+ logcat(ERROR) { result.error.stackTraceToString() }
+ next(isError = true, context)
+ return false
+ }
+ copyOnDecrypt = settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)
+ entry = withContext(dispatcherProvider.io()) { entryFactory.create(out.toByteArray()) }
+ startTimer()
+ return true
+ }
+
+ /** Drops the entry and closes after the "Password copy timeout" setting (0 = never). */
+ private fun startTimer() {
+ val timeout =
+ settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull()
+ ?: Constants.DEFAULT_DECRYPTION_TIMEOUT
+ if (timeout == 0) return
+ viewModelScope.launch {
+ delay(timeout.seconds)
+ entry = null
+ closed = true
+ }
+ }
+
+ private companion object {
+ const val MAX_RETRIES = 3
+ }
+}
+```
+
+- [ ] **Step 2: Build, lint, commit**
+
+Run: `./gradlew spotlessApply && CI=1 ./gradlew :app:assembleDebug :app:lintDebug`
+Expected: `BUILD SUCCESSFUL` (the ViewModel is not used yet; Hilt still generates its factory).
+If `when (auth)` reports `Retry` as needing `is`, write `is BiometricResult.Retry -> Unit`.
+
+```bash
+git add app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt
+git commit -m "feat(app): add the entry ViewModel
+
+Ports the decrypt screen's unlock flow: passphrase cache after a
+biometric success, passphrase prompt with three tries, decryption off
+the main thread and the auto-close timer. The decrypted entry is held in
+memory only and dropped by the timer or when the destination is popped.
+A cached passphrase that fails is no longer retried in a loop.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: Entry screen (stateless UI), field row options and strings
+
+**Files:**
+- Modify (replace whole file): `ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt`
+- Create: `app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt`
+- Modify: `app/src/main/res/values/strings.xml`
+- Modify: `app/src/main/res/values-it/strings.xml`
+
+- [ ] **Step 1: FieldRow: initial reveal state and a monospace password**
+
+`FieldRow` has no callers yet. Replace
+`ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt` with:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.compose
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import app.passwordstore.ui.compose.preview.ThemePreviews
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+
+/** Content descriptions for [FieldRow]'s buttons, supplied by the app's translated strings. */
+public data class FieldRowLabels(val copy: String, val show: String, val hide: String)
+
+/** Masked values (passwords) use a monospace face, so 0 and O, l and 1 stay apart. */
+private val Monospace = FontFamily(Font(R.font.jetbrainsmono_nl_regular))
+
+/**
+ * A labelled value with an optional copy button. When [masked] is true the value is hidden behind
+ * dots until the reveal toggle is pressed, unless [initiallyRevealed] shows it from the start.
+ */
+@Composable
+public fun FieldRow(
+ label: String,
+ value: String,
+ labels: FieldRowLabels,
+ modifier: Modifier = Modifier,
+ masked: Boolean = false,
+ initiallyRevealed: Boolean = false,
+ onCopy: (() -> Unit)? = null,
+) {
+ var revealed by rememberSaveable { mutableStateOf(initiallyRevealed) }
+ Row(
+ modifier
+ .fillMaxWidth()
+ .padding(
+ start = SpacingLarge,
+ end = SpacingMedium,
+ top = SpacingMedium,
+ bottom = SpacingMedium,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ label,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ val style = MaterialTheme.typography.bodyLarge
+ Text(
+ if (masked && !revealed) "•".repeat(10) else value,
+ style = if (masked) style.copy(fontFamily = Monospace) else style,
+ )
+ }
+ if (masked)
+ IconButton(onClick = { revealed = !revealed }) {
+ Icon(
+ painterResource(
+ if (revealed) R.drawable.baseline_visibility_off_24
+ else R.drawable.baseline_visibility_24
+ ),
+ contentDescription = if (revealed) labels.hide else labels.show,
+ )
+ }
+ if (onCopy != null)
+ IconButton(onClick = onCopy) {
+ Icon(painterResource(R.drawable.ic_content_copy), contentDescription = labels.copy)
+ }
+ }
+}
+
+@ThemePreviews
+@Composable
+private fun FieldRowPreview() {
+ val labels = FieldRowLabels("Copy", "Show", "Hide")
+ APSTheme {
+ GroupedSurface {
+ FieldRow("Password", "hunter2", labels, masked = true, onCopy = {})
+ FieldRow("Username", "you", labels, onCopy = {})
+ }
+ }
+}
+```
+
+(The JetBrains Mono font was used only by the old `FieldItemAdapter` and an autofill layout;
+`FieldRow` keeps it used once the adapter is deleted in Task 6.)
+
+- [ ] **Step 2: Strings**
+
+In `app/src/main/res/values/strings.xml`, before `</resources>`, add:
+
+```xml
+
+ <!-- Compose entry view -->
+ <string name="entry_back">Back</string>
+ <string name="entry_show_password">Show password</string>
+ <string name="entry_hide_password">Hide password</string>
+ <string name="entry_copy_name">Copy name</string>
+```
+
+In `app/src/main/res/values-it/strings.xml`, before `</resources>`, add:
+
+```xml
+
+ <!-- Compose entry view -->
+ <string name="entry_back">Indietro</string>
+ <string name="entry_show_password">Mostra password</string>
+ <string name="entry_hide_password">Nascondi password</string>
+ <string name="entry_copy_name">Copia nome</string>
+```
+
+Everything else the screen shows already exists in both locales: `password`, `username`,
+`otp_label_format`, `copy_label`, `copy_password`, `edit_password`, `share_as_plaintext`,
+`list_more_options`, `list_store_root`.
+
+- [ ] **Step 3: The screen**
+
+`app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.entry
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import app.passwordstore.R
+import app.passwordstore.ui.compose.FieldRow
+import app.passwordstore.ui.compose.FieldRowLabels
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.ui.compose.LetterAvatar
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+
+/**
+ * One entry: header (avatar, name, [folder] relative to the store root, "" at the root), then the
+ * password, username and one-time code, then the extra fields. [fields] is null until decrypted;
+ * [otp] is null when the entry has no TOTP. [onCopySecret] (password, one-time code) is separate
+ * from [onCopy] because only those copies start the clipboard clear timer.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EntryScreen(
+ name: String,
+ folder: String,
+ fields: EntryFields?,
+ otp: String?,
+ otpSecondsLeft: Long,
+ otpFractionLeft: Float,
+ showPassword: Boolean,
+ onBack: () -> Unit,
+ onEdit: () -> Unit,
+ onShare: () -> Unit,
+ onCopyName: () -> Unit,
+ onCopySecret: (String) -> Unit,
+ onCopy: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var menu by remember { mutableStateOf(false) }
+ val labels =
+ FieldRowLabels(
+ copy = stringResource(R.string.copy_label),
+ show = stringResource(R.string.entry_show_password),
+ hide = stringResource(R.string.entry_hide_password),
+ )
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {},
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ actions = {
+ if (fields != null)
+ IconButton(onClick = onEdit) {
+ Icon(
+ painterResource(R.drawable.ic_edit_24dp),
+ contentDescription = stringResource(R.string.edit_password),
+ )
+ }
+ Box {
+ IconButton(onClick = { menu = true }) {
+ Icon(Icons.Filled.MoreVert, stringResource(R.string.list_more_options))
+ }
+ DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.entry_copy_name)) },
+ onClick = {
+ menu = false
+ onCopyName()
+ },
+ )
+ if (fields?.password != null)
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.share_as_plaintext)) },
+ onClick = {
+ menu = false
+ onShare()
+ },
+ leadingIcon = {
+ Icon(painterResource(R.drawable.ic_share_24dp), contentDescription = null)
+ },
+ )
+ }
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = SpacingLarge, vertical = SpacingMedium),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ LetterAvatar(name)
+ Column(Modifier.weight(1f).padding(start = SpacingLarge)) {
+ Text(name, style = MaterialTheme.typography.titleLarge)
+ Text(
+ if (folder.isEmpty()) stringResource(R.string.list_store_root) else folder,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ if (fields != null) {
+ if (fields.password != null || fields.username != null || otp != null)
+ GroupedSurface(Modifier.fillMaxWidth()) {
+ if (fields.password != null)
+ FieldRow(
+ label = stringResource(R.string.password),
+ value = fields.password,
+ labels = labels.copy(copy = stringResource(R.string.copy_password)),
+ masked = true,
+ initiallyRevealed = showPassword,
+ onCopy = { onCopySecret(fields.password) },
+ )
+ if (fields.username != null)
+ FieldRow(
+ label = stringResource(R.string.username),
+ value = fields.username,
+ labels = labels,
+ onCopy = { onCopy(fields.username) },
+ )
+ if (otp != null) {
+ FieldRow(
+ label = stringResource(R.string.otp_label_format, otpSecondsLeft),
+ value = otp,
+ labels = labels,
+ onCopy = { onCopySecret(otp) },
+ )
+ LinearProgressIndicator(
+ progress = { otpFractionLeft },
+ modifier =
+ Modifier.fillMaxWidth()
+ .padding(start = SpacingLarge, end = SpacingLarge, bottom = SpacingMedium),
+ )
+ }
+ }
+ if (fields.extras.isNotEmpty())
+ GroupedSurface(Modifier.fillMaxWidth()) {
+ fields.extras.forEach { (label, value) ->
+ key(label) { FieldRow(label, value, labels, onCopy = { onCopy(value) }) }
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Build, lint, commit**
+
+Run: `./gradlew spotlessApply && CI=1 ./gradlew :app:assembleDebug :app:lintDebug :ui:compose:lintDebug`
+Expected: `BUILD SUCCESSFUL`. If `Icons.AutoMirrored.Filled.ArrowBack` does not resolve, use
+`painterResource(R.drawable.ic_arrow_back_black_24dp)` with the same content description. If a
+compose-lint rule fires on `EntryScreen`'s parameters, follow its message (the usual fix is
+parameter order: required, then `modifier`, then optional).
+
+```bash
+git add ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt \
+ app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt \
+ app/src/main/res/values/strings.xml app/src/main/res/values-it/strings.xml
+git commit -m "feat(app): add the Compose entry screen
+
+Header with avatar, name and folder; password, username and one-time
+code with a countdown bar in the first group; extra fields in the second.
+FieldRow can start revealed (the \"Show the password\" setting) and shows
+masked values in JetBrains Mono, as the old screen did.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: Entry destination and navigation
+
+After this task the list and the shortcuts open entries in Compose. `DecryptActivity` still
+compiles but nothing starts it (deleted in Task 6).
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt`
+- Modify (replace whole file): `app/src/main/java/app/passwordstore/ui/main/MainActivity.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/list/ListDestination.kt`
+
+- [ ] **Step 1: The destination**
+
+`app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.entry
+
+import android.app.Activity
+import android.content.Intent
+import android.view.WindowManager
+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.Row
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.window.SecureFlagPolicy
+import androidx.hilt.navigation.compose.hiltViewModel
+import app.passwordstore.R
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.data.passfile.Totp
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.crypto.BasePGPActivity
+import app.passwordstore.ui.crypto.BasePGPActivity.Companion.getParentPath
+import app.passwordstore.ui.crypto.PasswordCreationActivity
+import app.passwordstore.ui.crypto.copyPasswordToClipboard
+import app.passwordstore.ui.crypto.copyTextToClipboard
+import app.passwordstore.ui.crypto.getPGPIdentifiers
+import app.passwordstore.ui.main.LaunchActivity
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.pgp.PGPKeyImportActivity
+import app.passwordstore.util.auth.BiometricAuthenticator
+import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.settings.PreferenceKeys
+import java.io.File
+
+/**
+ * One entry: wires [EntryScreen] to the [viewModel], the biometric and passphrase prompts, the
+ * clipboard and the legacy editor. [onClose] leaves the destination; [onSaved] runs after the
+ * editor saved the entry (the host returns to the list).
+ */
+@Composable
+fun EntryDestination(
+ activity: MainActivity,
+ file: File,
+ onClose: () -> Unit,
+ onSaved: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: EntryViewModel = hiltViewModel(),
+) {
+ val root = PasswordRepository.getRepositoryDirectory()
+
+ // Secrets stay out of screenshots and recents while this destination is composed.
+ // ponytail: plain add/clear, fine while no other secret-showing destination can overlap this
+ // one in a transition; count in MainActivity once the editor (step 4) becomes a destination.
+ DisposableEffect(Unit) {
+ activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ onDispose { activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }
+ }
+
+ // Routes come from the app's own listing or from LaunchActivity's check; check once more.
+ val inRepo = remember(file) { LaunchActivity.isFileInRepo(file.path, root) }
+ LaunchedEffect(inRepo) { if (!inRepo) onClose() }
+ if (!inRepo) return
+
+ fun unlock() {
+ val ids =
+ activity.getPGPIdentifiers(getParentPath(file.absolutePath, root.absolutePath)) ?: return
+ if (viewModel.cacheEnabled && BiometricAuthenticator.canAuthenticate(activity)) {
+ BiometricAuthenticator.authenticate(
+ activity,
+ R.string.biometric_prompt_title_gpg_passphrase_cache,
+ ) { result ->
+ viewModel.unlock(file, ids, result, activity)
+ }
+ } else {
+ viewModel.unlock(file, ids, BiometricResult.CanceledByUser, activity)
+ }
+ }
+
+ val keyImport =
+ rememberLauncherForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK) unlock() else onClose()
+ }
+ val editor =
+ rememberLauncherForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK) onSaved()
+ }
+
+ // ponytail: a theme or locale change while the biometric prompt is up loses that prompt's
+ // callback (the flow already counts as started); back out and reopen the entry.
+ LaunchedEffect(Unit) { if (viewModel.start() && viewModel.checkKeys()) unlock() }
+ LaunchedEffect(viewModel.closed) { if (viewModel.closed) onClose() }
+
+ val entry = viewModel.entry
+ LaunchedEffect(entry) {
+ val password = entry?.password
+ if (viewModel.takeCopyOnDecrypt() && !password.isNullOrBlank()) {
+ activity.copyPasswordToClipboard(password)
+ }
+ }
+ val fields = remember(entry) { entry?.let(::entryFields) }
+ val totp by
+ produceState<Totp?>(null, entry) {
+ if (entry != null && entry.hasTotp()) entry.totp.collect { value = it }
+ }
+ val folder =
+ remember(file) {
+ file.canonicalFile.parentFile?.relativeTo(root.canonicalFile)?.path.orEmpty()
+ }
+ val showPassword = remember {
+ activity.sharedPrefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
+ }
+
+ Box(modifier) {
+ EntryScreen(
+ name = file.nameWithoutExtension,
+ folder = folder,
+ fields = fields,
+ otp = totp?.value,
+ otpSecondsLeft = totp?.remainingTime?.inWholeSeconds ?: 0L,
+ otpFractionLeft = totp?.fractionLeft() ?: 0f,
+ showPassword = showPassword,
+ onBack = onClose,
+ onEdit = { entry?.let { editor.launch(editIntent(activity, file, root, it)) } },
+ onShare = { fields?.password?.let { activity.startActivity(shareIntent(activity, it)) } },
+ onCopyName = { activity.copyTextToClipboard(file.nameWithoutExtension) },
+ onCopySecret = { activity.copyPasswordToClipboard(it) },
+ onCopy = { activity.copyTextToClipboard(it) },
+ )
+
+ when (viewModel.prompt) {
+ EntryPrompt.None -> Unit
+ EntryPrompt.NoKeys ->
+ AlertDialog(
+ onDismissRequest = viewModel::cancel,
+ title = { Text(stringResource(R.string.no_keys_imported_dialog_title)) },
+ text = { Text(stringResource(R.string.no_keys_imported_dialog_message)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ viewModel.dismissPrompt()
+ keyImport.launch(Intent(activity, PGPKeyImportActivity::class.java))
+ }
+ ) {
+ Text(stringResource(R.string.button_label_import))
+ }
+ },
+ )
+ EntryPrompt.Passphrase,
+ EntryPrompt.WrongPassphrase ->
+ PassphraseDialog(
+ wrong = viewModel.prompt == EntryPrompt.WrongPassphrase,
+ cacheEnabled = viewModel.cacheEnabled,
+ clearCache = viewModel.clearCache,
+ onSubmit = { passphrase, clear -> viewModel.submit(passphrase, clear, activity) },
+ onCancel = viewModel::cancel,
+ )
+ }
+ }
+}
+
+/** Asks for the PGP passphrase, as the old `PasswordDialog` did. */
+@Composable
+private fun PassphraseDialog(
+ wrong: Boolean,
+ cacheEnabled: Boolean,
+ clearCache: Boolean,
+ onSubmit: (passphrase: String, clearCache: Boolean) -> Unit,
+ onCancel: () -> Unit,
+) {
+ // remember, never rememberSaveable: the passphrase must not reach saved state.
+ var passphrase by remember { mutableStateOf("") }
+ var clear by remember { mutableStateOf(clearCache) }
+ var error by remember { mutableStateOf(wrong) }
+ val focus = remember { FocusRequester() }
+ AlertDialog(
+ onDismissRequest = onCancel,
+ properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
+ title = { Text(stringResource(R.string.password)) },
+ text = {
+ Column {
+ OutlinedTextField(
+ value = passphrase,
+ onValueChange = {
+ passphrase = it
+ error = false
+ },
+ modifier = Modifier.focusRequester(focus),
+ label = { Text(stringResource(R.string.password)) },
+ supportingText =
+ if (error) {
+ { Text(stringResource(R.string.git_operation_wrong_password)) }
+ } else null,
+ isError = error,
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions =
+ KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = { onSubmit(passphrase, clear) }),
+ )
+ if (cacheEnabled)
+ Row(
+ Modifier.toggleable(value = clear, role = Role.Checkbox, onValueChange = { clear = it }),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(checked = clear, onCheckedChange = null)
+ Text(stringResource(R.string.clear_cached_password_on_screen_off))
+ }
+ }
+ LaunchedEffect(Unit) { focus.requestFocus() }
+ },
+ confirmButton = {
+ TextButton(onClick = { onSubmit(passphrase, clear) }) {
+ Text(stringResource(R.string.dialog_ok))
+ }
+ },
+ )
+}
+
+/** The legacy editor, filled exactly as the old decrypt screen filled it. */
+private fun editIntent(activity: Activity, file: File, root: File, entry: PasswordEntry): Intent =
+ Intent(activity, PasswordCreationActivity::class.java)
+ .setAction(Intent.ACTION_VIEW)
+ .putExtra(BasePGPActivity.EXTRA_FILE_PATH, getParentPath(file.absolutePath, root.absolutePath))
+ .putExtra(BasePGPActivity.EXTRA_REPO_PATH, root.absolutePath)
+ .putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, file.nameWithoutExtension)
+ .putExtra(PasswordCreationActivity.EXTRA_USERNAME, entry.username)
+ .putExtra(PasswordCreationActivity.EXTRA_PASSWORD, entry.password)
+ .putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, entry.extraContentWithoutUsername)
+ .putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
+
+/** Always a chooser, so the user can still cancel. */
+private fun shareIntent(activity: Activity, password: String): Intent =
+ Intent.createChooser(
+ Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_TEXT, password).setType("text/plain"),
+ activity.getText(R.string.send_plaintext_password_to),
+ )
+```
+
+(`root.absolutePath` and `file.absolutePath` as `REPO_PATH`/`FILE_PATH`, not canonical paths:
+the editor hashes `"$repoPath/..."` for the recently-used history, and those keys were written
+with absolute paths. The header folder uses canonical paths only for display.)
+
+- [ ] **Step 2: The list hands entries to the host**
+
+In `ListDestination.kt`:
+
+Replace the KDoc sentence
+` * legacy activities and the store operations. [onOpenFolder] takes a path relative to the root.`
+with
+` * legacy activities and the store operations. [onOpenFolder] takes a path relative to the root,`
+` * [onOpenEntry] an entry file.`
+
+In the parameter list, replace
+
+```kotlin
+ onOpenFolder: (relativePath: String) -> Unit,
+ modifier: Modifier = Modifier,
+```
+
+with
+
+```kotlin
+ onOpenFolder: (relativePath: String) -> Unit,
+ onOpenEntry: (File) -> Unit,
+ modifier: Modifier = Modifier,
+```
+
+and in the `ListScreen(...)` call replace
+
+```kotlin
+ onOpenEntry = { entry ->
+ activity.recordUse(File(entry))
+ activity.openEntry(File(entry))
+ },
+```
+
+with
+
+```kotlin
+ onOpenEntry = { entry ->
+ activity.recordUse(File(entry))
+ onOpenEntry(File(entry))
+ },
+```
+
+- [ ] **Step 3: MainActivity**
+
+Replace `app/src/main/java/app/passwordstore/ui/main/MainActivity.kt` with:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.main
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.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.PasswordCreationActivity
+import app.passwordstore.ui.entry.EntryDestination
+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, by absolute path. Paths from [LaunchActivity] passed [LaunchActivity.isFileInRepo];
+ * the entry destination checks again before decrypting.
+ */
+@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<ListRoute> { entry ->
+ val path = entry.toRoute<ListRoute>().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)) }
+ }
+ },
+ onOpenEntry = { file ->
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ addShortcut(file)
+ nav.navigate(EntryRoute(file.absolutePath))
+ }
+ },
+ )
+ }
+ composable<EntryRoute> { entry ->
+ EntryDestination(
+ activity = this@MainActivity,
+ file = File(entry.toRoute<EntryRoute>().filePath),
+ onClose = {
+ // Only the entry on top closes: the auto-close timer can race a back press.
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ if (nav.previousBackStackEntry == null) finish() else nav.popBackStack()
+ }
+ },
+ onSaved = {
+ // Back to the list, which re-reads the store on resume. Opened from a shortcut,
+ // the entry has no list below it: the list root replaces it.
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ if (nav.previousBackStackEntry != null) nav.popBackStack()
+ else nav.navigate(ListRoute()) { popUpTo<EntryRoute> { inclusive = true } }
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ /** 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<GitOp> =
+ 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) }
+ }
+
+ /** Adds [file] to the launcher's dynamic shortcuts, as opening it from the list always did. */
+ fun addShortcut(file: File) {
+ val item = entryItem(file)
+ shortcutHandler.addDynamicShortcut(item, item.createAuthEnabledIntent(this))
+ }
+
+ 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())
+}
+```
+
+(`LegacyScreen` is gone: `EntryRoute` was its last caller, and its comment said the last port
+deletes it. Later steps start not-yet-ported screens with plain intents from their callers, as
+the list already does.)
+
+- [ ] **Step 4: Build and smoke check**
+
+Run: `./gradlew spotlessApply && CI=1 ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug`
+Expected: `BUILD SUCCESSFUL`. If `popUpTo<EntryRoute>` does not resolve, use
+`popUpTo(entry.destination.id) { inclusive = true }` (same effect: the current entry is the
+top one). If lint flags the early `return` in `EntryDestination`, wrap everything after it in
+`if (inRepo) { ... }` instead.
+
+With the emulator from Task 7 running and the store cloned (`export ANDROID_SERIAL=emulator-5554`
+first; a phone may be connected), `./gradlew :app:installDebug`, open the app, tap `example.org`,
+enter `test`: the entry view shows `hunter2` (`adb exec-out uiautomator dump /dev/tty | grep -o
+'text="hunter2"'`). Back returns to the list. Full checks are in Task 7.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt \
+ app/src/main/java/app/passwordstore/ui/main/MainActivity.kt \
+ app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
+git commit -m "feat(app): show the Compose entry view in MainActivity
+
+The list navigates to EntryRoute and shortcuts start on it, replacing the
+legacy decrypt screen. FLAG_SECURE is on while an entry is composed and
+off on the list; the route is checked with isFileInRepo again before
+decrypting. Saving in the editor returns to the list. Password and one-time
+code copies start the clipboard clear timer.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: Remove DecryptActivity and what only it used
+
+**Files:**
+- Delete: `app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt`
+- Delete: `app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt`
+- Delete: `app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt`
+- Delete: `app/src/main/java/app/passwordstore/data/password/FieldItem.kt`
+- Delete: `app/src/main/res/layout/decrypt_layout.xml`, `item_field.xml`
+- Delete: `app/src/main/res/menu/pgp_handler.xml`
+- Delete: `app/src/main/res/drawable/divider.xml`
+- Modify: `app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt`
+- Modify: `app/src/main/AndroidManifest.xml`
+- Modify: `app/lint-baseline.xml`
+- Modify: `AGENTS.md`
+- Modify: `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`
+
+Kept on purpose: `PasswordDialog` and `dialog_password_entry.xml` (autofill), `EditPasswordScreen.kt`,
+`PasswordField.kt`, `APSAppBar.kt`, ui:compose `PasswordItem.kt` (later steps).
+
+- [ ] **Step 1: Drop `name` from BasePGPActivity**
+
+In `BasePGPActivity.kt`, delete:
+
+```kotlin
+ /**
+ * Name of the password file
+ *
+ * Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org
+ */
+ val name: String by unsafeLazy { File(fullPath).nameWithoutExtension }
+
+```
+
+(`File` stays imported: `getPGPIdentifiers` uses it.)
+
+- [ ] **Step 2: Manifest**
+
+In `app/src/main/AndroidManifest.xml`, delete:
+
+```xml
+ <activity
+ android:name=".ui.crypto.DecryptActivity"
+ android:exported="false" />
+
+```
+
+- [ ] **Step 3: Delete the files**
+
+```bash
+git rm app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt \
+ app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt \
+ app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt \
+ app/src/main/java/app/passwordstore/data/password/FieldItem.kt \
+ app/src/main/res/layout/decrypt_layout.xml \
+ app/src/main/res/layout/item_field.xml \
+ app/src/main/res/menu/pgp_handler.xml \
+ app/src/main/res/drawable/divider.xml
+```
+
+- [ ] **Step 4: Lint baseline**
+
+`app/lint-baseline.xml` has two `DenyListedApi` issues located in `FieldItemAdapter.kt`. Remove
+both `<issue>` blocks:
+
+```bash
+perl -0pi -e 's/\n <issue\n id="DenyListedApi"\n(?:(?!<\/issue>).)*?FieldItemAdapter\.kt(?:(?!<\/issue>).)*?<\/issue>\n//gs' app/lint-baseline.xml
+grep -c FieldItemAdapter app/lint-baseline.xml
+```
+Expected: `0`, and `git diff app/lint-baseline.xml` shows exactly two removed `<issue>` blocks.
+
+- [ ] **Step 5: Nothing refers to the removed code**
+
+```bash
+grep -rnwE 'DecryptActivity|DecryptLayoutBinding|ItemFieldBinding|FieldItemAdapter|FieldItem|ViewPasswordScreen|openEntry' app/src
+grep -rnE 'R\.menu\.pgp_handler\b|@drawable/divider|R\.drawable\.divider' app/src
+```
+Expected: no output from either (`AutofillDecryptActivity` does not match `-w DecryptActivity`).
+
+- [ ] **Step 6: AGENTS.md and the spec**
+
+In `AGENTS.md`, "Verified facts and gotchas", replace
+
+```
+- `LaunchActivity` is exported and forwards `FILE_PATH` to `DecryptActivity` (not exported)
+ only after `isFileInRepo` checks it canonically resolves inside the store.
+```
+
+with
+
+```
+- `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.
+```
+
+In the spec, section "Entry view", replace
+
+```
+- Second group: remaining extra fields, copy where it makes sense.
+```
+
+with
+
+```
+- Second group: remaining extra fields, copy where it makes sense.
+- Overflow: copy name, share as plaintext. The password starts masked or shown per the existing
+ "Show the password" setting. Saving in the editor returns to the list. Password and one-time
+ code copies are cleared from the clipboard after the timeout.
+```
+
+- [ ] **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`, since a string left
+only in a translation is an `ExtraTranslation` error) and re-run. If it reports an issue in a
+file from Tasks 1 to 5, fix it there and include it in this commit.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add -A app/src/main app/lint-baseline.xml AGENTS.md \
+ docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md
+git commit -m "refactor(app): remove the legacy decrypt screen
+
+The Compose entry view replaces DecryptActivity, its layouts, menu and
+field adapter, and upstream's unused ViewPasswordScreen. BasePGPActivity
+keeps what the editor and autofill still need.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### 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. The PIN `1234` below is the emulator's own test
+PIN, never the owner's.
+
+- [ ] **Step 1: Server, emulator, unlock, 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
+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 xyz.danix.hardenedpass.debug || true
+./gradlew :app:installDebug
+scripts/e2e-env.sh push-keys
+OUT=$(mktemp -d)
+```
+
+(`ANDROID_HOME` is the `sdk.dir` from `local.properties`. `push-keys` only after the unlock.
+`installDebug` and `adb` both honor `ANDROID_SERIAL`. The uninstall also avoids a signature
+mismatch between checkouts, see AGENTS.md.)
+
+Helpers for this shell:
+
+```bash
+ui() { adb exec-out uiautomator dump /dev/tty; }
+center() { local b; b=$(ui | grep -o "<node [^>]*$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; }
+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; }
+secure() { adb shell dumpsys window windows | awk '/Window #/{w=($0 ~ /ui\.main\.MainActivity/)} w && /fl=/{print (/SECURE/ ? "secure" : "not secure"); w=0}'; }
+pass() { tap 'class="android.widget.EditText"'; adb shell input text "$1"; tap 'text="OK"'; sleep 2; }
+STORE=/data/user/0/xyz.danix.hardenedpass.debug/files/store
+```
+
+- [ ] **Step 2: Onboarding and clone**
+
+`open`, then go through onboarding as in step 2's plan: "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`; `seen 'text="example.org"'` prints `ok`;
+`secure` prints `not secure` (the list is not `FLAG_SECURE`).
+
+- [ ] **Step 3: Open from the list, passphrase prompt, fields, FLAG_SECURE**
+
+```bash
+tap 'text="example.org"'; top # MainActivity, no DecryptActivity
+seen 'class="android.widget.EditText"' # passphrase dialog
+pass wrong; seen 'text="Wrong password"'
+pass test
+seen 'text="hunter2"'; seen 'text="alice"'; seen 'text="url"'; seen 'text="https://example.org"'
+seen 'text="Password store"'; seen 'content-desc="Edit password"'
+secure
+adb exec-out screencap -p > "$OUT/entry.png"
+```
+Expected: every `seen` prints `ok`; `top` shows `MainActivity`; `secure` prints `secure`;
+`$OUT/entry.png` is black (open it to check).
+
+- [ ] **Step 4: Reveal toggle and copies**
+
+```bash
+tap 'content-desc="Hide password"'; seen 'text="••••••••••"'
+tap 'content-desc="Show password"'; seen 'text="hunter2"'
+tap 'content-desc="Copy password"'
+adb shell dumpsys activity services xyz.danix.hardenedpass.debug | grep -c ClipboardService
+tap 'content-desc="Copy"' # first plain copy: the username row
+```
+Expected: `ok` for both `seen`; the `grep -c` prints at least `1` (the clear timer started).
+The username copy starts no timer (maintainer decision 1).
+
+- [ ] **Step 5: Back clears the view; pasted username; reopen asks again**
+
+```bash
+adb shell input keyevent 4; sleep 1; secure; seen 'text="otp"'
+adb exec-out screencap -p > "$OUT/list.png"
+tap 'text="Search"'; adb shell input keyevent 279; seen 'text="alice"'
+adb shell input keyevent 4; adb shell input keyevent 4
+tap 'text="example.org"'; seen 'class="android.widget.EditText"'
+pass test; seen 'text="hunter2"'
+```
+Expected: `not secure` after back, `$OUT/list.png` shows the list (not black); the paste puts
+`alice` in the search field; reopening shows the passphrase dialog again (the decrypted entry
+went with the popped destination).
+
+- [ ] **Step 6: Rotation keeps the view**
+
+```bash
+adb shell settings put system accelerometer_rotation 0; adb shell settings put system user_rotation 1
+sleep 2; seen 'text="hunter2"'; seen 'class="android.widget.EditText"'; secure
+adb shell settings put system user_rotation 0; sleep 2
+```
+Expected: `ok: text="hunter2"`, `MISSING: class="android.widget.EditText"` (no new prompt),
+`secure`.
+
+- [ ] **Step 7: Edit through the legacy editor, back to the list**
+
+```bash
+tap 'content-desc="Edit password"'; top # PasswordCreationActivity
+tap 'resource-id="xyz.danix.hardenedpass.debug:id/extra_content"'
+adb shell input keyevent KEYCODE_MOVE_END; adb shell input keyevent 66
+adb shell input text 'note:%sedited'
+tap 'content-desc="Save"' || tap 'text="Save"'
+sleep 3; top; seen 'text="otp"'; seen 'class="android.widget.EditText"'; secure
+tap 'text="example.org"'; pass test; seen 'text="note"'; seen 'text="edited"'
+adb shell input keyevent 4; sleep 1; seen 'text="otp"'
+```
+Expected: the editor opens with the entry filled in; after Save, `MainActivity` is on top showing
+the list (`ok: text="otp"`), with no passphrase dialog (`MISSING: ...EditText`) and `not secure`.
+Reopening the entry shows the new `note` field; back returns to the list.
+
+- [ ] **Step 8: OTP entry (by eye) and its clipboard clear**
+
+```bash
+tap 'text="otp"'; tap 'text="example.com"'; 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))'
+secure
+```
+The OTP screen never goes idle, so `uiautomator dump` fails on it (AGENTS.md) and `screencap`
+is black (`FLAG_SECURE`). The emulator console screenshot (`adb emu screenrecord screenshot`)
+grabs the host-side display; open the PNG it wrote in `$OUT`. Expected: password `s3cret`, a row
+"OTP (expires in Ns)" whose code matches the Python output (same 30 s window), a progress bar
+under it shrinking with N; `secure` prints `secure`. If the console screenshot is black too or
+unsupported, start the emulator with a window (needs the host's `libbsd`, AGENTS.md) and check
+by eye, or leave this item to the maintainer.
+
+Copy the code and check that it is cleared after the timeout (unset, so 60 s). Read the OTP
+row's copy icon position from the screenshot (its pixels are the device's) and tap it:
+
+```bash
+adb shell input tap X Y # X Y: center of the OTP row's copy icon
+COPIED=$(date +%s)
+adb shell dumpsys activity services xyz.danix.hardenedpass.debug | grep -c ClipboardService
+adb shell input keyevent 4; adb shell input keyevent 4; sleep 1
+tap 'text="Search"'; adb shell input keyevent 279; ui | grep -oE 'text="[0-9]{6}"'
+adb shell input keyevent 4; adb shell input keyevent 4
+sleep $(( 65 - ($(date +%s) - COPIED) ))
+adb shell dumpsys activity services xyz.danix.hardenedpass.debug | grep -c ClipboardService
+tap 'text="Search"'; adb shell input keyevent 279; ui | grep -oE 'text="[0-9]{6}"'
+adb shell input keyevent 4; adb shell input keyevent 4
+```
+Expected: the first `grep -c` prints at least `1` (the clear timer started for the code, as for
+the password); the first paste puts the six-digit code in the search field; after the timeout
+the second `grep -c` prints `0` and the second paste prints nothing (the clipboard was cleared).
+The list is back at the root.
+
+- [ ] **Step 9: Shortcut start, back leaves the app; path outside the store**
+
+```bash
+adb shell am force-stop xyz.danix.hardenedpass.debug
+adb shell am start -n xyz.danix.hardenedpass.debug/app.passwordstore.ui.main.LaunchActivity \
+ -a DECRYPT_PASS --es FILE_PATH "$STORE/work/mail.example.org.gpg"; sleep 3
+pass test; seen 'text="correct horse battery staple"'; seen 'text="bob"'; seen 'text="work"'; secure
+adb shell input keyevent 4; sleep 1; adb shell dumpsys window | grep mCurrentFocus
+adb shell am force-stop xyz.danix.hardenedpass.debug
+adb shell am start -n xyz.danix.hardenedpass.debug/app.passwordstore.ui.main.LaunchActivity \
+ -a DECRYPT_PASS --es FILE_PATH "$STORE/../shared_prefs/xyz.danix.hardenedpass.debug_preferences.xml"
+sleep 3; seen 'text="otp"'; secure
+adb shell am force-stop xyz.danix.hardenedpass.debug
+adb shell am start -n xyz.danix.hardenedpass.debug/app.passwordstore.ui.main.LaunchActivity \
+ -a DECRYPT_PASS --es FILE_PATH "$STORE/work/mail.example.org.gpg"; sleep 3
+pass test; tap 'content-desc="Edit password"'
+tap 'resource-id="xyz.danix.hardenedpass.debug:id/extra_content"'
+adb shell input keyevent KEYCODE_MOVE_END; adb shell input keyevent 66
+adb shell input text 'note:%sshortcut'
+tap 'content-desc="Save"' || tap 'text="Save"'
+sleep 3; top; seen 'text="otp"'; seen 'content-desc="Password store"'; secure
+adb shell input keyevent 4; sleep 1; adb shell dumpsys window | grep mCurrentFocus
+open
+```
+Expected: the shortcut opens the entry directly (`ok` for all, `secure`); back leaves the app
+(`mCurrentFocus` outside `xyz.danix.hardenedpass.debug`); the outside path opens the list
+(`ok: text="otp"`, `not secure`). Saving an entry opened from a shortcut lands on the list root
+(`MainActivity`, `ok` for both, `not secure`) instead of closing the app; back from there leaves
+the app.
+
+- [ ] **Step 10: Biometric unlock and the passphrase cache**
+
+Fingerprint (once per AVD): `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 to find the buttons) and run
+`adb emu finger touch 1` until enrollment completes; then `open`.
+
+```bash
+tap 'content-desc="More options"'; tap 'text="Settings"'; tap 'text="PGP settings"'
+tap 'text="Enable passphrase caching"'; sleep 1; adb emu finger touch 1; sleep 1
+adb shell input keyevent 4; adb shell input keyevent 4; sleep 1
+tap 'text="example.org"'; seen 'text="Unlock passphrase cache"'
+adb emu finger touch 1; sleep 1
+seen 'text="Clear cached password on screen-off"'
+tap 'text="Clear cached password on screen-off"' # uncheck: keep the cache across screen off
+pass test; seen 'text="hunter2"'
+adb shell input keyevent 4; sleep 1
+tap 'text="example.org"'; adb emu finger touch 1; sleep 2
+seen 'text="hunter2"'; seen 'class="android.widget.EditText"'
+adb shell input keyevent 4; sleep 1
+tap 'text="example.org"'; adb shell input keyevent 4; sleep 1
+seen 'class="android.widget.EditText"'; adb shell input keyevent 4; sleep 1; seen 'text="otp"'
+```
+Expected, in order: the cache switch turns on after the fingerprint; opening the entry shows the
+"Unlock passphrase cache" prompt; after the touch, the passphrase dialog shows the cache box; the
+entry decrypts. Reopening with a touch decrypts without a dialog (`ok: hunter2`, `MISSING:
+EditText`). Cancelling the biometric prompt with back falls back to the passphrase dialog;
+cancelling that closes the entry (`ok: text="otp"`).
+
+- [ ] **Step 11: Auto-close timer**
+
+```bash
+tap 'content-desc="More options"'; tap 'text="Settings"'; tap 'text="Passwords"'
+tap 'text="Password copy timeout"'; adb shell input keyevent KEYCODE_MOVE_END
+for i in 1 2 3 4; do adb shell input keyevent 67; done; adb shell input text 10; tap 'text="OK"'
+adb shell input keyevent 4; adb shell input keyevent 4; sleep 1
+tap 'text="example.org"'; adb emu finger touch 1; sleep 2; seen 'text="hunter2"'
+sleep 12; seen 'text="otp"'; secure
+```
+Expected: the entry opens, then about 10 s later the list is back (`ok: text="otp"`) and the
+window is `not secure`.
+
+- [ ] **Step 12: Tear down and push**
+
+```bash
+adb shell settings put system user_rotation 0; adb shell settings put system accelerometer_rotation 1
+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
+rm -rf "$OUT"
+git push
+```
+
+Expected: push succeeds to every `origin` push URL.
+
+---
+
+## Self-review notes
+
+- **Spec coverage for step 3:** `EntryRoute` is a real Compose destination in `MainActivity`,
+ opened from the list by navigation and from `LaunchActivity` as the start destination (Task 5);
+ back pops to the list or leaves the app (Task 5, checked in Task 7 steps 5 and 9). Header,
+ top bar with back, edit and overflow, first group password (masked or shown, reveal, copy),
+ username (copy), OTP with countdown bar (copy), second group extras with copy (Tasks 1, 4).
+ Strings in `values/` and `values-it/` (Task 4; Italian uses existing translations for
+ everything but the four new strings). `FLAG_SECURE` only while an entry is composed, explicit on
+ the passphrase dialog; the list stays unflagged (Task 5,
+ checked in Task 7 steps 3, 5, 7, 8, 9, 11). Decrypted content only in ViewModel memory, dropped on
+ pop and by the timer; not in saved state, not in routes; passphrase only in `remember`
+ (Tasks 3, 5). Passphrase cache, biometric flow and clipboard handling reuse the existing code
+ paths unchanged (Tasks 2, 3). `isFileInRepo` re-checked in the destination (Task 5).
+ `DecryptActivity` deleted; `BasePGPActivity` reduced to what the editor and autofill need
+ (Tasks 2, 6). TDD for the pure logic: field grouping and OTP fraction (Task 1). Full local CI
+ (Task 6). Emulator check covering every listed scenario (Task 7).
+- **Parity summary:** ported: `FLAG_SECURE`, header, biometric cache unlock, no-keys import,
+ `.gpg-id` checks, cache clearing on screen off, cached passphrase, empty passphrase for
+ unprotected keys, passphrase dialog with cache box and error, three tries, caching after
+ success, decryption off main, all fields with copy, monospace password, `SHOW_PASSWORD`,
+ OTP refresh, `COPY_ON_DECRYPT`, auto-close timer, edit, share, up, dynamic shortcut. Changed:
+ name copy moves from long press to the overflow; saving in the editor returns to the list
+ (the list root when started from a shortcut); rotation and
+ theme changes keep the view; password and one-time code copies always start the clear timer.
+ Dropped: the "Copy
+ password" menu item (same as the row's button). Fixed: cached-passphrase loop, dialog after the
+ last retry, dismissed no-keys dialog leaving an empty screen.
+- **Deliberate shortcuts (`ponytail:`):** a theme or locale change during the biometric prompt
+ loses that prompt's callback; `FLAG_SECURE` is a plain add/clear until step 4 can overlap two
+ secret-showing destinations (both Task 5).
+
+## Maintainer decisions (2026-11-02)
+
+1. Clipboard: the password and the one-time code copies are cleared after the timeout, with the
+ timer and mechanism the password copy already uses (`copyPasswordToClipboard`,
+ `ClipboardService`). Username and extra fields are not cleared. Applied in Tasks 4 and 5,
+ checked in Task 7 steps 4 and 8.
+2. Password visibility: follow the "Show the password" setting, as planned.
+3. After saving in the editor: back to the list (pop the entry route; when the entry was the
+ start destination, navigate to the list root instead of finishing). No reopen, no new
+ passphrase prompt. The reopen logic (`RETURN_EXTRA_CREATED_FILE` handling, the entry to entry
+ replacement and the counted `FLAG_SECURE` it needed) is removed. Applied in Task 5, checked in
+ Task 7 steps 7 and 9.
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. <danix@danix.xyz>
+ * 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 <noreply@anthropic.com>`.
+- 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 <noreply@anthropic.com>"
+```
+
+---
+
+### 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. <danix@danix.xyz>
+ * 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<String>(), folderMoves("work/servers", "work"))
+ assertEquals(2 to emptyList<String>(), 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<String>(), 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. <danix@danix.xyz>
+ * 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<ListRow> = persistentListOf(),
+ val entries: ImmutableList<ListRow> = 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<File>, 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<Int, List<String>> {
+ 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 <noreply@anthropic.com>"
+```
+
+---
+
+### 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. <danix@danix.xyz>
+ * 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<File>): 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<File>) {
+ 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<File>, 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 <noreply@anthropic.com>"
+```
+
+---
+
+### 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 `</resources>`, add:
+
+```xml
+ <!-- Compose password list -->
+ <string name="list_new_entry">New entry</string>
+ <string name="list_store_root">Password store</string>
+ <string name="list_more_options">More options</string>
+ <string name="list_clear_search">Clear search</string>
+```
+
+In `app/src/main/res/values-it/strings.xml`, before the closing `</resources>`, add:
+
+```xml
+ <!-- Compose password list -->
+ <string name="list_new_entry">Nuova voce</string>
+ <string name="list_store_root">Archivio password</string>
+ <string name="list_more_options">Altre opzioni</string>
+ <string name="list_clear_search">Cancella ricerca</string>
+```
+
+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. <danix@danix.xyz>
+ * 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<String>,
+ refreshing: Boolean,
+ canSync: Boolean,
+ gitOps: ImmutableList<GitOp>,
+ 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<GitOp>,
+ 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<String>,
+ 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 <noreply@anthropic.com>"
+```
+
+---
+
+### 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. <danix@danix.xyz>
+ * 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<String>()) }
+ 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<Int?>(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. <danix@danix.xyz>
+ * 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<ListRoute> { entry ->
+ val path = entry.toRoute<ListRoute>().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<EntryRoute> { entry ->
+ val route = entry.toRoute<EntryRoute>()
+ 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<GitOp> =
+ 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 `</style>` of
+`AppThemeM3`, add:
+
+```xml
+
+ <!-- AppCompat activities whose whole UI is Compose -->
+ <style name="NoActionBarThemeM3" parent="AppThemeM3">
+ <item name="windowActionBar">false</item>
+ <item name="windowNoTitle">true</item>
+ </style>
+```
+
+- [ ] **Step 4: Manifest**
+
+In `app/src/main/AndroidManifest.xml`, replace
+
+```xml
+ <activity
+ android:name=".ui.main.MainActivity"
+ android:exported="false" />
+```
+
+with
+
+```xml
+ <activity
+ android:name=".ui.main.MainActivity"
+ android:exported="false"
+ android:theme="@style/NoActionBarThemeM3"
+ android:windowSoftInputMode="adjustResize" />
+```
+
+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 <noreply@anthropic.com>"
+```
+
+---
+
+### 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
+ <activity
+ android:name=".ui.passwords.PasswordStore"
+ android:configChanges="orientation|screenSize"
+ android:exported="false" />
+
+```
+
+- [ ] **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 "/<string name=\"$s\">/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 <noreply@anthropic.com>"
+```
+
+---
+
+### 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 "<node [^>]*$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.
diff --git a/docs/superpowers/plans/2026-11-03-compose-settings.md b/docs/superpowers/plans/2026-11-03-compose-settings.md
new file mode 100644
index 00000000..e481b0be
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-03-compose-settings.md
@@ -0,0 +1,3709 @@
+# Compose UI overhaul, step 6: Settings, git config, git log, proxy. 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 last legacy screens outside autofill with Compose destinations in `MainActivity`: `SettingsActivity` with its six `*Settings.kt` preference providers (ModernAndroidPreferences), `GitConfigActivity` (author name and email, abort rebase, hard reset, gc), `GitLogActivity` with `GitLogAdapter`, and `ProxySelectorActivity`. Settings rows that step 5 pointed at the setup intent now navigate within the graph, "Delete repository" no longer follows symlinks out of the store, and the "files first" and "type independent" sort orders are removed.
+
+**Architecture:** Five routes in `app.passwordstore.ui.settings`, registered by `NavGraphBuilder.settingsGraph` in `MainActivity`'s `NavHost`: `SettingsRoute` (the categories), `SettingsSectionRoute(section)` with a `@Keep` enum `SettingsSection` (General, Autofill, Passwords, Repository, Misc, Pgp), `GitConfigRoute`, `GitLogRoute` and `ProxyRoute`. Rows are four generic composables in `PreferenceRows.kt` (switch, single choice, text, action) over the existing `SharedPreferences` keys; no settings library. One `SettingsViewModel` per section entry reads every displayed value into an immutable `SettingsState`, re-read on any preference change and on resume (so "View generated public SSH key" follows a key change made on the SSH key screens); the destination intercepts the rows that need the activity (screen lock prompts, the autofill service dialog, the export folder picker, confirmations). Pure logic (proxy port parsing, HEAD status, commit log, the sort order fallback and migration, the proxy user name fix) is unit tested. Git operations of the git config screen run in `activity.lifecycleScope` with a busy flag reset in `finally` and their success kept in the ViewModel, completed by a `LaunchedEffect`; the repository deletion runs in `viewModelScope`. The legacy activities, `TextInputDialog`, `GitLogModel`, their layouts and manifest entries are deleted.
+
+**Tech Stack:** Kotlin 2.4.21, Compose BOM 2024.09.03 (Material 3 1.3.0: `ListItem`, `Switch`, `RadioButton`, `AlertDialog`, `OutlinedTextField`; foundation 1.7.3: `Modifier.toggleable`, `Modifier.selectable`, `selectableGroup`, `LazyColumn`), navigation-compose 2.8.9 (type-safe routes, `data object` and enum arguments), `hilt-navigation-compose` 1.2.0, activity-compose (`rememberLauncherForActivityResult`), androidx biometric through `BiometricAuthenticator` (unchanged), JGit 5.13 (unchanged), kotlin-result 2.0.0. No new dependencies; `modernandroidpreferences` becomes unused and is removed with the other libraries in step 8, as the spec says.
+
+Spec: `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`. Earlier plans: `2026-11-02-compose-foundation.md`, `2026-11-02-compose-list.md`, `2026-11-02-compose-entry.md`, `2026-11-02-compose-editor.md`, `2026-11-03-compose-setup.md` (same folder).
+
+## Decisions taken in this plan
+
+- **Routes and layout.** Settings keeps its two levels: a categories screen (`SettingsRoute`, the old six sub-screen rows with their icons) and one screen per category (`SettingsSectionRoute(section)`). Every category fits one phone screen, as before; one long screen would be a new layout (maintainer decision 1: keep the categories). `SettingsSection` is a `@Keep @Serializable` enum route argument, like step 5's `KeysMode`, checked in a minified release build in Task 8. The list's overflow "Settings" and the welcome screen's "Settings" navigate to `SettingsRoute`; `MainActivity.openSettings` is deleted.
+- **Rows.** `PreferenceSwitch`, `PreferenceChoice` (radio dialog), `PreferenceText` (text dialog, never used for secrets: its draft lives in `rememberSaveable`) and `PreferenceAction` in `ui/settings/PreferenceRows.kt`. Each section is one grouped surface of rows. A switch shows the stored value; for the switches behind the screen lock (biometric app lock, passphrase cache, its auto-clear) the value is written only after a successful prompt. The old library wrote first and reverted on failure, so a recreation or a process death during the prompt kept the unconfirmed value.
+- **Preference compatibility.** Every key and stored value meaning stays: theme, sort order, directory structure and password generator are strings from the same arrays; the clipboard timeout stays a string (`GENERAL_SHOW_TIME`, read with `toIntOrNull()`); an emptied text value removes its key, which every reader already treats as the default. No migration except the sort order.
+- **Sort orders.** `PasswordSortOrder.INDEPENDENT` and `FILE_FIRST` are deleted (the Compose list shows folders and entries in two separate groups, so neither did anything visible any more), with their array items and strings in every locale. `migrateRemovedSortOrders` in `Migrations.kt` turns a stored `FILE_FIRST` or `INDEPENDENT` into `FOLDER_FIRST`, the order that list shows by default; `getSortOrder` reads any unknown value (a backup from an older version, a downgrade) as `FOLDER_FIRST` instead of throwing from `valueOf`. Both tested.
+- **Delete repository** (Repository) asks first, with the old title and a new message ("Delete the local store? Entries not pushed to the remote are lost."; the old screen reused the clone's "Target directory already exists" text), then runs in `viewModelScope` under `NonCancellable`, so it never stops half way: close the repository, empty the store folder with `deleteWithoutFollowingLinks()` (never `deleteRecursively()`, which follows a `store/link -> ..` symlink into the app's files and wipes the PGP and SSH keys), recreate the empty folder, drop the dynamic shortcuts, clear `REPOSITORY_INITIALIZED`. The result is kept in `SettingsViewModel.deleted`; a `LaunchedEffect` replaces the whole stack with `WelcomeRoute` (the old screen finished and let the list's resume find no store). A partial deletion shows a snackbar first. Back, rows and switches are ignored while it runs.
+- **Clear saved HTTPS password / SSH key passphrase** never cleared anything: since the 2021 ModernAndroidPreferences rewrite its click only re-read the title. It now asks for confirmation (title of the row, new message) and removes the credential the title names (the HTTPS password if one is saved, otherwise the SSH key passphrase). Presence is checked with `contains`, so the secret is never decrypted to decide whether to show the row.
+- **"Clear remembered OpenKeystore SSH Key ID" is dropped.** OpenKeychain SSH support is gone: nothing writes `SSH_OPENKEYSTORE_KEYID` any more (`BaseGitActivity` only removes it), so the row could only appear for a stale value nobody uses. The string goes from every locale, and a migration removes the stale value once (tested; maintainer decision 3).
+- **Setup screens from Settings** navigate within the graph: "Edit Git server settings" to `GitServerRoute(clone = false)`, "Import SSH key" and "View generated public SSH key" to `SshKeyRoute`, "Generate SSH key pair" to `SshKeyGenRoute`, "Key manager" to `PgpKeysRoute()`. Their close already pops when there is an entry below. The section re-reads its state on resume, so a generated or imported key shows or hides "View generated public SSH key" at once (the step 5 result launcher is no longer needed). `SetupStart.GitServer` and `SetupStart.PgpKeys` lose their last callers and are removed; `setupIntent` stays for the git operations' "No SSH key" dialog, the entry view's key import and autofill (step 7).
+- **Autofill rows.** "Enable Autofill" reflects `AutofillManager.hasEnabledAutofillServices()`, re-read on resume; turning it off disables the service, turning it on shows the old instructions dialog with the installed browsers' support levels (now a Compose dialog, `oreo_autofill_instructions.xml` is deleted) whose OK opens the system's autofill service picker. Default username and custom domains are enabled only while the service is on, as before. The autofill activities themselves stay for step 7.
+- **Passphrase cache.** The switch is enabled while the device can authenticate, or while the cache is on (so it can always be turned off; without a screen lock it turns off without a prompt, as the old fallback did). Enabling or disabling asks the screen lock and empties the cache; disabling also resets auto-clear. Auto-clear is enabled only while the cache is on; turning it off asks the screen lock and empties the cache once, a failed prompt resets it to on. The cache is emptied in `viewModelScope` with `NonCancellable`, so leaving the screen at once cannot skip it.
+- **Biometric app lock.** Same row; re-enabled on Android 14 (maintainer decision 4): upstream's block for issue 2802 and its "Feature unavailable due to a platform bug" summary are removed, and the string goes from `values/` and `values-it/`. The maintainer checks the app lock on his Android 14 tablet; if the platform bug appears, the block returns. The dynamic shortcuts are dropped only after a successful change (the old screen dropped them on every tap). Only one screen lock prompt runs at a time: a double tap on any gated switch cannot open two.
+- **Git config screen** (`GitConfigRoute`) replaces `GitConfigActivity`: name, email (the old email check and dialog), Save (toast and close at once; the old screen showed a snackbar and closed after 500 ms, as step 5 changed for the server screen), "Utilities" with the HEAD status, "Show commit log", "Abort rebase and push new branch" (enabled while rebasing or merging, checked again before running), "Hard reset to remote branch" and "Run garbage collection job". Operations run through a new `MainActivity.gitOperation(op, branch)` in `activity.lifecycleScope`; back is ignored while one runs; a failure shows the git error dialog and the screen stays (the old screen finished after the dialog); a successful reset or gc stays on the screen with a snackbar ("Hard reset to remote branch: done", "Run garbage collection job: done"; maintainer decision 5), kept in the ViewModel until shown, so a recreation announces it once; a successful abort shows the old "Abort and Push" dialog, which closes the screen. `BreakOutOfDetached`'s "not rebasing" dialog no longer finishes the calling activity, which is now `MainActivity` (it closed the app).
+- **Hard reset asks first.** The old flow was a bare "Remote branch name" field; cancelling it closed the screen. The dialog is now titled "Hard reset to remote branch", warns that local commits and changes not on the remote branch are discarded (new string), and prefills the current branch (maintainer decision 2). Cancel only closes the dialog.
+- **Commit log** (`GitLogRoute`): newest first, message, short hash in monospace and the localized date, read off the main thread by `gitLog` (the old adapter read the whole log on the main thread on first bind). No bound on the number of commits, as before: the old code was tested with 1000 commits (`ponytail:`).
+- **Proxy screen** (`ProxyRoute`): host, port, user name, password (masked, reveal toggle), Save. The old host check (numeric address or web host) is kept and now blocks the save; the port must be 1 to 65535 (the old screen crashed on a number that does not fit an `Int` and accepted ports a socket address refuses later); an emptied port is stored as -1, meaning no proxy port (the old screen could not clear it). Save applies the proxy at once (`ProxyUtils.setDefaultProxy`), shows a toast and closes. The password is prefilled from `KeystorePreferences` into ViewModel memory only (never `rememberSaveable`), and the screen holds `FLAG_SECURE` while shown, since the reveal toggle can show it.
+- **Proxy user name fix.** `GitSettings.proxyUsername` wrote to the proxy preferences but read from the main settings, so `ProxyUtils` never saw a user name and an authenticating proxy never worked; the getter now reads the proxy preferences. Tested.
+- **Error handling** is unchanged in kind: git failures through `promptOnErrorHandler`, a failed deletion as a snackbar, invalid input as field errors or the old dialogs.
+
+## Feature parity
+
+### `SettingsActivity`
+
+| Behavior today | Decision |
+|---|---|
+| One activity, six collapsible sub-screens with icons (General, Autofill, Passwords, Repository, Misc, PGP settings) | Port: categories screen with the same icons, one route per category |
+| Back leaves a sub-screen first, then the activity; title follows the sub-screen | Port: one back stack entry per screen, titled with the category |
+| Adapter state saved across recreation | Port: navigation back stack; open dialogs in `rememberSaveable` |
+| Opened as an activity from the list and the welcome screen | Changed: in-graph `SettingsRoute` |
+
+### `GeneralSettings`
+
+| Behavior today | Decision |
+|---|---|
+| App theme: light, dark, battery saver (system default from Android 10), default from `app_theme_def` | Port (same arrays and default); the change recreates the activity through `Application`'s listener, the screen stays |
+| Password file organization (directory structure) | Port |
+| Sort order: folders first, files first, type independent, recently used | Changed: files first and type independent removed, stored values migrated to folders first |
+| Disable pull to sync, recursive filtering, search on start, show hidden | Port |
+| Biometric lock: prompt, value written then reverted on failure, shortcuts dropped on every tap, disabled on Android 14 with its summary | Changed: written only after success, one prompt at a time, shortcuts dropped after success; enabled on Android 14 (maintainer decision 4) |
+
+### `AutofillSettings`
+
+| Behavior today | Decision |
+|---|---|
+| Enable switch reflects the service, re-read on resume; off disables the service; on shows the instructions with installed browsers, OK opens the system picker | Port (Compose dialog) |
+| Default username, custom domains (hint, multi-line), enabled with the switch | Port |
+
+### `PasswordSettings`
+
+| Behavior today | Decision |
+|---|---|
+| Generator type (classic, diceware; default diceware) | Port |
+| Copy timeout as text, number keyboard, summary with the value or 45 | Port (digits only) |
+| Show the password, automatically copy | Port |
+
+### `RepositorySettings`
+
+| Behavior today | Decision |
+|---|---|
+| Rebase on pull with on/off summaries | Port |
+| Git server, git config, import SSH key: only with a git repository | Port: in-graph |
+| Proxy: only with an HTTPS remote | Port: in-graph `ProxyRoute` |
+| Generate SSH key pair; view generated public key only for a generated key, refreshed through a result launcher | Port: in-graph, refreshed on resume |
+| Clear saved HTTPS password / SSH passphrase: shown when one is saved, click did nothing | Fix: confirmation, then removed |
+| Clear OpenKeystore SSH key ID | Dropped (dead); the stale value is removed by a migration (maintainer decision 3) |
+| Delete repository: confirmation reusing the clone's "directory already exists" text, `deleteRecursively()` followed symlinks, shortcuts dropped, initialized flag cleared, activity finished | Port with the fix and its own message; never stops half way (`NonCancellable`); welcome screen replaces the stack; failure as a snackbar |
+
+### `MiscSettings`
+
+| Behavior today | Decision |
+|---|---|
+| Export passwords: folder picker with persistable grants, then `PasswordExportService` | Port (`rememberLauncherForActivityResult`) |
+| Clipboard history workaround | Port |
+| Debug logging, hidden in debug builds | Port |
+
+### `PGPSettings`
+
+| Behavior today | Decision |
+|---|---|
+| Key manager (setup intent since step 5) | Changed: in-graph `PgpKeysRoute()` |
+| ASCII armor | Port |
+| Passphrase cache: prompt to enable or disable, cache cleared, auto-clear reset on disable; written first, reverted on failure | Port; written only after success; switch stays usable to turn it off without a screen lock |
+| Auto-clear: disabling asks the screen lock and clears once; failure resets to on | Port |
+
+### `GitConfigActivity`
+
+| Behavior today | Decision |
+|---|---|
+| Name (focused when empty), email, Save with the email check; snackbar, close after 500 ms | Port; toast and close at once; no initial focus |
+| HEAD status: on branch, detached at hash, missing | Port (`headStatus`, tested) |
+| Show commit log | Port: `GitLogRoute` |
+| Abort rebase: enabled only while rebasing or merging; success dialog closes the screen; failure dialog closes the screen | Port; failure keeps the screen; the "not rebasing" path no longer finishes the activity |
+| Hard reset: branch name dialog, no warning; cancel closes the screen; success closes it | Changed: warning, prefilled branch, cancel only closes the dialog (maintainer decision 2); success stays with a snackbar (maintainer decision 5) |
+| Run gc; success closes, failure dialog closes | Changed: success stays with a snackbar (maintainer decision 5); failure keeps the screen |
+| Rotation recreated the activity and cancelled a running operation | Changed: no recreation (`configChanges`); a theme change still cancels (`ponytail:`) |
+
+### `GitLogActivity` and `GitLogAdapter`
+
+| Behavior today | Decision |
+|---|---|
+| RecyclerView with dividers: message, 8-character hash, medium localized date | Port: `LazyColumn` of list items |
+| Whole log read on the main thread (`GitLogModel`) | Changed: `gitLog` on the IO dispatcher, progress bar meanwhile |
+
+### `ProxySelectorActivity`
+
+| Behavior today | Decision |
+|---|---|
+| Host, user, port, password (toggle) prefilled | Port; the user name now actually reaches `ProxyUtils` |
+| Invalid host shown as a field error, save still allowed | Changed: save blocked |
+| Port: `toInt()` crash on overflow, could not be cleared | Fix: 1 to 65535, empty clears |
+| Save, close after 500 ms | Changed: toast, close at once |
+| No `FLAG_SECURE` | Changed: held while shown |
+
+### Callers
+
+| Behavior today | Decision |
+|---|---|
+| List overflow and welcome screen start `SettingsActivity` | Changed: navigate to `SettingsRoute` |
+| `SetupStart.GitServer`, `SetupStart.PgpKeys` for Settings | Removed with their callers |
+
+### Deleted in this step (Task 7)
+
+`ui/settings/SettingsActivity.kt`, `SettingsProvider.kt`, `GeneralSettings.kt`, `AutofillSettings.kt`, `PasswordSettings.kt`, `RepositorySettings.kt`, `MiscSettings.kt`, `PGPSettings.kt`, `ui/git/config/GitConfigActivity.kt`, `ui/git/log/GitLogActivity.kt`, `GitLogAdapter.kt`, `util/git/GitLogModel.kt`, `ui/proxy/ProxySelectorActivity.kt`, `ui/dialogs/TextInputDialog.kt` (only `GitConfigActivity` used it), `@dimen/normal_margin` (only two deleted layouts used it), `pref_biometric_auth_summary_disabled_platform`, layouts `activity_preference_recyclerview.xml`, `activity_git_config.xml`, `activity_git_log.xml`, `git_log_row_layout.xml`, `activity_proxy_selector.xml`, `dialog_text_input.xml`, `oreo_autofill_instructions.xml`, four manifest entries, `pref_title_openkeystore_clear_keyid` in every locale, and whatever lint then reports unused.
+
+**Kept for later steps:** the `modernandroidpreferences` dependency and its `map_preference_widget_switch.xml` override (step 8 removes the libraries); `CredentialFinder` with `git_credential_layout.xml`, `BaseGitActivity`'s View error dialog, `StoreActions`' View dialogs (step 8 or whenever the git prompts move); `setupIntent` for autofill and the git operations (step 7); the unused row-key constants in `PreferenceKeys` (`GIT_SERVER_INFO`, `PROXY_SETTINGS`, `GIT_CONFIG`, `SSH_KEY`, `SSH_KEYGEN`, `SSH_SEE_KEY`, `CLEAR_SAVED_PASS`, `GIT_DELETE_REPO`, `EXPORT_PASSWORDS`, `SSH_OPENKEYSTORE_CLEAR_KEY_ID`): they were keys of library rows, not stored values, and step 8's cleanup can drop them.
+
+## Lessons from steps 1 to 5, designed in
+
+- **Hosting.** Destinations take their route's values and a `MainActivity`; one ViewModel per back stack entry from `hiltViewModel()`, as a default parameter; only values and lambdas go down to screens. Only the destination on top may navigate (`nav.currentBackStackEntry?.id == entry.id`).
+- **Recreation.** Git operations need the activity and run in `activity.lifecycleScope`; `GitConfigViewModel.busy` is reset in `finally`, the success is kept in `GitConfigViewModel.done` and completed by a `LaunchedEffect`, so a theme or locale change between success and its report still shows the snackbar (once: `done` goes back to null after it) or the abort dialog. The repository deletion needs no activity and runs in `viewModelScope` under `NonCancellable`; `SettingsViewModel.deleted` drives the navigation. Open dialogs and non-secret drafts are `rememberSaveable`. A screen lock prompt cancelled by a recreation writes nothing (values are written only on success).
+- **Slack compose-lint and lint.** No composable members on activities (`settingsGraph` is a top-level extension); `modifier: Modifier = Modifier` is the first optional parameter of every public composable; collections passed to composables are `ImmutableList`; the ViewModel is a default parameter and never forwarded; no `kotlin.runCatching` (new code catches `IOException`, `GitAPIException`, `JGitInternalException`); ViewModels take `DispatcherProvider`; `SettingsViewModel` is an `AndroidViewModel` (no `Context` field: `StaticFieldLeak`); the enum route argument is `@Keep` (`MissingKeepAnnotation`). Lint runs with warnings as errors, so each task adds only the strings its code uses (`UnusedResources`).
+- **Accessibility.** Back buttons and the proxy password toggle carry content descriptions on the `IconButton`'s icon; switch rows are one `toggleable` with `Role.Switch`, choice rows `selectable` with `Role.RadioButton` inside a `selectableGroup`; category icons are decorative (`contentDescription = null`) next to their text.
+- **Security.** No secret in a route, a Bundle, `rememberSaveable` or a log: the proxy password lives in `ProxyViewModel` memory and `KeystorePreferences`; saved Git credentials are only tested with `contains`, never read; `PreferenceText` is never used for a secret. `FLAG_SECURE` while the proxy screen is shown. Destructive actions ask first and survive recreation (delete repository, clear a saved credential, hard reset). One screen lock prompt at a time. The store deletion never follows symlinks. The commit log and HEAD status are read off the main thread.
+- **Lint per task.** Each task's check includes `:app:lintDebug`; Task 7 runs the full CI set from AGENTS.md.
+
+**Conventions for every task**
+
+- New files get this header:
+ ```kotlin
+ /*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+ ```
+ Modified upstream files keep their header.
+- Strings go in `app/src/main/res/values/strings.xml` and `values-it/strings.xml` ("voce" for entry). New ones are appended under a `<!-- Compose settings -->` comment before `</resources>`, after the step 5 block.
+- Commits are GPG-signed (default config, never disable). End each message with
+ `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
+- No em dashes in docs, comments or commit messages.
+- Before each commit run `./gradlew spotlessApply`, then the task's checks.
+- Work on the `compose` branch.
+
+---
+
+### Task 1: Sort orders, stale key ID, proxy user name, settings model (TDD)
+
+**Files:**
+- Modify: `app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt`
+- Modify: `app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt`
+- Modify: `app/src/main/java/app/passwordstore/util/settings/Migrations.kt`
+- Modify: `app/src/main/java/app/passwordstore/util/settings/GitSettings.kt`
+- Modify: `app/src/main/res/values/arrays.xml`, `app/src/main/res/values*/strings.xml`
+- Create: `app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt`
+- Test: `app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt` (modify)
+- Test: `app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt`
+- Test: `app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt`
+- Test: `app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt`
+
+- [ ] **Step 1: Failing tests for the sort order and the proxy user name**
+
+Append to `MigrationsTest` (inside the class, after the last test):
+
+```kotlin
+ @Test
+ fun removedSortOrdersFallBackToFoldersFirst() {
+ val gitSettings =
+ GitSettings(sharedPrefs, encryptedSharedPreferences, proxySharedPreferences, filesDir)
+ for (removed in listOf("FILE_FIRST", "INDEPENDENT")) {
+ sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, removed) }
+ runMigrations(filesDir, sharedPrefs, gitSettings)
+ assertEquals("FOLDER_FIRST", sharedPrefs.getString(PreferenceKeys.SORT_ORDER))
+ }
+ sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, "RECENTLY_USED") }
+ runMigrations(filesDir, sharedPrefs, gitSettings)
+ assertEquals("RECENTLY_USED", sharedPrefs.getString(PreferenceKeys.SORT_ORDER))
+ }
+
+ @Test
+ fun staleOpenKeystoreKeyIdIsRemoved() {
+ sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, "0123456789ABCDEF") }
+ runMigrations(
+ filesDir,
+ sharedPrefs,
+ GitSettings(sharedPrefs, encryptedSharedPreferences, proxySharedPreferences, filesDir),
+ )
+ assertFalse(sharedPrefs.contains(PreferenceKeys.SSH_OPENKEYSTORE_KEYID))
+ }
+```
+
+`app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.util.settings
+
+import androidx.core.content.edit
+import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class PasswordSortOrderTest {
+
+ @Test
+ fun unknownAndRemovedValuesReadAsFoldersFirst() {
+ val prefs = SPMockBuilder().createSharedPreferences()
+ assertEquals(PasswordSortOrder.FOLDER_FIRST, PasswordSortOrder.getSortOrder(prefs))
+ for (value in listOf("FILE_FIRST", "INDEPENDENT", "bogus")) {
+ prefs.edit { putString(PreferenceKeys.SORT_ORDER, value) }
+ assertEquals(PasswordSortOrder.FOLDER_FIRST, PasswordSortOrder.getSortOrder(prefs))
+ }
+ prefs.edit { putString(PreferenceKeys.SORT_ORDER, "RECENTLY_USED") }
+ assertEquals(PasswordSortOrder.RECENTLY_USED, PasswordSortOrder.getSortOrder(prefs))
+ }
+}
+```
+
+`app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.util.settings
+
+import app.passwordstore.util.extensions.getString
+import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class GitSettingsTest {
+
+ @Test
+ fun proxyUsernameIsReadWhereItIsWritten() {
+ val settings = SPMockBuilder().createSharedPreferences()
+ val proxy = SPMockBuilder().createSharedPreferences()
+ val gitSettings =
+ GitSettings(settings, SPMockBuilder().createSharedPreferences(), proxy, "/nonexistent")
+ gitSettings.proxyUsername = "proxy-user"
+ assertEquals("proxy-user", gitSettings.proxyUsername)
+ assertEquals("proxy-user", proxy.getString(PreferenceKeys.PROXY_USERNAME))
+ assertNull(settings.getString(PreferenceKeys.PROXY_USERNAME))
+ }
+}
+```
+
+Run: `./gradlew :app:testDebugUnitTest --tests 'app.passwordstore.util.settings.*'`
+Expected: `removedSortOrdersFallBackToFoldersFirst` FAILS (`expected FOLDER_FIRST, actual FILE_FIRST`),
+`staleOpenKeystoreKeyIdIsRemoved` FAILS (the key is still there),
+`unknownAndRemovedValuesReadAsFoldersFirst` FAILS (`FILE_FIRST`, then `IllegalArgumentException:
+No enum constant ... bogus`), `proxyUsernameIsReadWhereItIsWritten` FAILS (`expected proxy-user,
+actual null`). The other migration tests pass.
+
+- [ ] **Step 2: Remove the two sort orders, read unknown values as folders first**
+
+Replace everything below the license header of
+`app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt` with:
+
+```kotlin
+package app.passwordstore.util.settings
+
+import android.content.Context
+import android.content.SharedPreferences
+import app.passwordstore.Application
+import app.passwordstore.data.password.PasswordItem
+import app.passwordstore.util.extensions.base64
+import app.passwordstore.util.extensions.getString
+
+enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) {
+ FOLDER_FIRST(
+ Comparator { p1: PasswordItem, p2: PasswordItem ->
+ (p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true)
+ }
+ ),
+ RECENTLY_USED(
+ Comparator { p1: PasswordItem, p2: PasswordItem ->
+ val recentHistory =
+ Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
+ val timeP1 = recentHistory.getString(p1.file.absolutePath.base64())
+ val timeP2 = recentHistory.getString(p2.file.absolutePath.base64())
+ when {
+ timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1)
+ timeP1 != null && timeP2 == null -> return@Comparator -1
+ timeP1 == null && timeP2 != null -> return@Comparator 1
+ else -> p1.name.compareTo(p2.name, ignoreCase = true)
+ }
+ }
+ );
+
+ companion object {
+
+ /**
+ * The stored sort order; anything else (the removed "files first" and "type independent", a
+ * value from another version) reads as [FOLDER_FIRST].
+ */
+ @JvmStatic
+ fun getSortOrder(settings: SharedPreferences): PasswordSortOrder {
+ val stored = settings.getString(PreferenceKeys.SORT_ORDER)
+ return entries.firstOrNull { it.name == stored } ?: FOLDER_FIRST
+ }
+ }
+}
+```
+
+In `app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt`, replace
+
+```kotlin
+ return when (typeSortOrder) {
+ PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type }
+ // In order to let INDEPENDENT not distinguish between items based on their type, we
+ // simply
+ // declare them all equal at this stage.
+ PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 }
+ PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type }
+ PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator
+ }
+```
+
+with
+
+```kotlin
+ return when (typeSortOrder) {
+ PasswordSortOrder.FOLDER_FIRST -> compareBy<PasswordItem> { it.type }
+ PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator
+ }
+```
+
+In `app/src/main/res/values/arrays.xml`, replace the two sort order arrays with:
+
+```xml
+ <string-array name="sort_order_entries">
+ <item>@string/pref_folder_first_sort_order</item>
+ <item>@string/pref_recently_used_sort_order</item>
+ </string-array>
+ <string-array name="sort_order_values">
+ <item>FOLDER_FIRST</item>
+ <item>RECENTLY_USED</item>
+ </string-array>
+```
+
+Delete the two strings from every locale (a string left in a translation only is lint's
+`ExtraTranslation` error):
+
+```bash
+sed -i '/name="pref_file_first_sort_order"/d; /name="pref_type_independent_sort_order"/d' \
+ app/src/main/res/values*/strings.xml
+grep -rn 'pref_file_first_sort_order\|pref_type_independent_sort_order\|FILE_FIRST\|INDEPENDENT' \
+ app/src/main
+```
+Expected: the `grep` prints nothing.
+
+- [ ] **Step 3: The migrations**
+
+In `app/src/main/java/app/passwordstore/util/settings/Migrations.kt`, add to `runMigrations`
+after `removeCurrentBranchValue(sharedPrefs)`:
+
+```kotlin
+ migrateRemovedSortOrders(sharedPrefs)
+ removeOpenKeystoreKeyId(sharedPrefs)
+```
+
+and append to the file:
+
+```kotlin
+/**
+ * "Files first" and "type independent" were removed with the Compose list, which shows folders and
+ * entries in separate groups: a stored one becomes folders first, the list's default.
+ */
+private fun migrateRemovedSortOrders(sharedPrefs: SharedPreferences) {
+ val stored = sharedPrefs.getString(PreferenceKeys.SORT_ORDER) ?: return
+ if (stored != "FILE_FIRST" && stored != "INDEPENDENT") return
+ logcat(TAG, INFO) { "Replacing the removed sort order $stored" }
+ sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, PasswordSortOrder.FOLDER_FIRST.name) }
+}
+
+/** OpenKeychain SSH support is gone and nothing reads the remembered key ID any more. */
+private fun removeOpenKeystoreKeyId(sharedPrefs: SharedPreferences) {
+ if (!sharedPrefs.contains(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)) return
+ sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
+}
+```
+
+- [ ] **Step 4: The proxy user name getter**
+
+In `app/src/main/java/app/passwordstore/util/settings/GitSettings.kt`, replace
+
+```kotlin
+ var proxyUsername
+ get() = settings.getString(PreferenceKeys.PROXY_USERNAME)
+```
+
+with
+
+```kotlin
+ var proxyUsername
+ get() = proxySettings.getString(PreferenceKeys.PROXY_USERNAME)
+```
+
+Run: `./gradlew :app:testDebugUnitTest --tests 'app.passwordstore.util.settings.*'`
+Expected: PASS.
+
+- [ ] **Step 5: Failing test for the settings model**
+
+`app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import app.passwordstore.util.git.GitCommit
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import org.eclipse.jgit.api.Git
+import org.eclipse.jgit.lib.PersonIdent
+import org.eclipse.jgit.revwalk.RevCommit
+import org.eclipse.jgit.util.FS
+
+class SettingsModelTest {
+
+ private val root = Files.createTempDirectory("aps-settings").toFile()
+ private val identity = PersonIdent("test", "test@example.org")
+
+ init {
+ // Ignore the developer's ~/.gitconfig (default branch, commit signing).
+ FS.DETECTED.setUserHome(root)
+ }
+
+ @AfterTest fun cleanUp() = root.deleteRecursively().let {}
+
+ private fun repo(): Git =
+ Git.init().setInitialBranch("master").setDirectory(File(root, "store")).call()
+
+ private fun Git.commitFile(name: String, message: String): RevCommit {
+ File(repository.workTree, name).writeText(message)
+ add().addFilepattern(name).call()
+ return commit()
+ .setMessage(message)
+ .setAuthor(identity)
+ .setCommitter(identity)
+ .setSign(false)
+ .call()
+ }
+
+ @Test
+ fun proxyPorts() {
+ assertEquals(-1, proxyPort(""))
+ assertEquals(-1, proxyPort(" "))
+ assertEquals(8080, proxyPort("8080"))
+ assertEquals(8080, proxyPort(" 8080 "))
+ assertEquals(1, proxyPort("1"))
+ assertEquals(65535, proxyPort("65535"))
+ assertNull(proxyPort("0"))
+ assertNull(proxyPort("65536"))
+ assertNull(proxyPort("99999999999"))
+ assertNull(proxyPort("-1"))
+ assertNull(proxyPort("80a"))
+ }
+
+ @Test
+ fun headStatusFollowsHead() {
+ repo().use { git ->
+ // A new repository: HEAD names a branch that has no commit yet.
+ assertEquals(HeadStatus.Branch("master"), headStatus(git.repository))
+ val first = git.commitFile("a.gpg", "Add a")
+ git.commitFile("b.gpg", "Add b")
+ assertEquals(HeadStatus.Branch("master"), headStatus(git.repository))
+ assertFalse(needsAbort(git.repository))
+ git.checkout().setName(first.name).call()
+ assertEquals(HeadStatus.Detached(first.name.take(8)), headStatus(git.repository))
+ }
+ }
+
+ @Test
+ fun gitLogIsNewestFirst() {
+ assertEquals(emptyList<GitCommit>(), gitLog(null))
+ repo().use { git ->
+ // No commit yet: JGit's NoHeadException is an empty log, not a crash.
+ assertEquals(emptyList<GitCommit>(), gitLog(git.repository))
+ val first = git.commitFile("a.gpg", "Add a")
+ val second = git.commitFile("b.gpg", "Add b")
+ val log = gitLog(git.repository)
+ assertEquals(listOf("Add b", "Add a"), log.map(GitCommit::shortMessage))
+ assertEquals(listOf(second.name, first.name), log.map(GitCommit::hash))
+ assertEquals("test", log[0].authorName)
+ }
+ }
+}
+```
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.settings.SettingsModelTest`
+Expected: compilation FAILS with `Unresolved reference 'proxyPort'` (and `HeadStatus`,
+`headStatus`, `needsAbort`, `gitLog`).
+
+- [ ] **Step 6: The settings model**
+
+`app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import app.passwordstore.util.extensions.hash
+import app.passwordstore.util.extensions.time
+import app.passwordstore.util.git.GitCommit
+import java.io.IOException
+import logcat.LogPriority.ERROR
+import logcat.asLog
+import logcat.logcat
+import org.eclipse.jgit.api.Git
+import org.eclipse.jgit.api.errors.GitAPIException
+import org.eclipse.jgit.api.errors.JGitInternalException
+import org.eclipse.jgit.lib.Constants
+import org.eclipse.jgit.lib.Repository
+import org.eclipse.jgit.lib.RepositoryState
+
+private const val TAG = "SettingsModel"
+
+/**
+ * The proxy port typed as [text]: -1 when empty (no port, as stored before any was set), null when
+ * it is not a port number from 1 to 65535.
+ */
+fun proxyPort(text: String): Int? {
+ val trimmed = text.trim()
+ if (trimmed.isEmpty()) return -1
+ return trimmed.toIntOrNull()?.takeIf { it in 1..65535 }
+}
+
+/** Where HEAD points, as the git config screen reports it. */
+sealed interface HeadStatus {
+ data class Branch(val name: String) : HeadStatus
+
+ data class Detached(val hash: String) : HeadStatus
+
+ data object Missing : HeadStatus
+}
+
+/** [repo]'s HEAD: a branch (also one without commits yet), a detached commit, or missing. */
+fun headStatus(repo: Repository): HeadStatus =
+ try {
+ val head = repo.findRef(Constants.HEAD)
+ when {
+ head == null -> HeadStatus.Missing
+ head.isSymbolic -> HeadStatus.Branch(Repository.shortenRefName(head.target.name))
+ else -> head.objectId?.let { HeadStatus.Detached(it.abbreviate(8).name()) } ?: HeadStatus.Missing
+ }
+ } catch (e: IOException) {
+ logcat(TAG, ERROR) { e.asLog() }
+ HeadStatus.Missing
+ }
+
+/** Whether a rebase or a merge is in progress, so "Abort rebase" has something to abort. */
+fun needsAbort(repo: Repository): Boolean =
+ repo.repositoryState.let { it.isRebasing || it == RepositoryState.MERGING }
+
+/**
+ * The commits reachable from HEAD, newest first; empty without a repository or a commit. Blocking:
+ * call it off the main thread.
+ */
+// ponytail: the whole log at once, as the old screen did (tested there with 1000 commits); page it
+// if a store's history ever makes this slow.
+fun gitLog(repo: Repository?): List<GitCommit> {
+ if (repo == null) return emptyList()
+ return try {
+ Git(repo).log().call().map {
+ GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time)
+ }
+ } catch (e: GitAPIException) {
+ logcat(TAG, ERROR) { e.asLog() }
+ emptyList()
+ } catch (e: JGitInternalException) {
+ logcat(TAG, ERROR) { e.asLog() }
+ emptyList()
+ }
+}
+```
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.settings.SettingsModelTest`
+Expected: PASS.
+
+- [ ] **Step 7: Checks**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:assembleDebug test -PslimTests :app:lintDebug
+```
+Expected: `BUILD SUCCESSFUL`. (`SettingsModel.kt` is not used by app code yet; lint does not
+report unused Kotlin functions.)
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add app/src/main/java/app/passwordstore/util/settings \
+ app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt \
+ app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt \
+ app/src/main/res app/src/test/java/app/passwordstore/util/settings \
+ app/src/test/java/app/passwordstore/ui/settings
+git commit -m "feat(app): drop two sort orders, fix the proxy user name
+
+The Compose list shows folders and entries in separate groups, so the
+files first and type independent sort orders did nothing visible. They
+are removed; a stored one migrates to folders first, and any unknown
+value reads as folders first instead of throwing. The OpenKeychain SSH
+key ID, which nothing reads any more, is removed once.
+
+GitSettings wrote the proxy user name to the proxy preferences but read
+it from the main settings, so ProxyUtils never authenticated.
+
+Adds the pure parts of the settings port: proxy port parsing, HEAD
+status and the commit log.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: Preference rows and the settings ViewModel
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt`
+- Create: `app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt`
+
+- [ ] **Step 1: The rows**
+
+`app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemColors
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.input.KeyboardType
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import kotlinx.collections.immutable.ImmutableList
+
+private const val DISABLED_ALPHA = 0.38f
+
+/** List item colors for rows inside a grouped surface: the surface shows through. */
+@Composable
+internal fun rowColors(): ListItemColors =
+ ListItemDefaults.colors(containerColor = Color.Transparent)
+
+private fun Modifier.dimmed(enabled: Boolean) = if (enabled) this else alpha(DISABLED_ALPHA)
+
+/** A row that runs [onClick]: opens a screen or a dialog. */
+@Composable
+fun PreferenceAction(
+ title: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ summary: String? = null,
+ enabled: Boolean = true,
+) {
+ ListItem(
+ headlineContent = { Text(title) },
+ modifier =
+ modifier.clickable(enabled = enabled, role = Role.Button, onClick = onClick).dimmed(enabled),
+ supportingContent =
+ if (summary != null) {
+ { Text(summary) }
+ } else null,
+ colors = rowColors(),
+ )
+}
+
+/**
+ * A switch row showing [checked]. [onCheckedChange] gets the requested value; the caller decides
+ * whether it is stored (a screen lock prompt may come first), and the row shows what was stored.
+ */
+@Composable
+fun PreferenceSwitch(
+ title: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ summary: String? = null,
+ enabled: Boolean = true,
+) {
+ ListItem(
+ headlineContent = { Text(title) },
+ modifier =
+ modifier
+ .toggleable(
+ value = checked,
+ enabled = enabled,
+ role = Role.Switch,
+ onValueChange = onCheckedChange,
+ )
+ .dimmed(enabled),
+ supportingContent =
+ if (summary != null) {
+ { Text(summary) }
+ } else null,
+ trailingContent = { Switch(checked = checked, onCheckedChange = null, enabled = enabled) },
+ colors = rowColors(),
+ )
+}
+
+/**
+ * A single choice among [values], shown by their [labels] (same order); the summary is the
+ * selected label. Tapping opens a dialog of radio buttons; choosing one closes it.
+ */
+@Composable
+fun PreferenceChoice(
+ title: String,
+ values: ImmutableList<String>,
+ labels: ImmutableList<String>,
+ selected: String,
+ onSelect: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var open by rememberSaveable { mutableStateOf(false) }
+ PreferenceAction(
+ title = title,
+ onClick = { open = true },
+ modifier = modifier,
+ summary = labels.getOrNull(values.indexOf(selected)),
+ )
+ if (open)
+ AlertDialog(
+ onDismissRequest = { open = false },
+ title = { Text(title) },
+ text = {
+ Column(Modifier.selectableGroup()) {
+ values.forEachIndexed { index, value ->
+ Row(
+ Modifier.fillMaxWidth()
+ .selectable(
+ selected = value == selected,
+ role = Role.RadioButton,
+ onClick = {
+ open = false
+ onSelect(value)
+ },
+ )
+ .padding(vertical = SpacingMedium),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected = value == selected, onClick = null)
+ Text(labels[index], Modifier.padding(start = SpacingLarge))
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { open = false }) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
+
+/**
+ * A text value; tapping opens a dialog to edit it. The draft is kept across recreation in saved
+ * state: never use this row for a secret. [KeyboardType.Number] keeps digits only.
+ */
+@Composable
+fun PreferenceText(
+ title: String,
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ summary: String? = null,
+ hint: String? = null,
+ singleLine: Boolean = true,
+ keyboardType: KeyboardType = KeyboardType.Text,
+ enabled: Boolean = true,
+) {
+ var draft by rememberSaveable { mutableStateOf<String?>(null) }
+ PreferenceAction(
+ title = title,
+ onClick = { draft = value },
+ modifier = modifier,
+ summary = summary,
+ enabled = enabled,
+ )
+ draft?.let { text ->
+ AlertDialog(
+ onDismissRequest = { draft = null },
+ title = { Text(title) },
+ text = {
+ OutlinedTextField(
+ value = text,
+ onValueChange = { new ->
+ draft = if (keyboardType == KeyboardType.Number) new.filter(Char::isDigit) else new
+ },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder =
+ if (hint != null) {
+ { Text(hint) }
+ } else null,
+ singleLine = singleLine,
+ minLines = if (singleLine) 1 else 3,
+ keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ draft = null
+ onValueChange(text)
+ }
+ ) {
+ Text(stringResource(R.string.dialog_ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { draft = null }) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+ }
+}
+```
+
+- [ ] **Step 2: The ViewModel**
+
+`app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.app.Application
+import android.content.SharedPreferences
+import android.content.pm.ShortcutManager
+import androidx.annotation.StringRes
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.content.edit
+import androidx.core.content.getSystemService
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.R
+import app.passwordstore.data.crypto.PGPPassphraseCache
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.injection.prefs.GitPreferences
+import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.ui.list.deleteWithoutFollowingLinks
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.features.Feature
+import app.passwordstore.util.git.sshj.SshKey
+import app.passwordstore.util.settings.DirectoryStructure
+import app.passwordstore.util.settings.GitSettings
+import app.passwordstore.util.settings.PasswordSortOrder
+import app.passwordstore.util.settings.PreferenceKeys
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.nio.file.Files
+import java.nio.file.LinkOption
+import javax.inject.Inject
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** What the settings sections show: stored values, and the state rows depend on. */
+data class SettingsState(
+ val theme: String,
+ val directoryStructure: String,
+ val sortOrder: String,
+ val disableSync: Boolean,
+ val filterRecursively: Boolean,
+ val searchOnStart: Boolean,
+ val showHidden: Boolean,
+ val biometric: Boolean,
+ val defaultUsername: String,
+ val customSuffixes: String,
+ val pwgenType: String,
+ val clipboardTimeout: String,
+ val showPassword: Boolean,
+ val copyOnDecrypt: Boolean,
+ val rebaseOnPull: Boolean,
+ val isGitRepo: Boolean,
+ val httpsRemote: Boolean,
+ val canShowSshKey: Boolean,
+ /** Title of the "clear saved credential" row, or null when none is saved. */
+ @StringRes val savedSecret: Int?,
+ val clearClipboardHistory: Boolean,
+ val debugLogging: Boolean,
+ val asciiArmor: Boolean,
+ val passphraseCache: Boolean,
+ val autoClearCache: Boolean,
+)
+
+/**
+ * One settings section's state over the existing preference keys. [state] is re-read on every
+ * change of the settings and on [refresh] (the destination calls it on resume, so changes made on
+ * other screens, such as a new SSH key, show at once).
+ */
+@HiltViewModel
+class SettingsViewModel
+@Inject
+constructor(
+ application: Application,
+ @SettingsPreferences private val prefs: SharedPreferences,
+ @GitPreferences private val gitPrefs: SharedPreferences,
+ private val gitSettings: GitSettings,
+ private val passphraseCache: PGPPassphraseCache,
+ private val dispatcherProvider: DispatcherProvider,
+) : AndroidViewModel(application) {
+
+ var state by mutableStateOf(read())
+ private set
+
+ /** True while "Delete repository" runs. */
+ var deleting by mutableStateOf(false)
+ private set
+
+ /** Null until "Delete repository" ran; then whether the whole store folder was deleted. */
+ var deleted by mutableStateOf<Boolean?>(null)
+ private set
+
+ // A field: SharedPreferences holds its listeners weakly.
+ private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> refresh() }
+
+ init {
+ prefs.registerOnSharedPreferenceChangeListener(listener)
+ }
+
+ override fun onCleared() {
+ prefs.unregisterOnSharedPreferenceChangeListener(listener)
+ }
+
+ fun refresh() {
+ state = read()
+ }
+
+ fun putBoolean(key: String, value: Boolean) {
+ prefs.edit { putBoolean(key, value) }
+ }
+
+ /** Stores [value] under [key]; an empty value removes the key, so readers use their default. */
+ fun putString(key: String, value: String) {
+ prefs.edit { if (value.isEmpty()) remove(key) else putString(key, value) }
+ }
+
+ /** Turns the passphrase cache on or off (after the screen lock prompt); it starts empty. */
+ fun setPassphraseCache(enabled: Boolean) {
+ prefs.edit {
+ putBoolean(Feature.EnablePGPPassphraseCache.configKey, enabled)
+ if (!enabled) remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE)
+ }
+ clearCachedPassphrases()
+ }
+
+ /** Stops clearing the cache when the screen goes off; the cache is emptied once now. */
+ fun disableAutoClear() {
+ putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, false)
+ clearCachedPassphrases()
+ }
+
+ /** Back to the default: the cache is cleared when the screen goes off. */
+ fun resetAutoClear() {
+ prefs.edit { remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) }
+ }
+
+ /** Forgets the credential the "clear" row names: the HTTPS password, else the SSH passphrase. */
+ fun clearSavedSecret() {
+ val key =
+ if (gitPrefs.contains(PreferenceKeys.HTTPS_PASSWORD)) PreferenceKeys.HTTPS_PASSWORD
+ else PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE
+ gitPrefs.edit { remove(key) }
+ refresh()
+ }
+
+ /**
+ * Empties the store folder and marks setup as not done, then sets [deleted]. In [viewModelScope]
+ * and [NonCancellable], so neither a recreation nor the ViewModel's end can stop it half way (a
+ * half-deleted store still marked as set up). Never follows a symlink: `deleteRecursively()`
+ * would, and a `store/link -> ..` link would take the PGP and SSH keys with it.
+ */
+ fun deleteRepository() {
+ if (deleting) return
+ deleting = true
+ viewModelScope.launch {
+ withContext(NonCancellable) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ val gone =
+ withContext(dispatcherProvider.io()) {
+ PasswordRepository.closeRepository()
+ val ok =
+ Files.notExists(root.toPath(), LinkOption.NOFOLLOW_LINKS) ||
+ root.deleteWithoutFollowingLinks()
+ root.mkdirs()
+ ok
+ }
+ getApplication<Application>()
+ .getSystemService<ShortcutManager>()
+ ?.removeAllDynamicShortcuts()
+ prefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
+ deleted = gone
+ }
+ }
+ }
+
+ // NonCancellable: leaving the screen right after the switch must not skip the clearing.
+ private fun clearCachedPassphrases() {
+ viewModelScope.launch(NonCancellable) {
+ passphraseCache.clearAllCachedPassphrases(getApplication())
+ }
+ }
+
+ private fun read(): SettingsState {
+ val app = getApplication<Application>()
+ return SettingsState(
+ theme = prefs.getString(PreferenceKeys.APP_THEME) ?: app.getString(R.string.app_theme_def),
+ directoryStructure =
+ prefs.getString(PreferenceKeys.DIRECTORY_STRUCTURE) ?: DirectoryStructure.DEFAULT.value,
+ sortOrder = PasswordSortOrder.getSortOrder(prefs).name,
+ disableSync = prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false),
+ filterRecursively = prefs.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true),
+ searchOnStart = prefs.getBoolean(PreferenceKeys.SEARCH_ON_START, false),
+ showHidden = prefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false),
+ biometric = prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, false),
+ defaultUsername = prefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME).orEmpty(),
+ customSuffixes =
+ prefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES).orEmpty(),
+ pwgenType = prefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: "diceware",
+ clipboardTimeout = prefs.getString(PreferenceKeys.GENERAL_SHOW_TIME).orEmpty(),
+ showPassword = prefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true),
+ copyOnDecrypt = prefs.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false),
+ rebaseOnPull = gitSettings.rebaseOnPull,
+ isGitRepo = PasswordRepository.isGitRepo(),
+ httpsRemote = gitSettings.url?.startsWith("https") == true,
+ canShowSshKey = SshKey.canShowSshPublicKey,
+ // contains, never getString: the secret is not decrypted to decide whether to show a row.
+ savedSecret =
+ when {
+ gitPrefs.contains(PreferenceKeys.HTTPS_PASSWORD) -> R.string.clear_saved_passphrase_https
+ gitPrefs.contains(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) ->
+ R.string.clear_saved_passphrase_ssh
+ else -> null
+ },
+ clearClipboardHistory = prefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false),
+ debugLogging = prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false),
+ asciiArmor = prefs.getBoolean(PreferenceKeys.ASCII_ARMOR, false),
+ passphraseCache = prefs.getBoolean(Feature.EnablePGPPassphraseCache.configKey, false),
+ autoClearCache = prefs.getBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, true),
+ )
+ }
+}
+```
+
+(`deleteWithoutFollowingLinks` is `internal` in `ui/list/StoreActions.kt`, same module. Every
+default here is the one the old rows and the readers use: `SHOW_PASSWORD` true,
+`FILTER_RECURSIVELY` true, `REBASE_ON_PULL` true through `GitSettings`, `CLEAR_PASSPHRASE_CACHE`
+true, the rest false; theme from `app_theme_def`, which is `follow_system` from Android 10.)
+
+- [ ] **Step 3: Checks**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:assembleDebug test -PslimTests :app:lintDebug
+```
+Expected: `BUILD SUCCESSFUL`. This task adds no string. If lint reports `StaticFieldLeak` or
+`MissingKeepAnnotation`, something stores a `Context` or an enum route argument lost `@Keep`:
+fix it, never baseline it.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt \
+ app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt
+git commit -m "feat(app): add Compose preference rows and the settings state
+
+Switch, single choice, text and action rows over the existing
+preference keys, and a ViewModel that reads every displayed value,
+follows preference changes, deletes the store without following
+symlinks, and clears saved credentials and the passphrase cache.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: Settings screens
+
+**Files:**
+- Modify: `app/src/main/res/values/strings.xml`, `app/src/main/res/values-it/strings.xml`
+- Create: `app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt`
+- Create: `app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt`
+
+- [ ] **Step 1: Strings**
+
+In `app/src/main/res/values/strings.xml`, before `</resources>`:
+
+```xml
+
+ <!-- Compose settings -->
+ <string name="store_delete_failed">Could not delete everything in the store folder.</string>
+ <string name="clear_saved_secret_message">It will be asked again at the next Git operation.</string>
+ <string name="store_delete_confirm">Delete the local store? Entries not pushed to the remote are lost.</string>
+```
+
+In `app/src/main/res/values-it/strings.xml`, before `</resources>`:
+
+```xml
+
+ <!-- Compose settings -->
+ <string name="store_delete_failed">Impossibile eliminare tutto il contenuto della cartella dell\'archivio.</string>
+ <string name="clear_saved_secret_message">Verrà chiesto di nuovo alla prossima operazione Git.</string>
+ <string name="store_delete_confirm">Eliminare l\'archivio locale? Le voci di cui non è stato fatto il push sul remote vanno perse.</string>
+```
+
+Everything else the sections show reuses the old rows' strings, which exist in both languages.
+The delete confirmation gets its own message: the old one (`dialog_delete_msg`) was written for
+the clone into an occupied folder ("Target directory already exists...").
+
+- [ ] **Step 2: The screens**
+
+`app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.Keep
+import androidx.annotation.StringRes
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringArrayResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.input.KeyboardType
+import app.passwordstore.BuildConfig
+import app.passwordstore.R
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.util.features.Feature
+import app.passwordstore.util.settings.PreferenceKeys
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.serialization.Serializable
+
+/** The settings categories, one screen each. A route argument: kept from minification. */
+@Keep
+@Serializable
+enum class SettingsSection(@StringRes val title: Int, @DrawableRes val icon: Int) {
+ General(R.string.pref_category_general_title, R.drawable.app_settings_alt_24px),
+ Autofill(R.string.pref_category_autofill_title, R.drawable.ic_wysiwyg_24px),
+ Passwords(R.string.pref_category_passwords_title, R.drawable.ic_password_24px),
+ Repository(R.string.pref_category_repository_title, R.drawable.ic_call_merge_24px),
+ Misc(R.string.pref_category_misc_title, R.drawable.ic_miscellaneous_services_24px),
+ Pgp(R.string.pref_category_pgp_title, R.drawable.ic_lock_open_24px),
+}
+
+/** What a row asks for beyond storing a value: another screen, a dialog or a picker. */
+enum class SettingsAction {
+ GitServer,
+ Proxy,
+ GitConfig,
+ SshKey,
+ SshKeyGen,
+ KeyManager,
+ ClearSecret,
+ DeleteRepository,
+ Export,
+}
+
+/** Top bar with [title] and back, then [content] in one grouped surface, scrolling. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SettingsScaffold(
+ title: String,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(title) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) {
+ GroupedSurface(content = content)
+ }
+ }
+}
+
+/** The settings categories. */
+@Composable
+fun SettingsScreen(
+ onSection: (SettingsSection) -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ SettingsScaffold(stringResource(R.string.action_settings), onBack, modifier) {
+ SettingsSection.entries.forEach { section ->
+ ListItem(
+ headlineContent = { Text(stringResource(section.title)) },
+ modifier = Modifier.clickable(role = Role.Button) { onSection(section) },
+ leadingContent = { Icon(painterResource(section.icon), contentDescription = null) },
+ colors = rowColors(),
+ )
+ }
+ }
+}
+
+/**
+ * One category's rows. Switches and values go out through [onBoolean] and [onString] with their
+ * preference key (the destination decides which need a prompt first); everything else through
+ * [onAction]. [canAuthenticate] and [autofillEnabled] come from the activity.
+ */
+@Composable
+fun SettingsSectionScreen(
+ section: SettingsSection,
+ state: SettingsState,
+ canAuthenticate: Boolean,
+ autofillEnabled: Boolean,
+ deleting: Boolean,
+ onBoolean: (key: String, value: Boolean) -> Unit,
+ onString: (key: String, value: String) -> Unit,
+ onAction: (SettingsAction) -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ SettingsScaffold(stringResource(section.title), onBack, modifier) {
+ when (section) {
+ SettingsSection.General -> {
+ PreferenceChoice(
+ title = stringResource(R.string.pref_app_theme_title),
+ values = persistentListOf(*stringArrayResource(R.array.app_theme_values)),
+ labels = persistentListOf(*stringArrayResource(R.array.app_theme_options)),
+ selected = state.theme,
+ onSelect = { onString(PreferenceKeys.APP_THEME, it) },
+ )
+ PreferenceChoice(
+ title = stringResource(R.string.pref_directory_structure),
+ values = persistentListOf(*stringArrayResource(R.array.directory_structure_values)),
+ labels = persistentListOf(*stringArrayResource(R.array.directory_structure_entries)),
+ selected = state.directoryStructure,
+ onSelect = { onString(PreferenceKeys.DIRECTORY_STRUCTURE, it) },
+ )
+ PreferenceChoice(
+ title = stringResource(R.string.pref_sort_order_title),
+ values = persistentListOf(*stringArrayResource(R.array.sort_order_values)),
+ labels = persistentListOf(*stringArrayResource(R.array.sort_order_entries)),
+ selected = state.sortOrder,
+ onSelect = { onString(PreferenceKeys.SORT_ORDER, it) },
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_disable_sync_on_pull_title),
+ checked = state.disableSync,
+ onCheckedChange = { onBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, it) },
+ summary = stringResource(R.string.pref_disable_sync_on_pull_summary),
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_recursive_filter_title),
+ checked = state.filterRecursively,
+ onCheckedChange = { onBoolean(PreferenceKeys.FILTER_RECURSIVELY, it) },
+ summary = stringResource(R.string.pref_recursive_filter_summary),
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_search_on_start_title),
+ checked = state.searchOnStart,
+ onCheckedChange = { onBoolean(PreferenceKeys.SEARCH_ON_START, it) },
+ summary = stringResource(R.string.pref_search_on_start_summary),
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_show_hidden_title),
+ checked = state.showHidden,
+ onCheckedChange = { onBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, it) },
+ summary = stringResource(R.string.pref_show_hidden_summary),
+ )
+ // Enabled on Android 14 too (maintainer decision 4): upstream blocked it there for issue
+ // 2802; if that platform bug shows on the maintainer's Android 14 tablet, the block returns.
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_biometric_auth_title),
+ checked = state.biometric,
+ onCheckedChange = { onBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, it) },
+ summary =
+ stringResource(
+ if (canAuthenticate) R.string.pref_biometric_auth_summary
+ else R.string.pref_biometric_auth_summary_error
+ ),
+ )
+ }
+ SettingsSection.Autofill -> {
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_autofill_enable_title),
+ checked = autofillEnabled,
+ onCheckedChange = { onBoolean(PreferenceKeys.AUTOFILL_ENABLE, it) },
+ )
+ PreferenceText(
+ title = stringResource(R.string.preference_default_username_title),
+ value = state.defaultUsername,
+ onValueChange = { onString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME, it) },
+ summary = stringResource(R.string.preference_default_username_summary),
+ enabled = autofillEnabled,
+ )
+ PreferenceText(
+ title = stringResource(R.string.preference_custom_public_suffixes_title),
+ value = state.customSuffixes,
+ onValueChange = { onString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES, it) },
+ summary = stringResource(R.string.preference_custom_public_suffixes_summary),
+ hint = stringResource(R.string.preference_custom_public_suffixes_hint),
+ singleLine = false,
+ enabled = autofillEnabled,
+ )
+ }
+ SettingsSection.Passwords -> {
+ PreferenceChoice(
+ title = stringResource(R.string.pref_password_generator_type_title),
+ values = persistentListOf(*stringArrayResource(R.array.pwgen_provider_values)),
+ labels = persistentListOf(*stringArrayResource(R.array.pwgen_provider_labels)),
+ selected = state.pwgenType,
+ onSelect = { onString(PreferenceKeys.PREF_KEY_PWGEN_TYPE, it) },
+ )
+ PreferenceText(
+ title = stringResource(R.string.pref_clipboard_timeout_title),
+ value = state.clipboardTimeout,
+ onValueChange = { onString(PreferenceKeys.GENERAL_SHOW_TIME, it) },
+ summary =
+ stringResource(
+ R.string.pref_clipboard_timeout_summary,
+ state.clipboardTimeout.ifEmpty { "45" },
+ ),
+ keyboardType = KeyboardType.Number,
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.show_password_pref_title),
+ checked = state.showPassword,
+ onCheckedChange = { onBoolean(PreferenceKeys.SHOW_PASSWORD, it) },
+ summary = stringResource(R.string.show_password_pref_summary),
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_copy_title),
+ checked = state.copyOnDecrypt,
+ onCheckedChange = { onBoolean(PreferenceKeys.COPY_ON_DECRYPT, it) },
+ summary = stringResource(R.string.pref_copy_summary),
+ )
+ }
+ SettingsSection.Repository -> {
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_rebase_on_pull_title),
+ checked = state.rebaseOnPull,
+ onCheckedChange = { onBoolean(PreferenceKeys.REBASE_ON_PULL, it) },
+ summary =
+ stringResource(
+ if (state.rebaseOnPull) R.string.pref_rebase_on_pull_summary_on
+ else R.string.pref_rebase_on_pull_summary
+ ),
+ )
+ if (state.isGitRepo) {
+ PreferenceAction(
+ title = stringResource(R.string.pref_edit_git_server_settings),
+ onClick = { onAction(SettingsAction.GitServer) },
+ )
+ if (state.httpsRemote)
+ PreferenceAction(
+ title = stringResource(R.string.pref_edit_proxy_settings),
+ onClick = { onAction(SettingsAction.Proxy) },
+ )
+ PreferenceAction(
+ title = stringResource(R.string.pref_edit_git_config),
+ onClick = { onAction(SettingsAction.GitConfig) },
+ )
+ PreferenceAction(
+ title = stringResource(R.string.pref_import_ssh_key_title),
+ onClick = { onAction(SettingsAction.SshKey) },
+ )
+ }
+ PreferenceAction(
+ title = stringResource(R.string.pref_ssh_keygen_title),
+ onClick = { onAction(SettingsAction.SshKeyGen) },
+ )
+ if (state.isGitRepo && state.canShowSshKey)
+ PreferenceAction(
+ title = stringResource(R.string.pref_ssh_see_key_title),
+ onClick = { onAction(SettingsAction.SshKey) },
+ )
+ state.savedSecret?.let { title ->
+ PreferenceAction(
+ title = stringResource(title),
+ onClick = { onAction(SettingsAction.ClearSecret) },
+ )
+ }
+ PreferenceAction(
+ title = stringResource(R.string.pref_git_delete_repo_title),
+ onClick = { onAction(SettingsAction.DeleteRepository) },
+ summary = stringResource(R.string.pref_git_delete_repo_summary),
+ enabled = !deleting,
+ )
+ }
+ SettingsSection.Misc -> {
+ PreferenceAction(
+ title = stringResource(R.string.prefs_export_passwords_title),
+ onClick = { onAction(SettingsAction.Export) },
+ summary = stringResource(R.string.prefs_export_passwords_summary),
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_clear_clipboard_title),
+ checked = state.clearClipboardHistory,
+ onCheckedChange = { onBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, it) },
+ summary = stringResource(R.string.pref_clear_clipboard_summary),
+ )
+ if (!BuildConfig.DEBUG)
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_debug_logging_title),
+ checked = state.debugLogging,
+ onCheckedChange = { onBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, it) },
+ summary = stringResource(R.string.pref_debug_logging_summary),
+ )
+ }
+ SettingsSection.Pgp -> {
+ PreferenceAction(
+ title = stringResource(R.string.pref_pgp_key_manager_title),
+ onClick = { onAction(SettingsAction.KeyManager) },
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_pgp_ascii_armor_title),
+ checked = state.asciiArmor,
+ onCheckedChange = { onBoolean(PreferenceKeys.ASCII_ARMOR, it) },
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_passphrase_cache_title),
+ checked = state.passphraseCache,
+ onCheckedChange = { onBoolean(Feature.EnablePGPPassphraseCache.configKey, it) },
+ summary = stringResource(R.string.pref_passphrase_cache_summary),
+ // Without a screen lock it can still be turned off.
+ enabled = canAuthenticate || state.passphraseCache,
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_passphrase_cache_auto_clear_title),
+ checked = state.autoClearCache,
+ onCheckedChange = { onBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, it) },
+ summary = stringResource(R.string.pref_passphrase_cache_auto_clear_summary),
+ enabled = state.passphraseCache,
+ )
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 3: The section destination**
+
+`app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ShortcutManager
+import android.net.Uri
+import android.provider.Settings
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+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.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.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.core.content.getSystemService
+import androidx.core.net.toUri
+import androidx.documentfile.provider.DocumentFile
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import app.passwordstore.BuildConfig
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.SetupDialog
+import app.passwordstore.util.auth.BiometricAuthenticator
+import app.passwordstore.util.auth.BiometricAuthenticator.Result
+import app.passwordstore.util.extensions.autofillManager
+import app.passwordstore.util.extensions.snackbar
+import app.passwordstore.util.features.Feature
+import app.passwordstore.util.services.PasswordExportService
+import app.passwordstore.util.settings.PreferenceKeys
+import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel
+import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel
+
+private enum class SettingsDialog {
+ Autofill,
+ ClearSecret,
+ DeleteRepository,
+}
+
+/** The export's folder picker, with the persistable write grant `PasswordExportService` needs. */
+private class ExportFolder : ActivityResultContracts.OpenDocumentTree() {
+ override fun createIntent(context: Context, input: Uri?): Intent =
+ super.createIntent(context, input).apply {
+ flags =
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
+ Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
+ Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
+ }
+}
+
+private fun MainActivity.autofillEnabled() = autofillManager?.hasEnabledAutofillServices() == true
+
+/**
+ * One settings category: wires [SettingsSectionScreen] to the [viewModel] and to what needs the
+ * activity: screen lock prompts, the autofill service, the export picker, confirmations. [onOpen]
+ * gets the actions that open another screen; [onDeleted] runs once the store was deleted.
+ */
+@Composable
+fun SettingsSectionDestination(
+ activity: MainActivity,
+ section: SettingsSection,
+ onBack: () -> Unit,
+ onOpen: (SettingsAction) -> Unit,
+ onDeleted: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: SettingsViewModel = hiltViewModel(),
+) {
+ var dialog by rememberSaveable { mutableStateOf<SettingsDialog?>(null) }
+ var canAuthenticate by remember { mutableStateOf(BiometricAuthenticator.canAuthenticate(activity)) }
+ var autofillEnabled by remember { mutableStateOf(activity.autofillEnabled()) }
+
+ fun refresh() {
+ viewModel.refresh()
+ canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
+ autofillEnabled = activity.autofillEnabled()
+ }
+
+ // Back from another screen (git server, SSH key, key generation, the system's autofill
+ // settings): rows that depend on it follow, e.g. "View generated public SSH key".
+ LifecycleResumeEffect(Unit) {
+ refresh()
+ onPauseOrDispose {}
+ }
+ // A finished deletion moves on, also when the screen was recreated in between.
+ val deleted = viewModel.deleted
+ LaunchedEffect(deleted) {
+ if (deleted == null) return@LaunchedEffect
+ if (!deleted) activity.snackbar(message = activity.getString(R.string.store_delete_failed))
+ onDeleted()
+ }
+ BackHandler(enabled = viewModel.deleting) {}
+
+ val export =
+ rememberLauncherForActivityResult(ExportFolder()) { uri ->
+ if (uri != null && DocumentFile.fromTreeUri(activity, uri) != null)
+ activity.startForegroundService(
+ Intent(activity, PasswordExportService::class.java)
+ .setAction(PasswordExportService.ACTION_EXPORT_PASSWORD)
+ .putExtra("uri", uri)
+ )
+ }
+
+ // One screen lock prompt at a time: a double tap must not open two. A recreation resets it
+ // (remember), and the old activity's prompt is gone with it.
+ var prompting by remember { mutableStateOf(false) }
+
+ fun authenticate(@StringRes title: Int, onResult: (Result) -> Unit) {
+ if (prompting) return
+ prompting = true
+ BiometricAuthenticator.authenticate(activity, title) { result ->
+ // Retry: a finger was not recognized, the prompt stays open. Errors close it and are
+ // terminal (Failure, Canceled*, HardwareUnavailableOrDisabled).
+ if (result !is Result.Retry) prompting = false
+ onResult(result)
+ }
+ }
+
+ // Values are written only once allowed: a failed or cancelled prompt leaves the stored value,
+ // which the switch shows.
+ fun setBoolean(key: String, value: Boolean) {
+ when (key) {
+ PreferenceKeys.AUTOFILL_ENABLE ->
+ if (value) dialog = SettingsDialog.Autofill
+ else {
+ activity.autofillManager?.disableAutofillServices()
+ refresh()
+ }
+ PreferenceKeys.BIOMETRIC_AUTH_2 -> {
+ fun write() {
+ viewModel.putBoolean(key, value)
+ // Shortcuts were built for the old lock setting, as the old screen knew.
+ activity.getSystemService<ShortcutManager>()?.removeAllDynamicShortcuts()
+ }
+ // Without a screen lock no prompt can open: turning the app lock off needs none.
+ if (!value && !BiometricAuthenticator.canAuthenticate(activity)) write()
+ else
+ authenticate(R.string.biometric_prompt_title) { result ->
+ if (result is Result.Success) write()
+ }
+ }
+ Feature.EnablePGPPassphraseCache.configKey ->
+ if (!BiometricAuthenticator.canAuthenticate(activity)) viewModel.setPassphraseCache(false)
+ else
+ authenticate(
+ if (value) R.string.pref_passphrase_cache_authenticate_enable
+ else R.string.pref_passphrase_cache_authenticate_disable
+ ) { result ->
+ if (result is Result.Success) viewModel.setPassphraseCache(value)
+ }
+ PreferenceKeys.CLEAR_PASSPHRASE_CACHE ->
+ when {
+ value -> viewModel.putBoolean(key, true)
+ !BiometricAuthenticator.canAuthenticate(activity) -> viewModel.resetAutoClear()
+ else ->
+ authenticate(R.string.pref_passphrase_cache_auto_clear_authenticate_disable) { result ->
+ when (result) {
+ is Result.Success -> viewModel.disableAutoClear()
+ is Result.Retry -> Unit
+ else -> viewModel.resetAutoClear()
+ }
+ }
+ }
+ else -> viewModel.putBoolean(key, value)
+ }
+ }
+
+ Box(modifier) {
+ SettingsSectionScreen(
+ section = section,
+ state = viewModel.state,
+ canAuthenticate = canAuthenticate,
+ autofillEnabled = autofillEnabled,
+ deleting = viewModel.deleting,
+ // Nothing else starts while the store is being deleted.
+ onBoolean = { key, value -> if (!viewModel.deleting) setBoolean(key, value) },
+ onString = { key, value -> if (!viewModel.deleting) viewModel.putString(key, value) },
+ onAction = { action ->
+ if (!viewModel.deleting)
+ when (action) {
+ SettingsAction.ClearSecret -> dialog = SettingsDialog.ClearSecret
+ SettingsAction.DeleteRepository -> dialog = SettingsDialog.DeleteRepository
+ SettingsAction.Export -> export.launch(null)
+ else -> onOpen(action)
+ }
+ },
+ onBack = { if (!viewModel.deleting) onBack() },
+ )
+ when (dialog) {
+ null -> Unit
+ SettingsDialog.Autofill ->
+ AutofillDialog(
+ onConfirm = {
+ dialog = null
+ activity.startActivity(
+ Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE)
+ .setData("package:${BuildConfig.APPLICATION_ID}".toUri())
+ )
+ },
+ onDismiss = { dialog = null },
+ )
+ SettingsDialog.ClearSecret ->
+ SetupDialog(
+ title = viewModel.state.savedSecret?.let { stringResource(it) },
+ text = stringResource(R.string.clear_saved_secret_message),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = {
+ dialog = null
+ viewModel.clearSavedSecret()
+ },
+ onDismiss = { dialog = null },
+ dismiss = stringResource(R.string.dialog_cancel),
+ )
+ SettingsDialog.DeleteRepository ->
+ SetupDialog(
+ title = stringResource(R.string.pref_dialog_delete_title),
+ text = stringResource(R.string.store_delete_confirm),
+ confirm = stringResource(R.string.dialog_delete),
+ onConfirm = {
+ dialog = null
+ viewModel.deleteRepository()
+ },
+ onDismiss = { dialog = null },
+ dismiss = stringResource(R.string.dialog_do_not_delete),
+ )
+ }
+ }
+}
+
+/** The old autofill instructions: what it does, how to enable it, and the installed browsers. */
+@Composable
+private fun AutofillDialog(
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ val browsers = remember { getInstalledBrowsersWithAutofillSupportLevel(context) }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ title = { Text(stringResource(R.string.pref_autofill_enable_title)) },
+ text = {
+ Column(
+ Modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ ) {
+ Text(stringResource(R.string.oreo_autofill_enable_dialog_description))
+ Text(stringResource(R.string.oreo_autofill_enable_dialog_instructions))
+ Column {
+ Text(
+ stringResource(R.string.oreo_autofill_enable_dialog_installed_browsers),
+ fontWeight = FontWeight.Bold,
+ )
+ browsers.forEach { (label, level) -> Text("$label: ${stringResource(level.label())}") }
+ }
+ }
+ },
+ confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(R.string.dialog_ok)) } },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
+
+@StringRes
+private fun BrowserAutofillSupportLevel.label(): Int =
+ when (this) {
+ BrowserAutofillSupportLevel.None -> R.string.oreo_autofill_no_support
+ BrowserAutofillSupportLevel.FlakyFill -> R.string.oreo_autofill_flaky_fill_support
+ BrowserAutofillSupportLevel.PasswordFill -> R.string.oreo_autofill_password_fill_support
+ BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility ->
+ R.string.oreo_autofill_password_fill_and_conditional_save_support
+ BrowserAutofillSupportLevel.GeneralFill -> R.string.oreo_autofill_general_fill_support
+ BrowserAutofillSupportLevel.GeneralFillAndSave ->
+ R.string.oreo_autofill_general_fill_and_save_support
+ }
+```
+
+(`SetupDialog` is `internal` in `ui/setup/WelcomeScreen.kt`, same module: the settings
+dialogs are the same plain dialog. `BiometricAuthenticator.authenticate` shows its own toast on
+failures, as before; its default title is `biometric_prompt_title`, passed explicitly by the local
+`authenticate`, which lets one prompt run at a time. A prompt cancelled by a recreation calls back into the old activity and
+writes nothing; the switch keeps showing the stored value (`ponytail:` comment above
+`setBoolean` if the reviewer asks for one). If `SettingsAction` grows, `else -> onOpen(action)`
+forwards it: keep the three local actions explicit above it.)
+
+- [ ] **Step 4: Checks**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:assembleDebug test -PslimTests :app:lintDebug
+```
+Expected: `BUILD SUCCESSFUL`. The screens are not reachable yet (Task 6 wires them); the two
+new strings are used by compiled code, so `UnusedResources` stays quiet. If compose-lint
+reports `ComposeModifierMissing` or `ComposeParameterOrder` on a new composable, fix the
+signature (modifier first optional parameter), do not suppress.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/src/main/res/values/strings.xml app/src/main/res/values-it/strings.xml \
+ app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt \
+ app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt
+git commit -m "feat(app): add the Compose settings screens
+
+The categories and one screen per category, with the old rows over the
+same keys. Screen lock gated switches store their value only after a
+successful prompt. Clearing a saved HTTPS password or SSH passphrase,
+which never removed anything, now asks and removes it.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: Proxy screen
+
+**Files:**
+- Modify: `app/src/main/res/values/strings.xml`, `app/src/main/res/values-it/strings.xml`
+- Create: `app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt`
+- Create: `app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt`
+
+- [ ] **Step 1: String**
+
+`values/strings.xml`, after `clear_saved_secret_message`:
+
+```xml
+ <string name="invalid_proxy_port">Enter a port from 1 to 65535, or nothing</string>
+```
+
+`values-it/strings.xml`, after `clear_saved_secret_message`:
+
+```xml
+ <string name="invalid_proxy_port">Inserisci una porta da 1 a 65535, oppure niente</string>
+```
+
+- [ ] **Step 2: The ViewModel**
+
+`app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.net.InetAddresses
+import android.os.Build
+import android.util.Patterns
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import app.passwordstore.util.proxy.ProxyUtils
+import app.passwordstore.util.settings.GitSettings
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+/** A numeric address or a web host name, as the old proxy screen accepted. */
+fun isProxyHost(text: String): Boolean {
+ val numeric =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) InetAddresses.isNumericAddress(text)
+ else @Suppress("DEPRECATION") Patterns.IP_ADDRESS.matcher(text).matches()
+ return numeric || Patterns.WEB_URL.matcher(text).matches()
+}
+
+/**
+ * The proxy form, prefilled from the stored proxy. The password lives here in memory only, never
+ * in saved state; it is stored in `KeystorePreferences` through [GitSettings].
+ */
+@HiltViewModel
+class ProxyViewModel
+@Inject
+constructor(private val gitSettings: GitSettings, private val proxyUtils: ProxyUtils) :
+ ViewModel() {
+
+ var host by mutableStateOf(gitSettings.proxyHost.orEmpty())
+ var port by mutableStateOf(gitSettings.proxyPort.takeIf { it != -1 }?.toString().orEmpty())
+ var username by mutableStateOf(gitSettings.proxyUsername.orEmpty())
+ var password by mutableStateOf(gitSettings.proxyPassword.orEmpty())
+
+ val hostError: Boolean
+ get() = host.isNotBlank() && !isProxyHost(host.trim())
+
+ val portError: Boolean
+ get() = proxyPort(port) == null
+
+ /** Stores the proxy and applies it at once; false, storing nothing, when a field is invalid. */
+ fun save(): Boolean {
+ if (hostError) return false
+ val portValue = proxyPort(port) ?: return false
+ gitSettings.proxyHost = host.trim().ifEmpty { null }
+ gitSettings.proxyPort = portValue
+ gitSettings.proxyUsername = username.trim().ifEmpty { null }
+ gitSettings.proxyPassword = password.ifEmpty { null }
+ proxyUtils.setDefaultProxy()
+ return true
+ }
+}
+```
+
+- [ ] **Step 3: The screen and destination**
+
+`app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.widget.Toast
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.hilt.navigation.compose.hiltViewModel
+import app.passwordstore.R
+import app.passwordstore.ui.compose.R as composeR
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.main.MainActivity
+
+/** Proxy host, port, user name and password; [hostError] and [portError] block the save. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ProxyScreen(
+ host: String,
+ port: String,
+ username: String,
+ password: String,
+ hostError: Boolean,
+ portError: Boolean,
+ onHostChange: (String) -> Unit,
+ onPortChange: (String) -> Unit,
+ onUsernameChange: (String) -> Unit,
+ onPasswordChange: (String) -> Unit,
+ onSave: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var revealed by remember { mutableStateOf(false) }
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.activity_proxy_label)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ ) {
+ val field = Modifier.fillMaxWidth()
+ OutlinedTextField(
+ value = host,
+ onValueChange = onHostChange,
+ modifier = field,
+ label = { Text(stringResource(R.string.proxy_hostname)) },
+ supportingText =
+ if (hostError) {
+ { Text(stringResource(R.string.invalid_proxy_url)) }
+ } else null,
+ isError = hostError,
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Next,
+ ),
+ )
+ OutlinedTextField(
+ value = port,
+ onValueChange = { onPortChange(it.filter(Char::isDigit)) },
+ modifier = field,
+ label = { Text(stringResource(R.string.port)) },
+ supportingText =
+ if (portError) {
+ { Text(stringResource(R.string.invalid_proxy_port)) }
+ } else null,
+ isError = portError,
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
+ )
+ OutlinedTextField(
+ value = username,
+ onValueChange = onUsernameChange,
+ modifier = field,
+ label = { Text(stringResource(R.string.username)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next),
+ )
+ OutlinedTextField(
+ value = password,
+ onValueChange = onPasswordChange,
+ modifier = field,
+ label = { Text(stringResource(R.string.password)) },
+ trailingIcon = {
+ IconButton(onClick = { revealed = !revealed }) {
+ Icon(
+ painterResource(
+ if (revealed) composeR.drawable.baseline_visibility_off_24
+ else composeR.drawable.baseline_visibility_24
+ ),
+ contentDescription =
+ stringResource(
+ if (revealed) R.string.entry_hide_password else R.string.entry_show_password
+ ),
+ )
+ }
+ },
+ visualTransformation =
+ if (revealed) VisualTransformation.None else PasswordVisualTransformation(),
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done,
+ ),
+ )
+ Button(onClick = onSave, modifier = field, enabled = !hostError && !portError) {
+ Text(stringResource(R.string.crypto_save))
+ }
+ }
+ }
+}
+
+/**
+ * The proxy screen. Holds FLAG_SECURE while shown: it shows the saved proxy password (masked, with
+ * a reveal toggle).
+ */
+@Composable
+fun ProxyDestination(
+ activity: MainActivity,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: ProxyViewModel = hiltViewModel(),
+) {
+ DisposableEffect(activity) {
+ activity.holdSecure()
+ onDispose { activity.releaseSecure() }
+ }
+ ProxyScreen(
+ host = viewModel.host,
+ port = viewModel.port,
+ username = viewModel.username,
+ password = viewModel.password,
+ hostError = viewModel.hostError,
+ portError = viewModel.portError,
+ onHostChange = { viewModel.host = it },
+ onPortChange = { viewModel.port = it },
+ onUsernameChange = { viewModel.username = it },
+ onPasswordChange = { viewModel.password = it },
+ onSave = {
+ if (viewModel.save()) {
+ Toast.makeText(activity, R.string.git_server_config_save_success, Toast.LENGTH_SHORT).show()
+ onClose()
+ }
+ },
+ onBack = onClose,
+ modifier = modifier,
+ )
+}
+```
+
+- [ ] **Step 4: Checks**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:assembleDebug test -PslimTests :app:lintDebug
+```
+Expected: `BUILD SUCCESSFUL`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/src/main/res/values/strings.xml app/src/main/res/values-it/strings.xml \
+ app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt \
+ app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt
+git commit -m "feat(app): add the Compose proxy screen
+
+Host, port, user name and password. An invalid host or port blocks the
+save, an emptied port is cleared, the proxy applies at once, and the
+screen is FLAG_SECURE while the saved password is on it.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: Git config and commit log screens
+
+**Files:**
+- Modify: `app/src/main/res/values/strings.xml`, `app/src/main/res/values-it/strings.xml`
+- Modify: `app/src/main/java/app/passwordstore/ui/main/MainActivity.kt`
+- Modify: `app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt`
+- Create: `app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt`
+- Create: `app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt`
+- Create: `app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt`
+
+- [ ] **Step 1: String**
+
+`values/strings.xml`, after `invalid_proxy_port`:
+
+```xml
+ <string name="git_reset_confirm_message">Local commits and changes that are not on this remote branch are discarded.</string>
+ <string name="git_tool_done">%1$s: done</string>
+```
+
+`values-it/strings.xml`, after `invalid_proxy_port`:
+
+```xml
+ <string name="git_reset_confirm_message">I commit e le modifiche locali che non sono su questo remote branch vengono scartati.</string>
+ <string name="git_tool_done">%1$s: completato</string>
+```
+
+- [ ] **Step 2: `MainActivity.gitOperation` and the abort dialog fix**
+
+In `app/src/main/java/app/passwordstore/ui/main/MainActivity.kt`, add the import
+
+```kotlin
+import com.github.michaelbull.result.Result
+```
+
+and after `runGit`:
+
+```kotlin
+ /** Runs [op] (a hard reset against [branch]) and returns its outcome; the caller reports it. */
+ suspend fun gitOperation(op: GitOp, branch: String = ""): Result<Unit, Throwable> {
+ remoteBranch = branch
+ return launchGitOperation(op)
+ }
+```
+
+In `app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt`, replace
+
+```kotlin
+ .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ ->
+ callingActivity.finish()
+ }
+```
+
+with
+
+```kotlin
+ // Only closes the dialog: the calling activity is the app's main screen.
+ .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok), null)
+```
+
+- [ ] **Step 3: The git config ViewModel**
+
+`app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.util.Patterns
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.git.base.BaseGitActivity.GitOp
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.settings.GitSettings
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * The git config screen's state: author name and email being edited, HEAD, and the git operation
+ * that runs or succeeded. The operation itself runs in the destination (it needs the activity);
+ * this ViewModel keeps what must survive a recreation.
+ */
+@HiltViewModel
+class GitConfigViewModel
+@Inject
+constructor(
+ private val gitSettings: GitSettings,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel() {
+
+ var name by mutableStateOf(gitSettings.authorName)
+ var email by mutableStateOf(gitSettings.authorEmail)
+
+ var head by mutableStateOf<HeadStatus?>(null)
+ private set
+
+ var canAbort by mutableStateOf(false)
+ private set
+
+ /** A git operation runs; everything waits. */
+ var busy by mutableStateOf(false)
+
+ /**
+ * The operation that succeeded and is not reported yet: a reset or gc gets a snackbar (then this
+ * goes back to null), an abort its dialog.
+ */
+ var done by mutableStateOf<GitOp?>(null)
+
+ /** The branch an abort pushed from, for its report. */
+ var abortedBranch by mutableStateOf<String?>(null)
+
+ init {
+ refresh()
+ }
+
+ /** Re-reads HEAD and the rebase state, off the main thread. */
+ fun refresh() {
+ viewModelScope.launch {
+ val repo = PasswordRepository.repository
+ val (status, abort) =
+ withContext(dispatcherProvider.io()) {
+ if (repo == null) HeadStatus.Missing to false else headStatus(repo) to needsAbort(repo)
+ }
+ head = status
+ canAbort = abort
+ }
+ }
+
+ /** Saves name and email; false, saving nothing, when the email is not an address. */
+ fun save(): Boolean {
+ val address = email.trim()
+ if (!Patterns.EMAIL_ADDRESS.matcher(address).matches()) return false
+ gitSettings.authorEmail = address
+ gitSettings.authorName = name.trim()
+ return true
+ }
+}
+```
+
+- [ ] **Step 4: The git config screen and destination**
+
+`app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.git.base.BaseGitActivity.GitOp
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.SetupDialog
+import app.passwordstore.util.extensions.snackbar
+import com.github.michaelbull.result.fold
+import com.google.android.material.snackbar.Snackbar
+import kotlinx.coroutines.launch
+
+/**
+ * Author name and email with Save, then the repository utilities: HEAD ([head], already a
+ * sentence), commit log, abort rebase ([canAbort]), hard reset, gc. [busy] disables everything.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GitConfigScreen(
+ name: String,
+ email: String,
+ head: String,
+ canAbort: Boolean,
+ busy: Boolean,
+ onNameChange: (String) -> Unit,
+ onEmailChange: (String) -> Unit,
+ onSave: () -> Unit,
+ onLog: () -> Unit,
+ onAbort: () -> Unit,
+ onReset: () -> Unit,
+ onGc: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.title_activity_git_config)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ ) {
+ val field = Modifier.fillMaxWidth()
+ OutlinedTextField(
+ value = name,
+ onValueChange = onNameChange,
+ modifier = field,
+ enabled = !busy,
+ label = { Text(stringResource(R.string.git_user_name_hint)) },
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ capitalization = KeyboardCapitalization.Words,
+ imeAction = ImeAction.Next,
+ ),
+ )
+ OutlinedTextField(
+ value = email,
+ onValueChange = onEmailChange,
+ modifier = field,
+ enabled = !busy,
+ label = { Text(stringResource(R.string.git_user_email)) },
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Email,
+ imeAction = ImeAction.Done,
+ ),
+ )
+ Button(onClick = onSave, modifier = Modifier.align(Alignment.End), enabled = !busy) {
+ Text(stringResource(R.string.crypto_save))
+ }
+ Text(stringResource(R.string.git_tools), style = MaterialTheme.typography.titleLarge)
+ Text(head)
+ if (busy) LinearProgressIndicator(field)
+ OutlinedButton(onClick = onLog, modifier = field, enabled = !busy) {
+ Text(stringResource(R.string.git_log))
+ }
+ OutlinedButton(onClick = onAbort, modifier = field, enabled = canAbort && !busy) {
+ Text(stringResource(R.string.abort_rebase))
+ }
+ OutlinedButton(onClick = onReset, modifier = field, enabled = !busy) {
+ Text(stringResource(R.string.reset_to_remote))
+ }
+ OutlinedButton(onClick = onGc, modifier = field, enabled = !busy) {
+ Text(stringResource(R.string.git_run_gc_job))
+ }
+ }
+ }
+}
+
+/** Asks before a hard reset, for the remote [branch] to reset to. */
+@Composable
+fun ResetDialog(
+ branch: String,
+ onBranchChange: (String) -> Unit,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ title = { Text(stringResource(R.string.reset_to_remote)) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(SpacingLarge)) {
+ Text(stringResource(R.string.git_reset_confirm_message))
+ OutlinedTextField(
+ value = branch,
+ onValueChange = onBranchChange,
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.git_utils_reset_remote_branch_title)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(autoCorrectEnabled = false),
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onConfirm, enabled = branch.isNotBlank()) {
+ Text(stringResource(R.string.dialog_ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
+
+private enum class GitConfigDialog {
+ InvalidEmail,
+ Reset,
+}
+
+/**
+ * The git config screen: wires [GitConfigScreen] to the [viewModel] and runs the utilities as git
+ * operations of [activity]. A successful reset or gc stays here with a short snackbar; an abort
+ * shows its report, which closes the screen; a failure shows the git error dialog and the screen
+ * stays.
+ */
+@Composable
+fun GitConfigDestination(
+ activity: MainActivity,
+ onClose: () -> Unit,
+ onLog: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: GitConfigViewModel = hiltViewModel(),
+) {
+ var dialog by rememberSaveable { mutableStateOf<GitConfigDialog?>(null) }
+ var branch by rememberSaveable { mutableStateOf("") }
+
+ LifecycleResumeEffect(Unit) {
+ viewModel.refresh()
+ onPauseOrDispose {}
+ }
+ // A running operation holds the screen: leaving would cancel it half way.
+ BackHandler(enabled = viewModel.busy) {}
+ // A finished reset or gc is announced once, also when the screen was recreated in between:
+ // the result stays in the ViewModel until the snackbar was shown.
+ val done = viewModel.done
+ LaunchedEffect(done) {
+ val label =
+ when (done) {
+ GitOp.RESET -> R.string.reset_to_remote
+ GitOp.GC -> R.string.git_run_gc_job
+ else -> return@LaunchedEffect
+ }
+ activity.snackbar(
+ message = activity.getString(R.string.git_tool_done, activity.getString(label)),
+ length = Snackbar.LENGTH_LONG,
+ )
+ viewModel.done = null
+ }
+
+ // ponytail: the activity's scope, like every git operation of the app: a theme or locale change
+ // cancels it, and the finally block frees the screen.
+ fun run(op: GitOp, remote: String = "") {
+ if (viewModel.busy) return
+ viewModel.busy = true
+ activity.lifecycleScope.launch {
+ try {
+ activity
+ .gitOperation(op, remote)
+ .fold(
+ success = {
+ if (op == GitOp.BREAK_OUT_OF_DETACHED)
+ viewModel.abortedBranch = PasswordRepository.getCurrentBranch()
+ viewModel.done = op
+ },
+ failure = { activity.promptOnErrorHandler(it) },
+ )
+ } finally {
+ viewModel.busy = false
+ viewModel.refresh()
+ }
+ }
+ }
+
+ val head =
+ when (val status = viewModel.head) {
+ is HeadStatus.Branch -> stringResource(R.string.git_head_on_branch, status.name)
+ is HeadStatus.Detached -> stringResource(R.string.git_head_detached, status.hash)
+ HeadStatus.Missing -> stringResource(R.string.git_head_missing)
+ null -> ""
+ }
+ Box(modifier) {
+ GitConfigScreen(
+ name = viewModel.name,
+ email = viewModel.email,
+ head = head,
+ canAbort = viewModel.canAbort,
+ busy = viewModel.busy,
+ onNameChange = { viewModel.name = it },
+ onEmailChange = { viewModel.email = it },
+ onSave = {
+ if (viewModel.save()) {
+ Toast.makeText(activity, R.string.git_server_config_save_success, Toast.LENGTH_SHORT)
+ .show()
+ onClose()
+ } else dialog = GitConfigDialog.InvalidEmail
+ },
+ onLog = onLog,
+ onAbort = {
+ // Checked again: BreakOutOfDetached reports success without doing anything otherwise.
+ if (PasswordRepository.repository?.let(::needsAbort) == true)
+ run(GitOp.BREAK_OUT_OF_DETACHED)
+ else viewModel.refresh()
+ },
+ onReset = {
+ branch = PasswordRepository.getCurrentBranch().orEmpty()
+ dialog = GitConfigDialog.Reset
+ },
+ onGc = { run(GitOp.GC) },
+ onBack = { if (!viewModel.busy) onClose() },
+ )
+ when (dialog) {
+ null -> Unit
+ GitConfigDialog.InvalidEmail ->
+ SetupDialog(
+ text = stringResource(R.string.invalid_email_dialog_text),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = { dialog = null },
+ onDismiss = { dialog = null },
+ )
+ GitConfigDialog.Reset ->
+ ResetDialog(
+ branch = branch,
+ onBranchChange = { branch = it },
+ onConfirm = {
+ dialog = null
+ run(GitOp.RESET, branch.trim())
+ },
+ onDismiss = { dialog = null },
+ )
+ }
+ if (done == GitOp.BREAK_OUT_OF_DETACHED) {
+ val aborted = viewModel.abortedBranch.orEmpty()
+ SetupDialog(
+ title = stringResource(R.string.git_abort_and_push_title),
+ text =
+ stringResource(R.string.git_break_out_of_detached_success, aborted, "conflicting-$aborted-..."),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = onClose,
+ onDismiss = onClose,
+ )
+ }
+ }
+}
+```
+
+- [ ] **Step 5: The commit log**
+
+`app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.git.GitCommit
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+import java.util.Locale
+import javax.inject.Inject
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** The store's commits, read once off the main thread. */
+@HiltViewModel
+class GitLogViewModel @Inject constructor(dispatcherProvider: DispatcherProvider) : ViewModel() {
+
+ /** Null while loading. */
+ var commits by mutableStateOf<ImmutableList<GitCommit>?>(null)
+ private set
+
+ init {
+ viewModelScope.launch {
+ commits =
+ withContext(dispatcherProvider.io()) {
+ gitLog(PasswordRepository.repository).toImmutableList()
+ }
+ }
+ }
+}
+
+/** The commit log, newest first: message, short hash, date. A progress bar while [commits] is null. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GitLogScreen(
+ commits: ImmutableList<GitCommit>?,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val formatter = remember {
+ DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
+ .withLocale(Locale.getDefault())
+ .withZone(ZoneId.systemDefault())
+ }
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.title_activity_git_log)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ if (commits == null) LinearProgressIndicator(Modifier.padding(padding).fillMaxWidth())
+ else
+ LazyColumn(Modifier.padding(padding).fillMaxSize()) {
+ items(commits, key = GitCommit::hash) { commit ->
+ ListItem(
+ headlineContent = { Text(commit.shortMessage) },
+ overlineContent = { Text(commit.hash.take(8), fontFamily = FontFamily.Monospace) },
+ supportingContent = { Text(formatter.format(commit.time)) },
+ )
+ HorizontalDivider()
+ }
+ }
+ }
+}
+
+@Composable
+fun GitLogDestination(
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: GitLogViewModel = hiltViewModel(),
+) {
+ GitLogScreen(commits = viewModel.commits, onBack = onClose, modifier = modifier)
+}
+```
+
+- [ ] **Step 6: Checks**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:assembleDebug test -PslimTests :app:lintDebug
+```
+Expected: `BUILD SUCCESSFUL`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add app/src/main/res/values/strings.xml app/src/main/res/values-it/strings.xml \
+ app/src/main/java/app/passwordstore/ui/main/MainActivity.kt \
+ app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt \
+ app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt \
+ app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt \
+ app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt
+git commit -m "feat(app): add the Compose git config and commit log screens
+
+Author name and email, HEAD status, commit log, abort rebase, hard
+reset and gc. The hard reset now warns and prefills the current branch;
+a finished reset or gc stays on the screen with a snackbar, and a
+failed operation keeps the screen too. The commit log is read off the
+main thread. BreakOutOfDetached's not-rebasing dialog no longer
+finishes the calling activity.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: Settings graph and its callers
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/main/MainActivity.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/list/ListDestination.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt`
+
+- [ ] **Step 1: The graph**
+
+`app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.composable
+import androidx.navigation.toRoute
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.GitServerRoute
+import app.passwordstore.ui.setup.PgpKeysRoute
+import app.passwordstore.ui.setup.SshKeyGenRoute
+import app.passwordstore.ui.setup.SshKeyRoute
+import app.passwordstore.ui.setup.WelcomeRoute
+import kotlinx.serialization.Serializable
+
+/** The settings categories. */
+@Serializable data object SettingsRoute
+
+/** One settings category. */
+@Serializable data class SettingsSectionRoute(val section: SettingsSection)
+
+/** Author name and email, and the repository utilities. */
+@Serializable data object GitConfigRoute
+
+/** The store's commit log. */
+@Serializable data object GitLogRoute
+
+/** HTTP(S) proxy settings. */
+@Serializable data object ProxyRoute
+
+/** The settings destinations, and where their rows lead. */
+fun NavGraphBuilder.settingsGraph(activity: MainActivity, nav: NavHostController) {
+ fun onTop(entry: NavBackStackEntry) = nav.currentBackStackEntry?.id == entry.id
+
+ fun close(entry: NavBackStackEntry) {
+ if (onTop(entry)) nav.popBackStack()
+ }
+
+ composable<SettingsRoute> { entry ->
+ SettingsScreen(
+ onSection = { if (onTop(entry)) nav.navigate(SettingsSectionRoute(it)) },
+ onBack = { close(entry) },
+ )
+ }
+ composable<SettingsSectionRoute> { entry ->
+ SettingsSectionDestination(
+ activity = activity,
+ section = entry.toRoute<SettingsSectionRoute>().section,
+ onBack = { close(entry) },
+ onOpen = { action ->
+ if (onTop(entry))
+ when (action) {
+ SettingsAction.GitServer -> nav.navigate(GitServerRoute(clone = false))
+ SettingsAction.Proxy -> nav.navigate(ProxyRoute)
+ SettingsAction.GitConfig -> nav.navigate(GitConfigRoute)
+ SettingsAction.SshKey -> nav.navigate(SshKeyRoute)
+ SettingsAction.SshKeyGen -> nav.navigate(SshKeyGenRoute)
+ SettingsAction.KeyManager -> nav.navigate(PgpKeysRoute())
+ // Handled by the destination itself.
+ SettingsAction.ClearSecret,
+ SettingsAction.DeleteRepository,
+ SettingsAction.Export -> Unit
+ }
+ },
+ onDeleted = {
+ // No store any more: setup starts over, as after a deletion from the list's side.
+ if (onTop(entry)) nav.navigate(WelcomeRoute) { popUpTo(nav.graph.id) { inclusive = true } }
+ },
+ )
+ }
+ composable<GitConfigRoute> { entry ->
+ GitConfigDestination(
+ activity = activity,
+ onClose = { close(entry) },
+ onLog = { if (onTop(entry)) nav.navigate(GitLogRoute) },
+ )
+ }
+ composable<GitLogRoute> { entry -> GitLogDestination(onClose = { close(entry) }) }
+ composable<ProxyRoute> { entry ->
+ ProxyDestination(activity = activity, onClose = { close(entry) })
+ }
+}
+```
+
+(`SettingsSection` as a route argument: navigation-common 2.8.9 maps `SerialKind.ENUM` to its
+built-in enum `NavType`, as step 5 verified for `KeysMode`; `@Keep` keeps the enum through R8,
+checked in Task 8.)
+
+- [ ] **Step 2: `MainActivity`**
+
+In `app/src/main/java/app/passwordstore/ui/main/MainActivity.kt`:
+
+Remove the imports
+
+```kotlin
+import app.passwordstore.ui.settings.SettingsActivity
+```
+```kotlin
+import app.passwordstore.util.extensions.launchActivity
+```
+
+and add
+
+```kotlin
+import app.passwordstore.ui.settings.SettingsRoute
+import app.passwordstore.ui.settings.settingsGraph
+```
+
+In `composable<ListRoute>`, after the `onClone` argument, add
+
+```kotlin
+ onSettings = {
+ if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(SettingsRoute)
+ },
+```
+
+After `setupGraph(this@MainActivity, nav)` add
+
+```kotlin
+ settingsGraph(this@MainActivity, nav)
+```
+
+Delete
+
+```kotlin
+ fun openSettings() {
+ launchActivity(SettingsActivity::class.java)
+ }
+
+```
+
+(`launchActivity` has no other use in `MainActivity`; check with
+`grep -n launchActivity app/src/main/java/app/passwordstore/ui/main/MainActivity.kt`, which must
+print nothing.)
+
+- [ ] **Step 3: The list and the welcome screen**
+
+In `app/src/main/java/app/passwordstore/ui/list/ListDestination.kt`, in the KDoc replace
+
+```kotlin
+ * [onClone] the git server screen for a clone; [onNoStore] runs when the store is gone.
+```
+
+with
+
+```kotlin
+ * [onClone] the git server screen for a clone, [onSettings] the settings; [onNoStore] runs when the
+ * store is gone.
+```
+
+add the parameter after `onClone: () -> Unit,`
+
+```kotlin
+ onSettings: () -> Unit,
+```
+
+and replace
+
+```kotlin
+ onSettings = activity::openSettings,
+```
+
+with
+
+```kotlin
+ onSettings = onSettings,
+```
+
+In `app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt`, add the import
+
+```kotlin
+import app.passwordstore.ui.settings.SettingsRoute
+```
+
+and replace
+
+```kotlin
+ onSettings = activity::openSettings,
+```
+
+with
+
+```kotlin
+ onSettings = { if (onTop(entry)) nav.navigate(SettingsRoute) },
+```
+
+- [ ] **Step 4: Nothing outside the legacy files opens the legacy screens**
+
+```bash
+grep -rnwE 'SettingsActivity|GitConfigActivity|GitLogActivity|ProxySelectorActivity|openSettings' \
+ app/src/main/java | grep -vE 'ui/settings/(SettingsActivity|RepositorySettings)\.kt|ui/git/(config|log)/|ui/proxy/'
+```
+Expected: no output.
+
+- [ ] **Step 5: Build**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:assembleDebug test -PslimTests :app:lintDebug
+```
+Expected: `BUILD SUCCESSFUL`.
+
+- [ ] **Step 6: Launch smoke check on the emulator**
+
+The emulator only (`ANDROID_SERIAL`); a tablet may be connected over USB and is never touched.
+Start the `aps-test` AVD as in Task 8, step 1, if it is not running (no e2e server needed).
+
+```bash
+export ANDROID_SERIAL=emulator-5554
+adb uninstall xyz.danix.hardenedpass.debug || true
+./gradlew :app:installDebug
+adb logcat -c
+adb shell am start -n xyz.danix.hardenedpass.debug/app.passwordstore.ui.main.LaunchActivity; sleep 3
+ui() { adb exec-out uiautomator dump /dev/tty; }
+tap() { local b; b=$(ui | grep -o "<node [^>]*$1[^>]*>" | head -1 | grep -o 'bounds="[^"]*"' | grep -oE '[0-9]+' | tr '\n' ' '); set -- $b; adb shell input tap $(( ($1 + $3) / 2 )) $(( ($2 + $4) / 2 )); sleep 2; }
+tap 'text="Settings"'; ui | grep -o 'text="PGP settings"'
+tap 'text="General"'; ui | grep -o 'text="Password sort order"'
+adb shell input keyevent 4; sleep 1
+tap 'text="Repository"'; ui | grep -o 'text="Generate SSH key pair"'
+tap 'text="Generate SSH key pair"'; ui | grep -o 'text="ECDSA"'
+adb shell input keyevent 4; sleep 1; ui | grep -o 'text="Delete repository"'
+adb logcat -d -b crash | grep -c 'FATAL EXCEPTION'
+adb uninstall xyz.danix.hardenedpass.debug
+unset ANDROID_SERIAL
+```
+Expected: each `grep -o` prints its text (welcome screen's Settings opens the categories, the
+General screen through its enum route argument, the Repository screen, the generation screen
+in-graph and back to the Repository screen), and the crash count is `0`. A crash (for example a
+missing `NavType`) is fixed before the commit.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt \
+ app/src/main/java/app/passwordstore/ui/main/MainActivity.kt \
+ app/src/main/java/app/passwordstore/ui/list/ListDestination.kt \
+ app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt
+git commit -m "feat(app): run Settings in MainActivity
+
+The list and the welcome screen open the Compose settings; their rows
+open the git server, SSH key, key generation, PGP keys, proxy and git
+config screens within the graph. Deleting the repository returns to the
+welcome screen.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 7: Remove the legacy settings screens
+
+**Files:**
+- Delete: the files listed in "Deleted in this step" above
+- Modify: `app/src/main/AndroidManifest.xml`
+- Modify: `app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/main/MainActivity.kt`
+- Modify: `app/src/main/res/values*/strings.xml`, `app/src/main/res/values/dimens.xml`
+- Modify: `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`
+
+- [ ] **Step 1: Delete the files**
+
+```bash
+git rm app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt \
+ app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt \
+ app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt \
+ app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt \
+ app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt \
+ app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt \
+ app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt \
+ app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt \
+ app/src/main/java/app/passwordstore/util/git/GitLogModel.kt \
+ app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt \
+ app/src/main/res/layout/activity_preference_recyclerview.xml \
+ app/src/main/res/layout/activity_git_config.xml \
+ app/src/main/res/layout/activity_git_log.xml \
+ app/src/main/res/layout/git_log_row_layout.xml \
+ app/src/main/res/layout/activity_proxy_selector.xml \
+ app/src/main/res/layout/dialog_text_input.xml \
+ app/src/main/res/layout/oreo_autofill_instructions.xml
+git rm -r app/src/main/java/app/passwordstore/ui/git/config \
+ app/src/main/java/app/passwordstore/ui/git/log \
+ app/src/main/java/app/passwordstore/ui/proxy
+sed -i '/name="pref_title_openkeystore_clear_keyid"/d; /name="pref_biometric_auth_summary_disabled_platform"/d' \
+ app/src/main/res/values*/strings.xml
+sed -i '/name="normal_margin"/d' app/src/main/res/values/dimens.xml
+```
+
+(`pref_biometric_auth_summary_disabled_platform` lost its last user with `GeneralSettings.kt`:
+the Compose row no longer blocks Android 14, maintainer decision 4; it exists only in `values/`
+and `values-it/`. `@dimen/normal_margin` was used only by `git_log_row_layout.xml` and
+`activity_proxy_selector.xml`, so it would be `UnusedResources`.)
+
+(`ui/git/config` holds only `GitConfigActivity.kt` since step 5; `ui/git/log` only the activity
+and its adapter. `GitCommit.kt` stays: `gitLog` returns it.)
+
+- [ ] **Step 2: Manifest**
+
+In `app/src/main/AndroidManifest.xml`, delete
+
+```xml
+ <activity
+ android:name=".ui.proxy.ProxySelectorActivity"
+ android:exported="false"
+ android:label="@string/activity_proxy_label"
+ android:windowSoftInputMode="adjustResize" />
+
+```
+
+and
+
+```xml
+ <activity
+ android:name=".ui.git.config.GitConfigActivity"
+ android:exported="false"
+ android:label="@string/title_activity_git_config"
+ android:windowSoftInputMode="adjustResize" />
+
+ <activity
+ android:name=".ui.git.log.GitLogActivity"
+ android:exported="false"
+ android:label="@string/title_activity_git_log" />
+
+ <activity
+ android:name=".ui.settings.SettingsActivity"
+ android:exported="false"
+ android:label="@string/action_settings"
+ android:parentActivityName=".ui.main.MainActivity" />
+
+```
+
+- [ ] **Step 3: The setup starts Settings no longer uses**
+
+In `app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt`, replace
+
+```kotlin
+enum class SetupStart(val route: Any) {
+ GitServer(GitServerRoute(clone = false)),
+ Ssh(SshKeyRoute),
+ SshGenerate(SshKeyGenRoute),
+ PgpKeys(PgpKeysRoute()),
+ PgpImport(PgpKeysRoute(KeysMode.Need)),
+}
+```
+
+with
+
+```kotlin
+enum class SetupStart(val route: Any) {
+ Ssh(SshKeyRoute),
+ SshGenerate(SshKeyGenRoute),
+ PgpImport(PgpKeysRoute(KeysMode.Need)),
+}
+```
+
+In `app/src/main/java/app/passwordstore/ui/main/MainActivity.kt`, replace the KDoc and
+`ponytail:` comment of `setupIntent`
+
+```kotlin
+ /**
+ * Opens one setup screen in an instance of its own, for callers outside the graph (legacy
+ * Settings, autofill, git operations) and for those waiting for a result (the entry view's
+ * missing key). Leaving that screen finishes the instance; the PGP keys screen sets RESULT_OK
+ * once a key was imported.
+ */
+ // ponytail: a second instance instead of in-graph navigation, until steps 6 and 7 move
+ // Settings and autofill into the graph.
+```
+
+with
+
+```kotlin
+ /**
+ * Opens one setup screen in an instance of its own, for callers outside the graph (autofill,
+ * the git operations' "No SSH key" dialog) and for those waiting for a result (the entry view's
+ * missing key). Leaving that screen finishes the instance; the PGP keys screen sets RESULT_OK
+ * once a key was imported.
+ */
+ // ponytail: a second instance instead of in-graph navigation, until step 7 moves autofill
+ // into the graph and the git operations' dialogs become Compose dialogs.
+```
+
+- [ ] **Step 4: Nothing refers to the removed code**
+
+```bash
+grep -rnwE 'SettingsActivity|SettingsProvider|GeneralSettings|AutofillSettings|PasswordSettings|RepositorySettings|MiscSettings|PGPSettings|GitConfigActivity|GitLogActivity|GitLogAdapter|GitLogModel|ProxySelectorActivity|TextInputDialog|openSettings' \
+ app/src
+grep -rnE 'SetupStart\.(GitServer|PgpKeys)\b|de\.Maxr1998|R\.layout\.(activity_preference_recyclerview|activity_git_config|activity_git_log|git_log_row_layout|activity_proxy_selector|dialog_text_input|oreo_autofill_instructions)|Activity(PreferenceRecyclerview|GitConfig|GitLog|ProxySelector)Binding|GitLogRowLayoutBinding|DialogTextInputBinding' \
+ app/src
+grep -rn 'pref_title_openkeystore_clear_keyid\|pref_biometric_auth_summary_disabled_platform\|normal_margin' app/src
+```
+Expected: no output from any of them.
+
+- [ ] **Step 5: The spec**
+
+In `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`, replace
+
+```
+handling and the passphrase cache. Existing stores, keys and settings carry over without
+migration.
+```
+
+with
+
+```
+handling and the passphrase cache. Existing stores, keys and settings carry over without
+migration, with two exceptions from step 6: a stored "files first" or "type independent" sort
+order (both removed) becomes folders first, and the unused OpenKeychain SSH key ID is dropped.
+```
+
+and replace
+
+```
+- Screens outside the graph (Settings, autofill, git operations) and the entry view's key
+ import open these through a `MainActivity` setup intent until steps 6 and 7.
+```
+
+with
+
+```
+- Settings opens these within the graph. Autofill, the git operations' "No SSH key" dialog and
+ the entry view's key import open them through a `MainActivity` setup intent until step 7.
+
+### Settings
+
+- Settings lists its categories (general, autofill, passwords, repository, misc, PGP); each opens
+ a screen of rows (switches, single choices, text values, actions) over the existing preference
+ keys. Switches behind the screen lock store their value only after a successful prompt.
+- Rows that lead elsewhere navigate within `MainActivity`: git server, SSH key, key generation,
+ PGP keys, proxy, and the git configuration screen (author name and email, commit log, abort
+ rebase, hard reset, garbage collection).
+- Destructive actions ask first: deleting the repository (which never follows symlinks out of the
+ store and returns to the welcome screen), clearing a saved password or passphrase, the hard
+ reset.
+- The list's sort orders are folders first and recently used; "files first" and "type
+ independent" are gone, and stored values fall back to folders first.
+```
+
+- [ ] **Step 6: 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 unused resources, they were used only by the
+deleted files (`normal_margin` and the two strings are already handled in Step 1; candidates:
+`title_activity_git_config` is still used by the git config screen,
+`activity_proxy_label` by the proxy screen; strings or drawables only the deleted layouts used,
+`pref_import_pgp_key_title` if nothing else uses it): confirm with `grep -rn` that nothing else
+uses them, then delete them from every `values*/` file (a string left only in a translation is
+an `ExtraTranslation` error) or from `res/drawable`, and re-run. `map_preference_widget_switch.xml`
+stays (lint-ignored, removed with the library in step 8). If lint reports an issue in a file from
+Tasks 1 to 6, fix it there and include it in this commit.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A app/src docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md
+git commit -m "refactor(app): remove the legacy settings screens
+
+The Compose settings, git config, commit log and proxy screens replace
+SettingsActivity with its six preference providers, GitConfigActivity
+with TextInputDialog, GitLogActivity with its adapter and GitLogModel,
+and ProxySelectorActivity, with their layouts and manifest entries.
+The dead OpenKeystore key ID row goes with its string, the Android 14
+platform bug summary and normal_margin lose their last users, and the
+setup intent loses the two starts only Settings used.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 8: Emulator check and push
+
+No real store or keys: the e2e environment from AGENTS.md only. A physical tablet may be
+connected over USB: every `adb` and Gradle install command in this task targets the emulator
+through `ANDROID_SERIAL`; never touch the tablet. The PIN `1234` below is the emulator's own test
+PIN, never the owner's.
+
+- [ ] **Step 1: Server, emulator, unlock, clean install, helpers**
+
+```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
+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 xyz.danix.hardenedpass.debug || true
+./gradlew :app:installDebug
+scripts/e2e-env.sh push-keys
+OUT=$(mktemp -d)
+```
+
+(`ANDROID_HOME` is the `sdk.dir` from `local.properties`. `set-pin` fails harmlessly when the
+AVD already has the PIN.)
+
+Helpers, the step 5 set plus `pin`, `until_seen` (polls, for snackbars), `prefs`, `secure` and
+`enabled`:
+
+```bash
+APP=xyz.danix.hardenedpass.debug
+ui() { adb exec-out uiautomator dump /dev/tty; }
+center() { local b; b=$(ui | grep -o "<node [^>]*$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 ))"; }
+tap() { local c; c=$(center "$1" "$2") && adb shell input tap $c; sleep 1; }
+field() { tap 'class="android.widget.EditText"' "$1"; adb shell input keycombination 113 29; adb shell input keyevent 67; adb shell input text "$2"; }
+value() { ui | grep -o '<node [^>]*class="android.widget.EditText"[^>]*>' | sed -n "${1}p" | grep -o ' text="[^"]*"' | cut -d'"' -f2; }
+seen() { ui | grep -q "$1" && echo "ok: $1" || echo "MISSING: $1"; }
+enabled() { ui | grep -o "<node [^>]*$1[^>]*>" | head -1 | grep -o 'enabled="[a-z]*"'; }
+top() { adb shell dumpsys activity activities | grep topResumedActivity; }
+open() { adb shell am start -n $APP/app.passwordstore.ui.main.LaunchActivity; sleep 3; }
+pick() { sleep 2; ui | grep -q "text=\"$1\"" || { tap 'content-desc="Show roots"'; tap 'text="Downloads"'; sleep 1; }; tap "text=\"$1\""; sleep 2; }
+pin() { sleep 2; adb shell input text 1234; adb shell input keyevent 66; sleep 2; }
+until_seen() { for _ in $(seq 30); do ui | grep -q "$1" && { echo "ok: $1"; return; }; sleep 1; done; echo "MISSING: $1"; }
+prefs() { adb shell run-as $APP cat shared_prefs/${APP}_preferences.xml | grep -o "name=\"$1\"[^/]*" || echo "absent: $1"; }
+secure() { adb shell dumpsys window | grep -c 'fl=.*SECURE'; }
+```
+
+- [ ] **Step 2: A cloned store to work with**
+
+```bash
+open; tap 'text="Clone Remote Repo"'
+field 1 ssh://git@10.0.2.2:2222/srv/git/store.git
+tap 'text="Manage"'; tap 'text="Import SSH key"'; pick aps-e2e-ssh-key
+seen 'text="Imported private key"'; adb shell input keyevent 4; sleep 1
+tap 'text="Clone"'; sleep 15
+tap 'text="Import PGP key"' 2; pick aps-e2e-pgp-key.asc; tap 'text="OK"'; sleep 2
+seen 'text="example.org"'
+```
+Expected: the step 5 flow ends on the cloned store's list with `example.org`. (If the FAB is
+the first "Import PGP key" match on this image, use index 1, as in step 5.)
+
+- [ ] **Step 3: Categories, General, sort orders, theme recreation**
+
+```bash
+tap 'content-desc="More options"'; tap 'text="Settings"'
+for t in General Autofill Passwords Repository Misc 'PGP settings'; do seen "text=\"$t\""; done
+adb exec-out screencap -p > "$OUT/settings.png"
+tap 'text="General"'
+tap 'text="Password sort order"'
+seen 'text="Folders first"'; seen 'text="Recently used"'; seen 'text="Files first"'; seen 'text="Type independent"'
+tap 'text="Recently used"'; prefs sort_order
+tap 'text="App theme"'; adb shell cmd uimode night yes; sleep 3
+seen 'text="System default"'; tap 'text="Dark"'; sleep 3
+seen 'text="Password sort order"'; prefs app_theme
+tap 'text="App theme"'; tap 'text="System default"'; sleep 3
+adb shell cmd uimode night no; sleep 3
+tap 'text="Show all files and folders"'; prefs show_hidden_contents
+tap 'text="Show all files and folders"'; prefs show_hidden_contents
+```
+Expected: the six categories; the sort dialog offers only "Folders first" and "Recently used"
+(`MISSING` for the other two); `sort_order` is `RECENTLY_USED`; the theme dialog survives the
+night mode recreation; choosing Dark recreates the activity and the General screen stays;
+`app_theme` is `dark`, then back to system default; the switch writes `true`, then `false`.
+
+- [ ] **Step 4: The sort order migration on upgrade**
+
+```bash
+adb shell am force-stop $APP
+adb shell run-as $APP sed -i 's/RECENTLY_USED/FILE_FIRST/' shared_prefs/${APP}_preferences.xml
+prefs sort_order
+open; prefs sort_order
+seen 'text="example.org"'
+```
+Expected: `FILE_FIRST` before the start, `FOLDER_FIRST` after (the migration ran in
+`Application.onCreate`), and the list opens normally.
+
+- [ ] **Step 5: Screen lock gated switches**
+
+```bash
+tap 'content-desc="More options"'; tap 'text="Settings"'; tap 'text="General"'
+tap 'text="Enable biometric authentication"'; sleep 2; adb shell input keyevent 4; sleep 2
+prefs biometric_auth_delete_soon_please
+tap 'text="Enable biometric authentication"'; pin; prefs biometric_auth_delete_soon_please
+tap 'text="Enable biometric authentication"'; pin; prefs biometric_auth_delete_soon_please
+adb shell input keyevent 4; sleep 1; tap 'text="PGP settings"'
+enabled 'text="Automatically clear passphrase cache"'
+tap 'text="Enable passphrase caching"'; pin; prefs enable_gpg_passphrase_cache
+enabled 'text="Automatically clear passphrase cache"'
+tap 'text="Automatically clear passphrase cache"'; pin; prefs pgpainless_auto_clear_passphrase_cache_screen_off
+tap 'text="Enable passphrase caching"'; pin
+prefs enable_gpg_passphrase_cache; prefs pgpainless_auto_clear_passphrase_cache_screen_off
+tap 'text="Key manager"'; sleep 2; seen 'text="f8993a789cdfb014"'; adb shell input keyevent 4; sleep 1
+```
+Expected: cancelling the prompt stores nothing (`absent: biometric_auth_delete_soon_please`);
+the PIN stores `true`, the next one `false`. Auto-clear is `enabled="false"` until the cache is
+on; enabling the cache stores `true`; turning auto-clear off after the PIN stores `false`;
+disabling the cache stores `false` and removes the auto-clear key (`absent:`). The key manager
+opens in-graph with the imported key. (The `enabled` helper reads the row node; if Compose
+merges it differently on this image, compare the dumps of both states by eye.) The emulator runs
+Android 15: the maintainer checks the app lock (enable, relaunch, unlock, disable) on his Android
+14 tablet himself, since it is enabled there again (maintainer decision 4); if upstream's platform
+bug (issue 2802) appears, the Android 14 block returns.
+
+- [ ] **Step 6: Autofill and export**
+
+```bash
+adb shell input keyevent 4; sleep 1; tap 'text="Autofill"'
+enabled 'text="Default username"'
+tap 'text="Enable Autofill"'; seen 'Autofill support with installed browsers'
+tap 'text="Cancel"'; seen 'text="Enable Autofill"'
+adb shell input keyevent 4; sleep 1; tap 'text="Misc"'
+tap 'text="Export passwords"'; sleep 2; top
+adb shell input keyevent 4; sleep 2; top
+```
+Expected: default username disabled while the service is off; the instructions dialog lists the
+browsers and Cancel closes it (the service setting is left alone: it was nulled in step 1 and
+is not changed here); Export opens the system folder picker (`documentsui` on top), back returns
+to `MainActivity`.
+
+- [ ] **Step 7: Git config, commit log, hard reset, gc**
+
+```bash
+adb shell input keyevent 4; sleep 1; adb shell input keyevent 4; sleep 1
+tap 'text="New entry"'; tap 'text="Create new password"'
+adb shell input text local-only; field 2 x1y2; tap 'text="Save"'; sleep 3; seen 'text="local-only"'
+tap 'content-desc="More options"'; tap 'text="Settings"'; tap 'text="Repository"'
+tap 'text="Local Git config'; sleep 2
+seen 'text="On branch master"'; enabled 'text="Abort rebase and push new branch"'
+field 1 Test; field 2 not-an-address; tap 'text="Save"'
+seen 'text="Please enter a valid email address"'; tap 'text="OK"'
+field 2 test@example.org; tap 'text="Save"'; sleep 2; prefs git_config_user_email
+tap 'text="Local Git config'; sleep 2; value 1; value 2
+tap 'text="Show commit log"'; sleep 2; seen 'local-only'; seen 'text="Initial test store"'
+adb exec-out screencap -p > "$OUT/log.png"
+adb shell input keyevent 4; sleep 1
+tap 'text="Hard reset to remote branch"'; seen 'are discarded'; value 1
+tap 'text="Cancel"'; seen 'text="Utilities"'
+tap 'text="Hard reset to remote branch"'; tap 'text="OK"'
+until_seen 'remote branch: done'; seen 'text="Utilities"'
+tap 'text="Show commit log"'; sleep 2; seen 'local-only'; adb shell input keyevent 4; sleep 1
+tap 'text="Run garbage collection job"'; until_seen 'collection job: done'; seen 'text="Utilities"'
+adb shell cmd uimode night yes; sleep 3; seen 'collection job: done'
+adb shell cmd uimode night no; sleep 3
+adb shell input keyevent 4; sleep 1; adb shell input keyevent 4; sleep 1
+adb shell input keyevent 4; sleep 2
+seen 'text="local-only"'; seen 'text="example.org"'
+```
+Expected: the new entry is committed locally (not pushed). The git config screen says "On branch
+master", abort is `enabled="false"`; an invalid email shows the old dialog and saves nothing; a
+valid one shows the toast and closes; reopened, the fields hold `Test` and `test@example.org`.
+The log lists the local commit first and "Initial test store". The reset dialog warns and
+prefills `master`; Cancel only closes the dialog; OK resets and the screen stays with the
+snackbar "Hard reset to remote branch: done" (maintainer decision 5); the log no longer has the
+local commit (`MISSING: local-only`); gc stays too, with its own snackbar and no error dialog,
+and a recreation afterwards does not show it again (`MISSING` after the night mode switch, once
+the first snackbar has gone; if it is still on screen, wait and repeat the check). Back on the
+list `local-only` is `MISSING` (the reset discarded it) and `example.org` is there.
+
+- [ ] **Step 8: Proxy (HTTPS remote) and FLAG_SECURE**
+
+```bash
+tap 'content-desc="More options"'; tap 'text="Settings"'; tap 'text="Repository"'
+seen 'text="HTTP(S) proxy settings"'
+tap 'text="Edit Git server settings"'; sleep 2
+field 1 https://example.org/store.git; tap 'text="Save"'; sleep 2
+seen 'text="HTTP(S) proxy settings"'; tap 'text="HTTP(S) proxy settings"'; sleep 2
+secure
+field 1 'bad%shost'; seen 'text="Invalid URL"'
+field 1 10.0.2.2; field 2 70000; seen 'Enter a port from 1 to 65535'
+field 2 3128; field 3 proxy-user; field 4 proxy-pass
+tap 'text="Save"'; sleep 2; secure
+tap 'text="HTTP(S) proxy settings"'; sleep 2; value 1; value 2; value 3
+field 1 ''; field 2 ''; tap 'text="Save"'; sleep 2
+tap 'text="HTTP(S) proxy settings"'; sleep 2; value 1; value 2; value 3
+adb shell input keyevent 4; sleep 1
+tap 'text="Edit Git server settings"'; sleep 2
+field 1 ssh://git@10.0.2.2:2222/srv/git/store.git; tap 'text="Save"'; sleep 2
+seen 'text="HTTP(S) proxy settings"'
+```
+Expected: no proxy row with the SSH remote (`MISSING`); after saving an HTTPS URL (mode falls
+back to None) the row appears and opens the proxy screen with `secure` at `1` or more (FLAG_SECURE
+on); a host with a space shows "Invalid URL", port 70000 the port error, and Save is disabled
+for both; a valid proxy saves with the toast and closes, `secure` back at `0`; reopened, host
+`10.0.2.2`, port `3128`, user `proxy-user` (the user name now reads back from where it is
+stored); clearing host and port and saving leaves both empty on the next open. Restoring the SSH
+URL hides the row again. (`field 1 ''` clears the field: the helper selects all and deletes
+before typing nothing; the empty `input text` may print a usage line, which is harmless. If `secure` counts another window's SECURE flag, compare against the
+count on the Repository screen.)
+
+- [ ] **Step 9: Key generation refreshes "View generated public SSH key"**
+
+```bash
+seen 'text="View generated public SSH key"'
+tap 'text="Generate SSH key pair"'; sleep 2
+tap 'text="Protect with screen lock credential"'; tap 'text="Generate"'; sleep 5
+seen 'text="Replace existing SSH key"'
+```
+If the replace confirmation shows, confirm it (`tap 'text="Replace"'` or the dialog's confirm
+text on this image; dump with `ui` if unsure) and wait for the key:
+
+```bash
+seen 'text="Your public key"'; tap 'text="Later"'; sleep 2
+seen 'text="View generated public SSH key"'
+tap 'text="View generated public SSH key"'; sleep 2; seen 'ecdsa-sha2-nistp'
+adb shell input keyevent 4; sleep 1
+```
+Expected: the row is `MISSING` with the imported e2e key; after generating an ECDSA key in-graph
+and leaving the dialog, the Repository screen shows it at once (refreshed on resume), and it
+opens the SSH key screen with the public key.
+
+- [ ] **Step 10: Delete repository never follows symlinks**
+
+```bash
+adb shell run-as $APP ln -s .. files/store/up
+adb shell run-as $APP ls files/pgp_keys
+tap 'text="Delete repository"'; seen 'text="Clear repository"'
+tap 'text="Cancel"'; seen 'text="Delete repository"'
+tap 'text="Delete repository"'; tap 'text="Delete directory"'; sleep 3
+seen 'text="Clone Remote Repo"'
+adb shell run-as $APP ls -a files/store
+adb shell run-as $APP ls files/pgp_keys
+adb shell run-as $APP ls -a files | grep -c ssh_key
+tap 'text="Settings"'; tap 'text="Repository"'
+seen 'text="Generate SSH key pair"'; seen 'text="Delete repository"'
+seen 'text="Edit Git server settings"'; seen 'text="Local Git config'
+adb shell input keyevent 4; adb shell input keyevent 4; adb shell input keyevent 4; sleep 1
+adb shell dumpsys window | grep mCurrentFocus
+```
+Expected: `store/up` points at the app's `files` folder; Cancel keeps everything; Delete
+returns to the welcome screen (the stack was replaced); `files/store` is empty (`.` and `..`);
+the PGP key file is still in `pgp_keys` and the SSH key files are still there (count `1` or
+more): `deleteRecursively()` would have emptied `files` through the link. Settings from the
+welcome screen shows only the rows that need no repository (`MISSING` for the git server and
+git config). Back from the welcome screen leaves the app.
+
+- [ ] **Step 11: Minified release build: the enum route argument**
+
+```bash
+./gradlew :app:assembleRelease
+adb install -r app/build/outputs/apk/release/app-release.apk
+adb logcat -c
+adb shell am start -n xyz.danix.hardenedpass/app.passwordstore.ui.main.LaunchActivity; sleep 3
+tap 'text="Settings"'; tap 'text="General"'; seen 'text="Password sort order"'
+adb shell input keyevent 4; sleep 1; tap 'text="Misc"'; seen 'text="Debug logging"'
+adb logcat -d -b crash | grep -c 'FATAL EXCEPTION'
+adb uninstall xyz.danix.hardenedpass
+```
+Expected: the R8-minified release build opens Settings and two category screens through the
+`SettingsSection` route argument; the release build shows the "Debug logging" row (hidden in
+debug builds); crash count `0`. The release package is removed from the emulator afterwards.
+
+- [ ] **Step 12: Tear down and push**
+
+```bash
+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
+unset ANDROID_SERIAL
+rm -rf "$OUT"
+git push
+```
+
+Expected: push succeeds to every `origin` push URL. Look at the screenshots before deleting
+`$OUT` if a visual check is wanted (copy them elsewhere first).
+
+---
+
+## Self-review notes
+
+- **Spec coverage for step 6:** Settings as hand-written Compose rows over the existing keys
+ (Tasks 2, 3; checked in Task 8 steps 3 to 6), no settings library in use (the dependency goes in
+ step 8 with the others); git log (Tasks 1, 5; step 7); proxy (Tasks 1, 4; step 8); the git
+ config screen that step 5 left here (Tasks 1, 5; step 7). All four legacy activities, their
+ layouts and manifest entries deleted (Task 7). `MainActivity` replaces `SettingsActivity`,
+ `GitConfigActivity`, `GitLogActivity`, `ProxySelectorActivity` as the spec lists. Spec gains a
+ Settings section and its Setup bullet is updated (Task 7).
+- **Maintainer decisions applied:** 1 and 2 as planned (Tasks 3, 5); 3 plus a tested migration
+ removing the stale key ID (Task 1); 4: the Android 14 block, its summary and string removed
+ (Tasks 3, 7), the tablet check noted in Task 8 step 5; 5: reset and gc stay with a snackbar
+ announced once from ViewModel state (Task 5, new string `git_tool_done`; Task 8 step 7).
+- **Review findings applied:** `logcat(TAG, ERROR)` with a file `TAG` in `SettingsModel.kt` (the
+ top-level `logcat(priority)` form does not exist in logcat 0.1); `deleteRepository` under
+ `NonCancellable` and input ignored while it runs (Tasks 2, 3); `normal_margin` named and
+ deleted with a grep check (Task 7); the spec's "carry over without migration" amended (Task 7);
+ the stale `SSH_OPENKEYSTORE_KEYID` removed and tested (Task 1); a `prompting` guard for screen
+ lock prompts (Task 3); `if (x != null) { { Text(x) } } else null` in `PreferenceRows.kt`
+ (Task 2); a delete confirmation message of its own, `store_delete_confirm` (Task 3).
+- **Known requirements:** "files first" and "independent" removed with a migration to folders
+ first and a tolerant reader (Task 1, tested; Task 8 step 4); "Delete repository" uses
+ `deleteWithoutFollowingLinks()` (Task 2; Task 8 step 10 with a `store/up -> ..` link); the
+ rows step 5 rewired navigate in-graph and "View generated public SSH key" refreshes on resume
+ (Tasks 3, 6; Task 8 steps 5, 9); `GitConfigActivity` ported (Task 5).
+- **Lessons applied:** enum route argument `@Keep` and checked minified (Tasks 3, 8); work that
+ needs the activity in `lifecycleScope` with state in the ViewModel and a `LaunchedEffect`
+ (git config), the deletion in `viewModelScope`; compose-lint signatures, `ImmutableList`,
+ ViewModels as default parameters and never forwarded, `DispatcherProvider`, no `runCatching`,
+ no `Context` field; content descriptions on every icon-only control (back, password toggle);
+ secrets only in ViewModel memory and `KeystorePreferences`, presence tested with `contains`;
+ confirmations for delete, clear and reset, recreation-safe (`rememberSaveable` dialog state,
+ ViewModel results); strings in `values/` and `values-it/`, each added by the task that uses it;
+ `:app:lintDebug` in every task, full CI in Task 7; emulator pinned to `emulator-5554` with the
+ e2e server (Task 8) and a smoke check in Task 6.
+- **Fixes found while reading the legacy code:** the clear-saved-credential row never cleared
+ anything (Task 2, 3); `GitSettings.proxyUsername` read the wrong preferences, so proxy
+ authentication never worked (Task 1, tested; Task 8 step 8); `BreakOutOfDetached` finished the
+ calling activity (Task 5); the proxy port crashed on overflow and could not be cleared (Tasks
+ 1, 4); the commit log was read on the main thread (Task 5); gated switches stored unconfirmed
+ values (Task 3).
+- **Not checked on the emulator:** clearing a saved credential (the e2e key has no passphrase
+ and the e2e server no HTTPS); the abort rebase success path (needs a conflicting remote
+ commit; the button's disabled state is checked, and the operation itself is unchanged and
+ covered by the full device checklist before merge: "conflict recovery"); the autofill
+ service picker's OK (it changes a device setting). The maintainer covers these on the phone if
+ he uses them.
+- **Deliberate shortcuts (`ponytail:`):** git operations of the git config screen cancelled by a
+ theme or locale change (Task 5), as everywhere in the app; the whole commit log read at once
+ (Task 1).
+- **Type and name consistency:** `proxyPort(text: String): Int?`, `HeadStatus.Branch/Detached/
+ Missing`, `headStatus(repo)`, `needsAbort(repo)`, `gitLog(repo: Repository?): List<GitCommit>`
+ (Task 1) are used with these signatures in Tasks 4 and 5. `rowColors`, `PreferenceAction`,
+ `PreferenceSwitch`, `PreferenceChoice(title, values, labels, selected, onSelect)`,
+ `PreferenceText(title, value, onValueChange, modifier, summary, hint, singleLine,
+ keyboardType, enabled)` (Task 2) match Task 3. `SettingsState` fields and
+ `SettingsViewModel.state/deleting/deleted/refresh/putBoolean/putString/setPassphraseCache/
+ disableAutoClear/resetAutoClear/clearSavedSecret/deleteRepository` (Task 2) match Task 3.
+ New strings: `store_delete_failed`, `clear_saved_secret_message`, `store_delete_confirm`
+ (Task 3), `invalid_proxy_port` (Task 4), `git_reset_confirm_message`, `git_tool_done` (Task 5),
+ each in `values/` and `values-it/`.
+ `SettingsSection`, `SettingsAction` (Task 3) match Task 6's routes and `onOpen`.
+ `ProxyViewModel.host/port/username/password/hostError/portError/save` and `isProxyHost`
+ (Task 4); `GitConfigViewModel.name/email/head/canAbort/busy/done/abortedBranch/refresh/save`,
+ `MainActivity.gitOperation(op, branch)`, `ResetDialog`, `GitLogViewModel.commits`,
+ `GitLogDestination(onClose)` (Task 5) match Task 6. `SetupDialog(text, confirm, onConfirm,
+ onDismiss, modifier, title, dismiss, onDismissButton)` from step 5 is used with named
+ arguments. `MainActivity.holdSecure/releaseSecure` (step 3) are public.
+- **Build order:** Task 1 changes the sort enum, its arrays and strings together, so the legacy
+ Settings keeps building; Tasks 2 to 5 add unreferenced but compiled code with the strings it
+ uses; Task 6 wires it and leaves the legacy files compiling and unreferenced; Task 7 deletes
+ them and the two `SetupStart` values their last callers used.
+
+## Maintainer decisions (2026-11-03)
+
+1. **Settings layout:** two levels, categories then one screen per category, as planned (Tasks 3,
+ 6).
+2. **Hard reset:** the warning and the current branch prefilled, as planned (Task 5).
+3. **"Clear remembered OpenKeystore SSH Key ID":** dropped, as planned; a migration also removes
+ the stale `SSH_OPENKEYSTORE_KEYID` value (Task 1, tested).
+4. **Biometric app lock on Android 14:** changed. Re-enabled: the Android 14 block and its
+ "Feature unavailable due to a platform bug" path are removed, the string deleted from
+ `values/` and `values-it/` (Tasks 3, 7). The maintainer checks the app lock on an Android 14
+ tablet (Task 8 step 5); if the platform bug appears, the block returns.
+5. **After a successful hard reset or gc:** changed. The git config screen stays and shows a short
+ snackbar (`git_tool_done`, "%1$s: done"), kept in `GitConfigViewModel.done` until shown, so it is
+ announced once across a recreation (Task 5; Task 8 step 7).
diff --git a/docs/superpowers/plans/2026-11-03-compose-setup.md b/docs/superpowers/plans/2026-11-03-compose-setup.md
new file mode 100644
index 00000000..d62aeff6
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-03-compose-setup.md
@@ -0,0 +1,3790 @@
+# Compose UI overhaul, step 5: Setup. 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 setup screens with Compose destinations in `MainActivity`: onboarding (`OnboardingActivity` and its three fragments), the git server screen and clone (`GitServerConfigActivity`), SSH key import, generation and display (`SshKeyImportActivity`, `SshKeyGenActivity`, `ShowSshKeyFragment`) and PGP key import and management (`PGPKeyImportActivity`, `PGPKeyListActivity`). The setup flow asks for the PGP key after a clone and lets a wrongly imported SSH key be replaced at any time, the two real-device findings of step 4.
+
+**Architecture:** Five routes in a new `ui/setup` package, registered by `NavGraphBuilder.setupGraph` in `MainActivity`'s `NavHost`: `WelcomeRoute` (start destination when no store is set up), `GitServerRoute(clone)`, `SshKeyRoute`, `SshKeyGenRoute` and `PgpKeysRoute(mode)` with modes `Manage`, `Choose` (key of a new local store) and `Need` (a store or an entry needs a key). Each gets its own ViewModel per back stack entry (`GitServerViewModel`, `SshKeyViewModel`, `PgpKeysViewModel`). Pure logic (URL checks and fixes, auth modes per URL, clone target state, `.gpg-id` recipients, SSH private key file check) sits in `SetupModel.kt` and `SshKey.kt` with unit tests. Work that needs the activity (clone through `launchGitOperation`, the `.gpg-id` commit) runs in `activity.lifecycleScope` with busy flags reset in `finally`, and its success is kept in the ViewModel and completed by a `LaunchedEffect`; work that does not (key generation after the biometric prompt, SSH and PGP imports) runs in `viewModelScope`, so a recreation never loses it. Callers outside the graph or waiting for a result (legacy Settings rows, `GitOperation`'s "No SSH key" dialog, the entry view's and autofill's "No keys imported" prompts) start `MainActivity.setupIntent(context, SetupStart.X)`: an instance whose first screen is that destination and which finishes when it is left, returning `RESULT_OK` after a PGP import. The legacy activities, fragments, layouts, `BasicBottomSheet`, `PGPKeyList.kt`, `PGPKeyListViewModel` and ui:compose `APSAppBar` are deleted.
+
+**Tech Stack:** Kotlin 2.4.21, Compose BOM 2024.09.03 (Material 3 1.3.0: `SingleChoiceSegmentedButtonRow`, `SegmentedButton`, `ExtendedFloatingActionButton`, `LinearProgressIndicator`; foundation 1.7.3: `KeyboardOptions(autoCorrectEnabled = ...)`, `SelectionContainer`, `Modifier.toggleable`), navigation-compose 2.8.9 (type-safe routes, `data object` and enum arguments, `popUpTo(nav.graph.id)`), `hilt-navigation-compose` 1.2.0, sshj 0.41.2 (`KeyProviderUtil.detectKeyFileFormat`), PGPainless key manager (unchanged), JGit 5.13 (unchanged), kotlin-result 2.0.0. No new dependencies.
+
+Spec: `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`. Earlier plans: `2026-11-02-compose-foundation.md`, `2026-11-02-compose-list.md`, `2026-11-02-compose-entry.md`, `2026-11-02-compose-editor.md` (same folder).
+
+## Decisions taken in this plan
+
+- **Routes, package and the setup intent.** Everything new lives in `app.passwordstore.ui.setup`. The NavHost starts at `WelcomeRoute` when `checkStore()` finds no store, at `ListRoute()` otherwise (an `EntryRoute` from a shortcut and a setup start still win). `checkStore()` no longer starts an activity: the list calls a new `onNoStore` callback when the store disappears (deleted from Settings), which replaces the stack with `WelcomeRoute`. Setup is complete when the stack is replaced by `ListRoute()` (`popUpTo(nav.graph.id) { inclusive = true }`): after a clone that needs no key, after "Later" or an import on the `Need` screen, after a new local store was created. `MainActivity.setupIntent(context, start)` puts `SetupStart.name` in an extra; that instance's first screen closes with `finish()` instead of popping. It is the only way in for screens outside the graph until steps 6 and 7 (Settings, autofill) move into it; the entry view uses it too, because it waits for an activity result exactly as it did with `PGPKeyImportActivity`, so its code barely changes (`ponytail:` in `MainActivity`).
+- **Welcome merges two screens** (maintainer decision 1). "Let's Go" and the repository type screen become one screen: icon, app name, the old explanation text, "Clone Remote Repo", "Create Local Repo" and "Settings" (still the legacy activity, step 6). It reuses the old strings.
+- **One git server screen for clone and edit.** `GitServerRoute(clone = true)` from the welcome screen and the list's clone prompt, `clone = false` from Settings. URL field, authentication mode as segmented buttons with the old rules (SSH URLs: SSH key or password; HTTP(S) URLs: none or password; a choice no longer valid falls back to the first), and, while the mode is SSH key, a row with the key's state and **Manage** (opens `SshKeyRoute`). "Clear saved host key" while one is saved. The old URL warnings become dialogs with the same texts and fixes, checked by `urlProblem` before `GitSettings.updateConnectionSettingsIfValid` (unchanged); two old slips are fixed: `git://` is checked first (with a port it used to get the `ssh://` fix), and `http://` with a port is left alone (it used to get `ssh://http://...`). Save in edit mode shows a toast and closes at once (the old screen waited 500 ms).
+- **Clone.** Same sequence as before: initialize the repository object if needed, delete confirmation when the store folder holds anything but a lone `.git` (deleted silently), "Deleting…" then "Cloning…" shown on the screen, `launchGitOperation(GitOp.CLONE)` in `activity.lifecycleScope`. Back is ignored while it runs. **A failed clone keeps the screen** with its URL and mode (the old activity finished after a failure that followed a deletion), so a wrong URL or SSH key is fixed and retried in place (step 4 finding). On success the key check runs in `viewModelScope` (it needs no activity, so a recreation in its IO cannot lose the answer) and stores whether the store still needs a PGP key in `GitServerViewModel.cloned`; a `LaunchedEffect` then opens `PgpKeysRoute(Need)` or the list. "Needs a key": no imported **secret** key matches any recipient of the store's root `.gpg-id` (or, without recipients, no secret key is imported at all); a public key alone cannot decrypt, so it does not count. The store folder is emptied with `deleteWithoutFollowingLinks()` (from `ui/list/StoreActions.kt`), never `deleteRecursively()`, which follows symlinks: a `store/link -> ../keys` link would wipe the PGP keys. Settings' "Delete repository" (`RepositorySettings`) still uses `deleteRecursively()` and has the same flaw; it is step 6's to fix with the Settings port. A theme or locale change in the middle of a clone cancels it, as for every git operation of the app (`ponytail:`); the clone can be started again and the delete confirmation covers the leftovers.
+- **SSH key screen** (`SshKeyRoute`) replaces `SshKeyImportActivity` and `ShowSshKeyFragment`: the key's state ("No SSH key yet", "Imported private key", "Generated on this device"), the public key with Copy and Share when the device generated it, **Import SSH key** (the old replace confirmation, the document picker, the toast and the error dialog) and **Generate SSH key pair** (opens `SshKeyGenRoute`). Settings' "Import SSH key" and "View generated public SSH key" and the "No SSH key" dialog's Import all open this screen, one tap before the picker (maintainer decision 4). The import runs on the IO thread and checks, in addition to the old size and marker checks, that sshj recognizes the format (`KeyProviderUtil.detectKeyFileFormat` is not `Unknown`): an OpenPGP private key block (`-----BEGIN PGP PRIVATE KEY BLOCK-----`) matched the old marker regex and was accepted as an SSH key, which is how the e2e PGP key ended up as the device's SSH key in step 4. A refused file leaves the current key in place (the old key is deleted only after the checks, unchanged). The size query no longer crashes on an empty cursor: `cursor.moveToFirst()` was unchecked before `getInt(0)` (a `CursorIndexOutOfBoundsException` would escape `viewModelScope`); an empty cursor now ends in the existing "Unable to open the ssh private key" `IOException`.
+- **Key generation screen** (`SshKeyGenRoute`) ports `SshKeyGenActivity`: RSA, ECDSA (default), Ed25519 as segmented buttons with the old explanations (plain text: `stringResource` drops their bold title, `ponytail:`), "Protect with screen lock credential" (enabled and on when the device is secure), the replace confirmation (Keep closes the screen, as before), the biometric prompt first when protected, the public key dialog (Share, Later; both close) and the error dialog (OK closes). Generation itself runs in `viewModelScope` (it needs no activity once the prompt succeeded), so the dialog that follows survives a recreation. `SshKey` is unchanged: Ed25519 keys still come from Bouncy Castle through `ed25519KeyPair` and `SshjConfig`'s pinned `KeyFactory.Ed25519`.
+- **PGP keys screen** (`PgpKeysRoute(mode)`) replaces `PGPKeyListActivity` (with `KeyList`) and `PGPKeyImportActivity`. Rows show the user ID and the long key ID; an extended FAB "Import PGP key" opens the picker; the old success (key ID), replace and failure dialogs follow. `Manage` (Settings) deletes with the old confirmation, now by key ID (by user ID, two keys sharing one could delete the wrong key). `Choose` (welcome screen, "Create Local Repo") picks the key: only then is the repository created, `.gpg-id` written and committed with the old message. `.gpg-id` now holds the key's 16-hex long key ID in uppercase, one line per key (`F8993A789CDFB014\n`), the format of the maintainer's desktop `pass init` store, instead of the user ID the old selection wrote (maintainer decision 3); `PGPIdentifier.fromString` reads it back as a key ID. Backing out creates nothing (the old flow created the repository first, so backing out left an empty store that the next start treated as set up). `Need` explains that the store needs its private key, lists the root `.gpg-id` recipients, and offers "Later" (maintainer decision 2); an import, "Later" or back completes the flow. `.gpg-id` is read only when it is a regular file, not a symlink, of at most 64 KiB (`readGpgId`), and off the main thread. The key file is read and imported off the main thread (the old activity read it on the main thread and imported with `runBlocking`); the bytes kept for the replace question live in ViewModel memory only and are zeroed (`fill(0)`) once imported, refused, answered or when the ViewModel is cleared. Rows read the first user ID safely: `KeyUtils.tryGetEmail` called `userIDs.next()` unguarded and threw on a key without user ID; it now returns null there. `PGPKeyListViewModel` (user IDs only, delete by user ID) is replaced by `PgpKeysViewModel`.
+- **Callers.** Settings rows (git server, import SSH key, generate, view public key, key manager) start setup intents. The list's clone prompt navigates to `GitServerRoute(clone = true)`. The entry view's "No keys imported" Import and `BasePGPActivity.requireKeysExist` (autofill decryption, step 7) start `PgpKeysRoute(Need)` for a result. `GitOperation`'s "No SSH key" dialog: Import opens the SSH key screen, Generate the generation screen, Cancel only closes the dialog (it used to finish the calling activity, which is now `MainActivity`, so the app closed). Its biometric branch for an unusable prompt also finished the calling activity; it now shows the same toast and cancels the operation.
+- **No FLAG_SECURE on setup screens.** None shows a secret: passphrases (SSH key, HTTPS password) are still asked by `CredentialFinder` when a git operation needs them, PGP passphrases by the entry view. Key file bytes never reach Compose state.
+- **`GitConfigActivity` stays for step 6** (maintainer decision 5): it is the Settings row "Edit git config" (author name and email) with the repository tools (git log, abort rebase, hard reset, gc), all Settings and git log material.
+
+## Feature parity
+
+### `OnboardingActivity` and `WelcomeFragment`
+
+| Behavior today | Decision |
+|---|---|
+| Separate activity, started by `MainActivity.checkStore()` from the list's resume | Changed: `WelcomeRoute` is the start destination without a store; the list's resume check navigates there when the store disappears |
+| Welcome: icon, app name, "Let's Go", Settings | Changed: merged with the repository type screen (maintainer decision 1); Settings kept (legacy activity) |
+| Back on the first screen leaves the app (`finishAffinity`) | Port: the welcome screen is the stack's root |
+
+### `CloneFragment`
+
+| Behavior today | Decision |
+|---|---|
+| "Clone Remote Repo" opens `GitServerConfigActivity` for a clone; on success marks the store initialized and closes onboarding | Port: `GitServerRoute(clone = true)`; then the key check and the list, whose `checkStore()` marks the store initialized (the folder is no longer empty) |
+| No PGP key step after a clone (step 4: the first save failed with "No keys were found") | New: `PgpKeysRoute(Need)` when no imported secret key matches the store's `.gpg-id` |
+| "Create Local Repo" creates the repository, then asks for the key | Changed: key first, repository created with it |
+| Creation failure: logged, empty folder deleted, onboarding closed | Changed: snackbar with the reason, empty folder deleted, the key screen stays |
+
+### `KeySelectionFragment`
+
+| Behavior today | Decision |
+|---|---|
+| "Select key" opens `PGPKeyListActivity` in selection mode | Port: `PgpKeysRoute(Choose)` |
+| Writes the key's user ID to `.gpg-id` off the main thread, marks the store initialized, commits "Initialize GPG IDs in HardenedPass." | Changed: the uppercase 16-hex long key ID, one line per key, as the maintainer's desktop store (maintainer decision 3); same commit, a failed commit shows the git error dialog; then the list |
+| Cancelled selection: snackbar "Selecting a GPG key is necessary to proceed" | Changed: back returns to the welcome screen, nothing was created |
+
+### `GitServerConfigActivity`
+
+| Behavior today | Decision |
+|---|---|
+| URL field prefilled from the settings | Port |
+| Toggle group: SSH key and password for SSH URLs, password or nothing (none) for HTTPS; adjusted while typing | Port as segmented buttons, "None" a button of its own (new string) |
+| Port and scheme warnings in bottom sheets with fixes; `git://` refused | Port as dialogs; `git://` checked first; `http://` with a port no longer offered `ssh://` |
+| Save results: snackbars for an unparsable URL and a wrong mode, sheet for a missing user name | Port (dialog for the user name) |
+| "Clear saved host key" when one is saved, snackbar | Port |
+| No SSH key controls; Settings' SSH rows are hidden before a clone, so a wrong key could not be replaced during onboarding (step 4) | New: SSH key row with state and Manage |
+| Edit mode: snackbar, finish after 500 ms | Changed: toast, closes at once |
+| Clone: delete confirmation for an occupied folder, "Deleting…" snackbar, lone `.git` deleted silently; `deleteRecursively()` followed symlinks out of the store | Port: dialog, progress text on the screen; fix: deletion never follows symlinks |
+| Clone failure: error dialog; after a deletion the activity finished as well | Changed: error dialog, the screen stays |
+| Back during a clone | Changed: ignored while it runs |
+| Rotation recreated the activity and cancelled a clone | Changed: no recreation (`configChanges`); a theme change still cancels (`ponytail:`) |
+
+### `SshKeyImportActivity`
+
+| Behavior today | Decision |
+|---|---|
+| Invisible activity: replace confirmation when a key exists, document picker (`*/*`), toast on success, error dialog | Port into the SSH key screen's "Import SSH key" |
+| Checks: size 1 byte to 100 kB, private key markers on the first and last line (an OpenPGP private key passed); an empty size cursor crashed | Port and fix: the format must also be one sshj recognizes; a refused file keeps the current key; an empty cursor is an `IOException` |
+| Runs on the main thread | Changed: IO thread |
+
+### `SshKeyGenActivity` and `ShowSshKeyFragment`
+
+| Behavior today | Decision |
+|---|---|
+| RSA, ECDSA (default), Ed25519 with explanations | Port: segmented buttons; explanations without their bold title (`ponytail:`) |
+| "Protect with screen lock credential": enabled and on when the device is secure | Port |
+| Replace confirmation; Keep closes | Port |
+| Biometric prompt first when protected; failure becomes the error dialog | Port |
+| Button shows "Generating keys…" and is disabled | Port; back ignored while generating |
+| Public key dialog: Share (chooser), Later; both close | Port |
+| Error dialog "Message : ..."; OK closes | Port |
+| Removes the local SSH passphrase | Kept in `SshKey.delete()`, which every generation calls (the activity's second removal was redundant) |
+| `ShowSshKeyFragment`: public key dialog from Settings | Replaced: the SSH key screen shows it, with Copy and Share |
+| Generation in the activity's scope | Changed: `viewModelScope` after the prompt |
+
+### `PGPKeyImportActivity`
+
+| Behavior today | Decision |
+|---|---|
+| Invisible activity, picker at once; read on the main thread, `runBlocking` import | Changed: "Import PGP key" on the keys screen; read and import off the main thread |
+| Success dialog with the key ID | Port |
+| Key ID already imported: replace question, bytes kept for it | Port: bytes in ViewModel memory only, zeroed when no longer needed |
+| Failure dialog with the message | Port |
+| Used for a result by the entry view's and autofill's "No keys imported" prompts | Port: they start `PgpKeysRoute(Need)` through the setup intent; `RESULT_OK` after an import |
+
+### `PGPKeyListActivity`, `KeyList` and `PGPKeyListViewModel`
+
+| Behavior today | Decision |
+|---|---|
+| Rows with the user ID, delete icon with "Delete key?" Yes/No, FAB import, empty guidance with the logo | Port in the app's look: grouped surface, extended FAB, guidance text |
+| Rows show only the user ID; a key without user ID threw (`userIDs.next()`) | Changed: user ID and key ID; fix: a key without user ID shows its key ID |
+| Delete by user ID | Fix: by key ID |
+| Selection mode for onboarding | Port: `Choose` |
+| `PGPKeyListViewModel` | Replaced by `PgpKeysViewModel` |
+
+### Callers
+
+| Behavior today | Decision |
+|---|---|
+| Settings: git server, import SSH key, generate, view public key, key manager start the legacy activities | Port: setup intents (`GitServer`, `Ssh`, `SshGenerate`, `Ssh`, `PgpKeys`) |
+| List's clone prompt starts `GitServerConfigActivity` | Port: `GitServerRoute(clone = true)` |
+| `GitOperation` "No SSH key": Import, Generate, Cancel (finishes the calling activity) | Port: setup intents; Cancel only closes the dialog |
+| `GitOperation`: unusable biometric prompt shows a toast and finishes the calling activity | Fix: toast, operation cancelled |
+
+### Deleted in this step (Task 7)
+
+`ui/onboarding/activity/OnboardingActivity.kt`, `ui/onboarding/fragments/WelcomeFragment.kt`, `CloneFragment.kt`, `KeySelectionFragment.kt`, `ui/git/config/GitServerConfigActivity.kt`, `ui/sshkeygen/SshKeyImportActivity.kt`, `SshKeyGenActivity.kt`, `ShowSshKeyFragment.kt`, `ui/pgp/PGPKeyImportActivity.kt`, `PGPKeyListActivity.kt`, `PGPKeyList.kt`, `ui/dialogs/BasicBottomSheet.kt` (only `GitServerConfigActivity` used it), `util/viewmodel/PGPKeyListViewModel.kt`, ui:compose `APSAppBar.kt` (only `PGPKeyListActivity` used it), layouts `activity_onboarding.xml`, `fragment_welcome.xml`, `fragment_clone.xml`, `fragment_key_selection.xml`, `activity_git_clone.xml`, `activity_ssh_keygen.xml`, `basic_bottom_sheet.xml`, six manifest entries, and the resources lint then reports unused.
+
+**Kept for later steps:** `GitConfigActivity`, `TextInputDialog`, `activity_git_config.xml`, `GitLogActivity` and the Settings rows themselves (step 6); `CredentialFinder` and `git_credential_layout.xml` (the passphrase and password prompts of git operations); `BasePGPActivity.requireKeysExist` (autofill decryption, step 7); `FragmentExtensions.kt` and the other view-binding helpers (step 8, as the spec says). For step 6: Settings' "Delete repository" (`RepositorySettings`) empties the store with `deleteRecursively()`, which follows symlinks out of it; port it with `deleteWithoutFollowingLinks()`.
+
+## Lessons from steps 2 to 4, designed in
+
+- **Hosting.** `MainActivity` (single activity, `configChanges="orientation|screenSize"`, extends `BaseGitActivity`) hosts the new destinations; each takes its route and gets its ViewModel from `hiltViewModel()` (one per back stack entry). Only the destination on top may navigate (`nav.currentBackStackEntry?.id == entry.id`), so double taps never push twice.
+- **Recreation.** Work that needs the activity runs in `activity.lifecycleScope`: the clone (`launchGitOperation`, `promptOnErrorHandler`) and the `.gpg-id` commit (`commitChange`). Its busy flag lives in the ViewModel and is reset in `finally`, so a cancelled job never leaves a screen disabled; its success lives in the ViewModel (`GitServerViewModel.cloned`, set by a key check that itself runs in `viewModelScope`; `PgpKeysViewModel.done`) and a `LaunchedEffect` completes the navigation, so a recreation between success and navigation still moves on. Work that does not need the activity (key generation after the prompt, SSH and PGP imports) runs in `viewModelScope`, and the dialogs that report it are driven by ViewModel state.
+- **Slack compose-lint and lint.** No composable members on activities (`setupGraph` is a top-level `NavGraphBuilder` extension, the dialogs are top-level functions); every public composable has `modifier: Modifier = Modifier` as its first optional parameter; collections passed to composables are `ImmutableList`; ViewModels are default parameters of the destinations and only values and lambdas go down to screens; no `kotlin.runCatching` (`DenyListedApi`): new code catches specific exceptions; ViewModels take `DispatcherProvider`, never `Dispatchers` (`RawDispatchersUse`).
+- **Accessibility.** Every `IconButton` has a content description (back, delete); the FAB takes its description from its icon (Material 3 clears the label's semantics); the protect switch row is one `toggleable` with `Role.Switch`.
+- **Security.** No secret goes into a route, a Bundle, `rememberSaveable` or a log. SSH keys are stored as before (`SshKey`, Keystore-backed for generated keys, `KeystorePreferences` for the passphrase); the import reads the URI inside `SshKey.import`. Ed25519 keys keep coming from Bouncy Castle (do not touch `SshjConfig`). Store paths come from `PasswordRepository.getRepositoryDirectory()` only; nothing takes a path from the user. Deleting the store folder never follows symlinks (`deleteWithoutFollowingLinks`), and `.gpg-id` is never read through a symlink or beyond 64 KiB (`readGpgId`). Possible private key bytes held for the replace question are zeroed as soon as they are not needed.
+- **Real-device findings of step 4.** The clone flow now asks for the PGP key (`Need`); the SSH key can be replaced from the server screen at any time, and the import refuses OpenPGP key files.
+- **Off the main thread:** key generation, both imports, the key list (parsing every key), the store creation, the folder deletion before a clone, the after-clone key check. Every `.gpg-id` read (after-clone check, `Need` screen) is off the main thread too, through `readGpgId` (regular file, no symlink, at most 64 KiB). Small reads stay where they were: the clone target listing before the dialog, the public key file.
+- **Lint per task.** Each task's check command includes `:app:lintDebug`, so every commit passes lint on its own; Task 7 runs the full CI set.
+
+**Conventions for every task**
+
+- New files get this header:
+ ```kotlin
+ /*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+ ```
+ Modified upstream files keep their header.
+- Strings go in `app/src/main/res/values/strings.xml` and `values-it/strings.xml` ("voce" for entry).
+- Commits are GPG-signed (default config, never disable). End each message with
+ `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
+- 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: Setup model, key checks and the SSH key file check (TDD)
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt`
+- Test: `app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt`
+- Modify: `app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt`
+- Test: `app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt`
+- Modify: `crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt`
+- Modify: `crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt`
+
+- [ ] **Step 1: Failing tests for the setup model**
+
+`app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.util.settings.AuthMode
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+
+class SetupModelTest {
+
+ private val root = Files.createTempDirectory("aps").toFile()
+
+ @AfterTest
+ fun cleanUp() {
+ root.deleteRecursively()
+ }
+
+ @Test
+ fun urlProblems() {
+ assertEquals(UrlProblem.HttpsPort, urlProblem("https://example.org:8443/store.git"))
+ assertEquals(UrlProblem.SshScheme, urlProblem("git@example.org:2222/srv/store.git"))
+ assertEquals(UrlProblem.GitScheme, urlProblem("git://example.org/store.git"))
+ assertEquals(UrlProblem.GitScheme, urlProblem("git://example.org:9418/store.git"))
+ assertNull(urlProblem("ssh://git@example.org:2222/srv/store.git"))
+ assertNull(urlProblem("git@example.org:srv/store.git"))
+ assertNull(urlProblem("https://example.org/store.git"))
+ assertNull(urlProblem("http://example.org:8080/store.git"))
+ }
+
+ @Test
+ fun urlFixes() {
+ assertEquals(
+ "https://example.org/store.git",
+ fixUrl("https://example.org:8443/store.git", UrlProblem.HttpsPort),
+ )
+ assertEquals(
+ "ssh://git@example.org:2222/srv/store.git",
+ fixUrl("git@example.org:2222/srv/store.git", UrlProblem.SshScheme),
+ )
+ assertNull(fixUrl("git://example.org/store.git", UrlProblem.GitScheme))
+ }
+
+ @Test
+ fun authModesFollowTheUrl() {
+ val ssh = listOf(AuthMode.SshKey, AuthMode.Password)
+ val http = listOf(AuthMode.None, AuthMode.Password)
+ assertEquals(ssh, authModes("ssh://git@example.org/store.git"))
+ assertEquals(ssh, authModes("git@example.org:store.git"))
+ assertEquals(ssh, authModes(""))
+ assertEquals(http, authModes("https://example.org/store.git"))
+ assertEquals(http, authModes("http://example.org/store.git"))
+ assertEquals(AuthMode.None, fitAuthMode("https://example.org/x", AuthMode.SshKey))
+ assertEquals(AuthMode.Password, fitAuthMode("https://example.org/x", AuthMode.Password))
+ assertEquals(AuthMode.SshKey, fitAuthMode("git@example.org:x", AuthMode.None))
+ assertEquals(AuthMode.Password, fitAuthMode("git@example.org:x", AuthMode.Password))
+ }
+
+ @Test
+ fun cloneTargets() {
+ val store = File(root, "store")
+ assertEquals(CloneTarget.Empty, cloneTarget(store))
+ store.mkdirs()
+ assertEquals(CloneTarget.Empty, cloneTarget(store))
+ File(store, ".git").mkdirs()
+ assertEquals(CloneTarget.OnlyGit, cloneTarget(store))
+ File(store, "a.gpg").writeText("x")
+ assertEquals(CloneTarget.Occupied, cloneTarget(store))
+ File(store, ".git").deleteRecursively()
+ assertEquals(CloneTarget.Occupied, cloneTarget(store))
+ }
+
+ @Test
+ fun storeRecipientsSkipBlankAndInvalidLines() {
+ val ids =
+ storeRecipients(
+ "4882E8257A3700D43119B8D3F8993A789CDFB014\n\n Alice <alice@example.org> \nnot an id\n"
+ )
+ assertEquals(listOf("f8993a789cdfb014", "alice@example.org"), ids.map(PGPIdentifier::toString))
+ assertEquals(emptyList(), storeRecipients(""))
+ }
+
+ @Test
+ fun gpgIdTextIsUppercaseKeyIdsOnePerLine() {
+ val ids =
+ listOf("f8993a789cdfb014", "0x79E8208280490C77").map {
+ assertIs<PGPIdentifier.KeyId>(PGPIdentifier.fromString(it))
+ }
+ val text = gpgIdText(ids)
+ assertEquals("F8993A789CDFB014\n79E8208280490C77\n", text)
+ // pass and this app read it back as the same key IDs.
+ assertEquals(ids, storeRecipients(text))
+ }
+
+ @Test
+ fun gpgIdIsReadOnlyFromASmallRegularFile() {
+ assertNull(readGpgId(root))
+ File(root, ".gpg-id").writeText("F8993A789CDFB014\n")
+ assertEquals("F8993A789CDFB014\n", readGpgId(root))
+
+ val linked = File(root, "linked").apply { mkdirs() }
+ val outside = File(root, "outside").apply { writeText("F8993A789CDFB014\n") }
+ Files.createSymbolicLink(File(linked, ".gpg-id").toPath(), outside.toPath())
+ assertNull(readGpgId(linked))
+
+ val big = File(root, "big").apply { mkdirs() }
+ File(big, ".gpg-id").writeText("A".repeat(64 * 1024 + 1))
+ assertNull(readGpgId(big))
+
+ val folder = File(root, "folder").apply { File(this, ".gpg-id").mkdirs() }
+ assertNull(readGpgId(folder))
+ }
+}
+```
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.setup.SetupModelTest`
+Expected: compilation FAILS with `Unresolved reference 'UrlProblem'` (and the other new names).
+
+- [ ] **Step 2: The setup model**
+
+`app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.util.settings.AuthMode
+import java.io.File
+import java.io.IOException
+import java.nio.file.Files
+import java.nio.file.LinkOption
+import java.util.Locale
+
+/** Why a repository URL is questioned before it is saved, as the old server screen checked it. */
+enum class UrlProblem {
+ /** An HTTPS URL with a custom port, which the old screen did not support. */
+ HttpsPort,
+ /** An scp-style SSH URL with a port: without `ssh://` the port is read as part of the path. */
+ SshScheme,
+ /** The unencrypted `git://` protocol. */
+ GitScheme,
+}
+
+private val PORT = ":[0-9]{1,5}/".toRegex()
+
+private fun isHttp(url: String) = url.startsWith("http://") || url.startsWith("https://")
+
+/** What is wrong with [url], or null. `git://` is checked first: a port does not make it SSH. */
+fun urlProblem(url: String): UrlProblem? =
+ when {
+ url.startsWith("git://") -> UrlProblem.GitScheme
+ !url.contains(PORT) -> null
+ url.startsWith("https://") -> UrlProblem.HttpsPort
+ url.startsWith("http://") || url.startsWith("ssh://") -> null
+ else -> UrlProblem.SshScheme
+ }
+
+/** The old screen's fix for [problem] in [url], or null when there is none (`git://`). */
+fun fixUrl(url: String, problem: UrlProblem): String? =
+ when (problem) {
+ UrlProblem.HttpsPort -> url.replace(PORT, "/")
+ UrlProblem.SshScheme -> "ssh://$url"
+ UrlProblem.GitScheme -> null
+ }
+
+/** The authentication modes offered for [url], the default first. */
+fun authModes(url: String): List<AuthMode> =
+ if (isHttp(url)) listOf(AuthMode.None, AuthMode.Password)
+ else listOf(AuthMode.SshKey, AuthMode.Password)
+
+/** [current] when [url] still allows it, the default for [url] otherwise. */
+fun fitAuthMode(url: String, current: AuthMode): AuthMode =
+ authModes(url).let { modes -> if (current in modes) current else modes.first() }
+
+/** What a clone into the store folder finds there. */
+enum class CloneTarget {
+ /** Missing or empty. */
+ Empty,
+ /** Only a `.git` folder, from a store created and abandoned: deleted without asking. */
+ OnlyGit,
+ /** Anything else: deleted after a confirmation. */
+ Occupied,
+}
+
+fun cloneTarget(dir: File): CloneTarget {
+ val files = dir.listFiles().orEmpty()
+ return when {
+ files.isEmpty() -> CloneTarget.Empty
+ files.size == 1 && files[0].name == ".git" -> CloneTarget.OnlyGit
+ else -> CloneTarget.Occupied
+ }
+}
+
+/** The recipients named by a `.gpg-id` file's [text]; blank and unparsable lines are skipped. */
+fun storeRecipients(text: String): List<PGPIdentifier> =
+ text.lines().map(String::trim).filter(String::isNotEmpty).mapNotNull(PGPIdentifier::fromString)
+
+/**
+ * A `.gpg-id` naming [keys]: each 16-hex long key ID in uppercase on a line of its own, the format
+ * of a desktop store made with `pass init <key ID>`.
+ */
+fun gpgIdText(keys: List<PGPIdentifier.KeyId>): String =
+ keys.joinToString("") { "${it.toString().uppercase(Locale.ROOT)}\n" }
+
+private const val MAX_GPG_ID_BYTES = 64 * 1024
+
+/**
+ * The text of [dir]'s `.gpg-id`, or null when it is missing, not a regular file, a symlink, larger
+ * than 64 KiB or unreadable. Blocking: call it off the main thread.
+ */
+fun readGpgId(dir: File): String? {
+ val file = File(dir, ".gpg-id")
+ val path = file.toPath()
+ return try {
+ if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) null
+ else if (Files.size(path) > MAX_GPG_ID_BYTES) null
+ else file.readText()
+ } catch (_: IOException) {
+ null
+ }
+}
+```
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.setup.SetupModelTest`
+Expected: PASS (7 tests).
+
+- [ ] **Step 3: Failing test for the SSH key file check**
+
+`app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.util.git.sshj
+
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class SshKeyFileTest {
+
+ private fun pem(type: String) =
+ listOf("-----BEGIN $type-----", "b3BlbnNzaC1rZXktdjEAAAAABG5vbmU", "-----END $type-----")
+
+ @Test
+ fun acceptsSshPrivateKeys() {
+ assertTrue(isSshPrivateKey(pem("OPENSSH PRIVATE KEY")))
+ assertTrue(isSshPrivateKey(pem("RSA PRIVATE KEY")))
+ assertTrue(isSshPrivateKey(pem("EC PRIVATE KEY")))
+ }
+
+ @Test
+ fun refusesOtherFiles() {
+ // The step 4 finding: an OpenPGP key block has the same markers and was accepted.
+ assertFalse(isSshPrivateKey(pem("PGP PRIVATE KEY BLOCK")))
+ assertFalse(isSshPrivateKey(pem("PGP PUBLIC KEY BLOCK")))
+ assertFalse(isSshPrivateKey(listOf("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA test@example.org")))
+ assertFalse(isSshPrivateKey(emptyList()))
+ assertFalse(isSshPrivateKey(pem("OPENSSH PRIVATE KEY").dropLast(1)))
+ }
+}
+```
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.util.git.sshj.SshKeyFileTest`
+Expected: compilation FAILS with `Unresolved reference 'isSshPrivateKey'`.
+
+- [ ] **Step 4: The check, used by the import**
+
+In `app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt`, add the imports
+
+```kotlin
+import net.schmizz.sshj.userauth.keyprovider.KeyFormat
+import net.schmizz.sshj.userauth.keyprovider.KeyProviderUtil
+```
+
+add, after `ed25519KeyPair`:
+
+```kotlin
+/**
+ * True when [lines] look like an SSH private key sshj can load: private key markers on the first
+ * and last line, in a format sshj recognizes. An OpenPGP private key block has the same markers
+ * and is refused by the format check.
+ */
+internal fun isSshPrivateKey(lines: List<String>): Boolean {
+ if (
+ lines.size < 2 ||
+ !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
+ !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
+ )
+ return false
+ return try {
+ KeyProviderUtil.detectKeyFileFormat(lines.joinToString("\n"), false) != KeyFormat.Unknown
+ } catch (_: IOException) {
+ false
+ }
+}
+```
+
+then, in `SshKey.import`, replace
+
+```kotlin
+ // Cursor returns only a single row.
+ cursor.moveToFirst()
+ cursor.getInt(0)
+```
+
+with
+
+```kotlin
+ // A single row; none when the provider cannot tell: the IOException below.
+ if (cursor.moveToFirst()) cursor.getInt(0) else null
+```
+
+(an empty cursor threw `CursorIndexOutOfBoundsException`, which `SshKeyViewModel.import` does
+not catch, so the app crashed; now it ends in the existing `?: throw IOException(...)`), and
+replace
+
+```kotlin
+ // The file must have more than 2 lines, and the first and last line must have private key
+ // markers.
+ if (
+ lines.size < 2 ||
+ !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
+ !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
+ )
+ throw IllegalArgumentException(
+```
+
+with
+
+```kotlin
+ // Private key markers and a format sshj can read; the current key stays when this fails.
+ if (!isSshPrivateKey(lines))
+ throw IllegalArgumentException(
+```
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.util.git.sshj.SshKeyFileTest --tests app.passwordstore.util.git.sshj.Ed25519KeyPairTest`
+Expected: PASS (2 + 2 tests).
+
+- [ ] **Step 5: Failing tests for the key checks**
+
+In `crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt`, add the imports
+
+```kotlin
+import app.passwordstore.crypto.KeyUtils.isSecretKey
+import app.passwordstore.crypto.KeyUtils.tryGetEmail
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+```
+
+and, inside the class, after `isKeyUsable`:
+
+```kotlin
+ @Test
+ fun secretKeysAreTold() {
+ assertTrue(isSecretKey(PGPKey(AllKeys.ARMORED_SEC.keyMaterial)))
+ assertTrue(isSecretKey(PGPKey(AllKeys.GNUPG_OCB_SEC.keyMaterial)))
+ assertFalse(isSecretKey(PGPKey(AllKeys.ARMORED_PUB.keyMaterial)))
+ assertFalse(isSecretKey(PGPKey("not a key".encodeToByteArray())))
+ }
+
+ @Test
+ fun firstUserIdIsReturned() {
+ val key = PGPKey(getArmoredSecretKeyWithMultipleIdentities())
+ assertNotNull(tryGetEmail(key))
+ assertEquals(null, tryGetEmail(PGPKey("not a key".encodeToByteArray())))
+ }
+```
+
+Run: `./gradlew :crypto:pgpainless:test --tests app.passwordstore.crypto.KeyUtilsTest`
+Expected: compilation FAILS with `Unresolved reference 'isSecretKey'`.
+
+(No fixture has a key without user ID, so the `next()` fix below is covered by review, not by a
+test: `firstOrNull()` cannot throw where `next()` did.)
+
+- [ ] **Step 6: The key checks**
+
+In `crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt`, add the import
+
+```kotlin
+import org.bouncycastle.openpgp.PGPSecretKeyRing
+```
+
+replace
+
+```kotlin
+ public fun tryGetEmail(key: PGPKey): UserId? {
+ val keyRing = tryParseKeyring(key) ?: return null
+ return UserId(keyRing.publicKey.userIDs.next())
+ }
+```
+
+with
+
+```kotlin
+ public fun tryGetEmail(key: PGPKey): UserId? {
+ val keyRing = tryParseKeyring(key) ?: return null
+ // A key may carry no user ID at all: next() threw NoSuchElementException there.
+ return keyRing.publicKey.userIDs.asSequence().firstOrNull()?.let(::UserId)
+ }
+
+ /** True when [key] parses as a secret key ring, the only kind that can decrypt. */
+ public fun isSecretKey(key: PGPKey): Boolean = tryParseKeyring(key) is PGPSecretKeyRing
+```
+
+(The app has no direct dependency on Bouncy Castle's OpenPGP classes, so the secret-key test
+lives here, next to `tryParseKeyring`.)
+
+Run: `./gradlew :crypto:pgpainless:test --tests app.passwordstore.crypto.KeyUtilsTest`
+Expected: PASS (4 tests).
+
+- [ ] **Step 7: Checks and commit**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:testDebugUnitTest --tests 'app.passwordstore.ui.setup.*' \
+ --tests 'app.passwordstore.util.git.sshj.*' :crypto:pgpainless:test :app:assembleDebug \
+ :app:lintDebug
+git add app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt \
+ app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt \
+ app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt \
+ app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt \
+ crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt \
+ crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt
+git commit -m "feat(app): add the setup model and stricter key checks
+
+URL checks and fixes, authentication modes per URL, the clone target,
+the store's recipients, a size- and symlink-checked .gpg-id read and
+the .gpg-id text for a new store (uppercase long key IDs), as pure
+functions for the Compose setup screens. The SSH key import now also
+requires a format sshj recognizes (an OpenPGP private key block
+matched the old marker check and was imported as the SSH key) and no
+longer crashes on an empty size cursor. KeyUtils tells secret keys
+apart and no longer throws on a key without user ID.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+Expected: `BUILD SUCCESSFUL`, one signed commit.
+
+---
+
+### Task 2: Setup ViewModels
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt`
+- Create: `app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt`
+- Create: `app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt`
+
+- [ ] **Step 1: SSH key ViewModel**
+
+`app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import android.net.Uri
+import androidx.annotation.StringRes
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.R
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.git.sshj.SshKey
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.io.IOException
+import java.security.GeneralSecurityException
+import java.security.ProviderException
+import javax.inject.Inject
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** The key types the generation screen offers, in the old screen's order. */
+enum class SshKeyType(@StringRes val label: Int, @StringRes val explanation: Int) {
+ Rsa(R.string.ssh_keygen_label_rsa, R.string.ssh_keygen_explanation_rsa),
+ Ecdsa(R.string.ssh_keygen_label_ecdsa, R.string.ssh_keygen_explanation_ecdsa),
+ Ed25519(R.string.ssh_keygen_label_ed25519, R.string.ssh_keygen_explanation_ed25519),
+}
+
+/** The current SSH key's state, as a string resource. */
+@StringRes
+fun sshKeySummary(): Int =
+ when {
+ !SshKey.exists -> R.string.ssh_key_state_none
+ SshKey.canShowSshPublicKey -> R.string.ssh_key_state_generated
+ else -> R.string.ssh_key_state_imported
+ }
+
+/**
+ * The SSH key screens' work: import a key file, generate a key. Both run in [viewModelScope] (no
+ * activity needed), so their outcome survives a recreation; the screens show it from [generated],
+ * [imported] and [failure]. [version] changes with the key, so screens re-read [SshKey].
+ */
+@HiltViewModel
+class SshKeyViewModel @Inject constructor(private val dispatcherProvider: DispatcherProvider) :
+ ViewModel() {
+
+ var type by mutableStateOf(SshKeyType.Ecdsa)
+
+ /** The "protect with screen lock" switch; only effective when the device is secure. */
+ var protect by mutableStateOf(true)
+
+ var busy by mutableStateOf(false)
+ private set
+
+ var version by mutableIntStateOf(0)
+ private set
+
+ /** Set after a successful import, until the screen showed its toast. */
+ var imported by mutableStateOf(false)
+ private set
+
+ /** The new public key after a generation. */
+ var generated by mutableStateOf<String?>(null)
+ private set
+
+ /** Why the last import or generation failed. */
+ var failure by mutableStateOf<String?>(null)
+ private set
+
+ /** Imports the private key at [uri]; [SshKey.import] reads it and keeps the old key on refusal. */
+ fun import(uri: Uri) {
+ if (busy) return
+ busy = true
+ viewModelScope.launch {
+ try {
+ withContext(dispatcherProvider.io()) { SshKey.import(uri) }
+ imported = true
+ version++
+ } catch (e: IOException) {
+ failure = e.message.orEmpty()
+ } catch (e: IllegalArgumentException) {
+ failure = e.message.orEmpty()
+ } catch (e: SecurityException) {
+ failure = e.message.orEmpty()
+ } finally {
+ busy = false
+ }
+ }
+ }
+
+ /** Generates a key of [type], replacing the current one; the prompt already ran if needed. */
+ fun generate(requireAuthentication: Boolean) {
+ if (busy) return
+ busy = true
+ val type = type
+ viewModelScope.launch {
+ try {
+ generated =
+ withContext(dispatcherProvider.io()) {
+ when (type) {
+ SshKeyType.Rsa ->
+ SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication)
+ SshKeyType.Ecdsa ->
+ SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication)
+ SshKeyType.Ed25519 -> SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication)
+ }
+ SshKey.sshPublicKey.orEmpty()
+ }
+ version++
+ } catch (e: GeneralSecurityException) {
+ failure = e.message.orEmpty()
+ } catch (e: ProviderException) {
+ failure = e.message.orEmpty()
+ } catch (e: IOException) {
+ failure = e.message.orEmpty()
+ } finally {
+ busy = false
+ }
+ }
+ }
+
+ /** Reports a failure found before generating (the screen lock prompt). */
+ fun fail(message: String) {
+ failure = message
+ }
+
+ fun dismissFailure() {
+ failure = null
+ }
+
+ fun announced() {
+ imported = false
+ }
+}
+```
+
+- [ ] **Step 2: Git server ViewModel**
+
+`app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.R
+import app.passwordstore.crypto.KeyUtils
+import app.passwordstore.crypto.PGPKeyManager
+import app.passwordstore.ui.list.deleteWithoutFollowingLinks
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.settings.GitSettings
+import com.github.michaelbull.result.get
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.io.File
+import javax.inject.Inject
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * The git server screen's state: URL and mode being edited, the clone's [progress] and outcome
+ * ([cloned]), the host key and SSH key state. The clone itself runs in the destination (it needs
+ * the activity); this ViewModel keeps what must survive a recreation.
+ */
+@HiltViewModel
+class GitServerViewModel
+@Inject
+constructor(
+ private val gitSettings: GitSettings,
+ private val keyManager: PGPKeyManager,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel() {
+
+ var url by mutableStateOf(gitSettings.url.orEmpty())
+ private set
+
+ var authMode by mutableStateOf(fitAuthMode(gitSettings.url.orEmpty(), gitSettings.authMode))
+
+ /** What a running clone does, as a string resource; null when idle. */
+ var progress by mutableStateOf<Int?>(null)
+
+ /** Null until a clone succeeded; then whether the store still needs a PGP key imported. */
+ var cloned by mutableStateOf<Boolean?>(null)
+ private set
+
+ var hasHostKey by mutableStateOf(false)
+ private set
+
+ var sshKey by mutableIntStateOf(R.string.ssh_key_state_none)
+ private set
+
+ init {
+ refresh()
+ }
+
+ /** Re-reads what other screens change: the saved host key and the SSH key. */
+ fun refresh() {
+ hasHostKey = gitSettings.hasSavedHostKey()
+ sshKey = sshKeySummary()
+ }
+
+ fun changeUrl(value: String) {
+ url = value
+ if (value.isNotEmpty()) authMode = fitAuthMode(value.trim(), authMode)
+ }
+
+ fun clearHostKey() {
+ gitSettings.clearSavedHostKey()
+ hasHostKey = false
+ }
+
+ /** Validates and stores URL and mode (unchanged rules in [GitSettings]). */
+ fun save(): GitSettings.UpdateConnectionSettingsResult =
+ gitSettings.updateConnectionSettingsIfValid(newAuthMode = authMode, newUrl = url.trim())
+
+ /**
+ * Empties the store folder at [root] for a clone, unless it is empty already. Never follows a
+ * symlink: `deleteRecursively()` would, and a `store/link -> ../keys` link would take the PGP
+ * keys with it.
+ */
+ suspend fun clearStore(root: File) {
+ if (withContext(dispatcherProvider.io()) { cloneTarget(root) } == CloneTarget.Empty) return
+ progress = R.string.delete_directory_progress_text
+ withContext(dispatcherProvider.io()) { root.deleteWithoutFollowingLinks() }
+ progress = R.string.git_clone_progress
+ }
+
+ /** Set while [finishClone]'s key check runs: the clone's [progress] stays up until it is done. */
+ private var checking = false
+
+ /**
+ * After a successful clone into [root], checks in [viewModelScope] whether its key is missing and
+ * records the answer in [cloned]: no recipient of the root `.gpg-id` is an imported secret key
+ * (without recipients: no secret key is imported at all). A public key alone cannot decrypt.
+ * In [viewModelScope], so a recreation during the check cannot lose the answer.
+ */
+ fun finishClone(root: File) {
+ checking = true
+ viewModelScope.launch {
+ try {
+ cloned =
+ withContext(dispatcherProvider.io()) {
+ val ids = readGpgId(root)?.let(::storeRecipients).orEmpty()
+ if (ids.isEmpty()) keyManager.getAllKeys().get().orEmpty().none(KeyUtils::isSecretKey)
+ else ids.none { id -> keyManager.getKeyById(id).get()?.let(KeyUtils::isSecretKey) == true }
+ }
+ } finally {
+ checking = false
+ progress = null
+ }
+ }
+ }
+
+ /** The clone's coroutine ended (done, failed or cancelled): free the screen, re-read state. */
+ fun cloneEnded() {
+ if (!checking) progress = null
+ refresh()
+ }
+}
+```
+
+- [ ] **Step 3: PGP keys ViewModel**
+
+`app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import android.content.Context
+import android.net.Uri
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.crypto.KeyUtils
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.crypto.PGPKey
+import app.passwordstore.crypto.PGPKeyManager
+import app.passwordstore.crypto.errors.KeyAlreadyExistsException
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.util.coroutines.DispatcherProvider
+import com.github.michaelbull.result.onSuccess
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.io.File
+import java.io.IOException
+import javax.inject.Inject
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import logcat.LogPriority.ERROR
+import logcat.asLog
+import logcat.logcat
+import org.eclipse.jgit.api.errors.GitAPIException
+import org.eclipse.jgit.api.errors.JGitInternalException
+
+/** One imported key: its first user ID, when it has one, and its long key ID. */
+@Immutable data class KeyRow(val userId: String?, val keyId: PGPIdentifier.KeyId)
+
+/** The outcome of a PGP key import, shown until dismissed. */
+sealed interface KeyImport {
+ data class Imported(val keyId: String) : KeyImport
+
+ /** A key with the same ID is imported already: the screen asks whether to replace it. */
+ data object Exists : KeyImport
+
+ data class Failed(val message: String?) : KeyImport
+}
+
+/**
+ * The PGP keys screen's state and work: the imported [keys], import with the replace question,
+ * deletion by key ID, and the creation of a new local store for a chosen key. Holds no Context;
+ * calls take one and use the application context.
+ */
+@HiltViewModel
+class PgpKeysViewModel
+@Inject
+constructor(
+ private val keyManager: PGPKeyManager,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel() {
+
+ var keys: ImmutableList<KeyRow> by mutableStateOf(persistentListOf())
+ private set
+
+ /** Set while an import or a store creation runs: further taps wait. */
+ var busy by mutableStateOf(false)
+
+ var importResult by mutableStateOf<KeyImport?>(null)
+ private set
+
+ /** Set once a new store is created and committed: the screen moves on. */
+ var done by mutableStateOf(false)
+ private set
+
+ /** The root `.gpg-id` recipients, for the `Need` screen; see [loadRecipients]. */
+ var recipients: ImmutableList<String> by mutableStateOf(persistentListOf())
+ private set
+
+ /**
+ * The picked file while the replace question is open. Memory only, zeroed as soon as it is not
+ * needed ([dropPending]): it may be a private key.
+ */
+ private var pending: ByteArray? = null
+
+ init {
+ updateKeySet()
+ }
+
+ /** Reads the recipients of the store at [root] off the main thread. */
+ fun loadRecipients(root: File) {
+ viewModelScope.launch {
+ recipients =
+ withContext(dispatcherProvider.io()) {
+ readGpgId(root)?.let(::storeRecipients).orEmpty().map(PGPIdentifier::toString)
+ }
+ .toPersistentList()
+ }
+ }
+
+ private fun dropPending() {
+ pending?.fill(0)
+ pending = null
+ }
+
+ fun updateKeySet() {
+ viewModelScope.launch {
+ keyManager.getAllKeys().onSuccess { all ->
+ keys = withContext(dispatcherProvider.default()) { all.mapNotNull(::row) }.toPersistentList()
+ }
+ }
+ }
+
+ private fun row(key: PGPKey): KeyRow? {
+ val id = KeyUtils.tryGetId(key) ?: return null
+ return KeyRow(KeyUtils.tryGetEmail(key)?.email, id)
+ }
+
+ fun deleteKey(row: KeyRow) {
+ viewModelScope.launch {
+ keyManager.removeKey(row.keyId)
+ updateKeySet()
+ }
+ }
+
+ /** Reads the key file at [uri] and imports it. */
+ fun import(uri: Uri, context: Context) {
+ if (busy) return
+ busy = true
+ val resolver = context.applicationContext.contentResolver
+ viewModelScope.launch {
+ try {
+ val bytes =
+ withContext(dispatcherProvider.io()) {
+ resolver.openInputStream(uri)?.use { it.readBytes() }
+ }
+ if (bytes == null) importResult = KeyImport.Failed(null) else add(bytes, replace = false)
+ } catch (e: IOException) {
+ importResult = KeyImport.Failed(e.message)
+ } catch (e: SecurityException) {
+ importResult = KeyImport.Failed(e.message)
+ } finally {
+ busy = false
+ }
+ }
+ }
+
+ /** Imports the key the replace question was about, over the one with its ID. */
+ fun replace() {
+ val bytes = pending ?: return
+ pending = null
+ importResult = null
+ busy = true
+ viewModelScope.launch {
+ try {
+ add(bytes, replace = true)
+ } finally {
+ busy = false
+ }
+ }
+ }
+
+ fun dismissImport() {
+ dropPending()
+ importResult = null
+ }
+
+ /** Imports [bytes]; they are zeroed afterwards unless the replace question keeps them. */
+ private suspend fun add(bytes: ByteArray, replace: Boolean) {
+ val result = keyManager.addKey(PGPKey(bytes), replace)
+ if (!replace && result.isErr && result.error is KeyAlreadyExistsException) {
+ pending = bytes
+ importResult = KeyImport.Exists
+ return
+ }
+ // The ID is read before the bytes are zeroed: the returned key shares them.
+ importResult =
+ if (result.isOk) {
+ val id = withContext(dispatcherProvider.default()) { KeyUtils.tryGetId(result.value) }
+ KeyImport.Imported(id?.toString().orEmpty())
+ } else KeyImport.Failed(result.error.message)
+ bytes.fill(0)
+ if (result.isOk) updateKeySet()
+ }
+
+ /**
+ * Creates the local store at [root] for [row]: a git repository and a `.gpg-id` naming the key by
+ * its uppercase long key ID ([gpgIdText]). Returns null, or why it failed.
+ */
+ suspend fun createStore(root: File, row: KeyRow): String? =
+ withContext(dispatcherProvider.io()) {
+ try {
+ check(root.isDirectory || root.mkdirs()) { "Could not create $root" }
+ PasswordRepository.createRepository(root)
+ File(root, ".gpg-id").writeText(gpgIdText(listOf(row.keyId)))
+ null
+ } catch (e: IOException) {
+ failed(root, e)
+ } catch (e: GitAPIException) {
+ failed(root, e)
+ } catch (e: JGitInternalException) {
+ failed(root, e)
+ } catch (e: IllegalStateException) {
+ failed(root, e)
+ }
+ }
+
+ private fun failed(root: File, e: Exception): String {
+ logcat(ERROR) { e.asLog() }
+ // Only removes the folder while it is still empty, as the old onboarding did.
+ root.delete()
+ return e.message.orEmpty()
+ }
+
+ fun markDone() {
+ done = true
+ }
+
+ override fun onCleared() {
+ dropPending()
+ }
+}
+```
+
+- [ ] **Step 4: Strings the ViewModels use**
+
+`sshKeySummary` and `clearStore` refer to strings added in Task 3. Add these four now, so this
+task builds on its own (Task 3 adds the rest next to them).
+
+In `app/src/main/res/values/strings.xml`, before `</resources>`:
+
+```xml
+
+ <!-- Compose setup -->
+ <string name="ssh_key_state_none">No SSH key yet</string>
+ <string name="ssh_key_state_imported">Imported private key</string>
+ <string name="ssh_key_state_generated">Generated on this device</string>
+ <string name="git_clone_progress">Cloning…</string>
+```
+
+In `app/src/main/res/values-it/strings.xml`, before `</resources>`:
+
+```xml
+
+ <!-- Compose setup -->
+ <string name="ssh_key_state_none">Ancora nessuna chiave SSH</string>
+ <string name="ssh_key_state_imported">Chiave privata importata</string>
+ <string name="ssh_key_state_generated">Generata su questo dispositivo</string>
+ <string name="git_clone_progress">Clonazione…</string>
+```
+
+- [ ] **Step 5: Checks and commit**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:assembleDebug :app:lintDebug
+git add app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt \
+ app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt \
+ app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt \
+ app/src/main/res/values/strings.xml app/src/main/res/values-it/strings.xml
+git commit -m "feat(app): add the setup ViewModels
+
+Git server (URL, mode, clone state, the after-clone key check), SSH
+key (import and generation off the main thread, outcome kept across
+recreation) and PGP keys (rows with key IDs, import with the replace
+question, delete by key ID, creation of a local store).
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+Expected: `BUILD SUCCESSFUL`.
+
+---
+
+### Task 3: Strings, welcome and git server screens
+
+**Files:**
+- Modify: `app/src/main/res/values/strings.xml`, `app/src/main/res/values-it/strings.xml`
+- Create: `app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt`
+- Create: `app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt`
+
+- [ ] **Step 1: Strings**
+
+In `app/src/main/res/values/strings.xml`, after `<string name="git_clone_progress">Cloning…</string>`:
+
+```xml
+ <string name="connection_mode_none">None</string>
+ <string name="ssh_key_manage">Manage</string>
+```
+
+In `app/src/main/res/values-it/strings.xml`, after `<string name="git_clone_progress">Clonazione…</string>`:
+
+```xml
+ <string name="connection_mode_none">Nessuna</string>
+ <string name="ssh_key_manage">Gestisci</string>
+```
+
+Each task adds only the strings its own code uses, so its `:app:lintDebug` finds no unused
+resource (`UnusedResources` is an error: lint runs with `warningsAsErrors`). Task 4 adds one
+more, Task 5 three; everything else the setup screens show reuses existing strings in both
+languages (titles, buttons, dialogs of the old screens).
+
+- [ ] **Step 2: Welcome screen and the shared dialog**
+
+`app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingLargest
+
+/** First start without a store: clone one, create a local one, or open the settings. */
+@Composable
+fun WelcomeScreen(
+ onClone: () -> Unit,
+ onCreate: () -> Unit,
+ onSettings: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(modifier = modifier) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(SpacingLargest),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painterResource(R.drawable.ic_launcher_foreground),
+ contentDescription = null,
+ modifier = Modifier.size(144.dp),
+ )
+ Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineMedium)
+ Text(
+ stringResource(R.string.select_repo_type_text),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ )
+ Button(onClick = onClone, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.clone_remote_repo))
+ }
+ OutlinedButton(onClick = onCreate, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.create_local_repo))
+ }
+ TextButton(onClick = onSettings) { Text(stringResource(R.string.action_settings)) }
+ }
+ }
+}
+
+/**
+ * A plain dialog for the setup screens: optional [title], [text], [confirm] and, when [dismiss] is
+ * given, a second button running [onDismissButton] (by default the same as dismissing).
+ */
+@Composable
+internal fun SetupDialog(
+ text: String,
+ confirm: String,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+ title: String? = null,
+ dismiss: String? = null,
+ onDismissButton: () -> Unit = onDismiss,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ title = if (title != null) { { Text(title) } } else null,
+ text = { Text(text) },
+ confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } },
+ dismissButton =
+ if (dismiss != null) {
+ { TextButton(onClick = onDismissButton) { Text(dismiss) } }
+ } else null,
+ )
+}
+```
+
+- [ ] **Step 3: Git server screen and destination**
+
+`app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.git.base.BaseGitActivity.GitOp
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.util.extensions.snackbar
+import app.passwordstore.util.settings.AuthMode
+import app.passwordstore.util.settings.GitSettings.UpdateConnectionSettingsResult
+import app.passwordstore.util.settings.Protocol
+import com.github.michaelbull.result.fold
+import com.google.android.material.snackbar.Snackbar
+import kotlinx.coroutines.launch
+
+/**
+ * Server URL and authentication mode; the SSH key's state ([sshKey], a string resource) with
+ * Manage while the mode is SSH key; "Clear saved host key" when one is saved. [progress] (a
+ * string resource) shows while a clone runs, and disables everything.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GitServerScreen(
+ clone: Boolean,
+ url: String,
+ authMode: AuthMode,
+ @StringRes sshKey: Int,
+ hasHostKey: Boolean,
+ @StringRes progress: Int?,
+ onUrlChange: (String) -> Unit,
+ onAuthModeChange: (AuthMode) -> Unit,
+ onSshKey: () -> Unit,
+ onClearHostKey: () -> Unit,
+ onSubmit: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val idle = progress == null
+ val modes = remember(url) { authModes(url.trim()) }
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.title_activity_git_clone)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ ) {
+ OutlinedTextField(
+ value = url,
+ onValueChange = onUrlChange,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = idle,
+ label = { Text(stringResource(R.string.server_url)) },
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ autoCorrectEnabled = false,
+ imeAction = ImeAction.Done,
+ ),
+ )
+ Text(stringResource(R.string.connection_mode), style = MaterialTheme.typography.labelLarge)
+ SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
+ modes.forEachIndexed { index, mode ->
+ SegmentedButton(
+ selected = mode == authMode,
+ onClick = { onAuthModeChange(mode) },
+ shape = SegmentedButtonDefaults.itemShape(index, modes.size),
+ enabled = idle,
+ ) {
+ Text(stringResource(mode.label()))
+ }
+ }
+ }
+ if (authMode == AuthMode.SshKey)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ stringResource(R.string.connection_mode_ssh_key),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(stringResource(sshKey))
+ }
+ TextButton(onClick = onSshKey, enabled = idle) {
+ Text(stringResource(R.string.ssh_key_manage))
+ }
+ }
+ if (hasHostKey)
+ OutlinedButton(onClick = onClearHostKey, enabled = idle) {
+ Text(stringResource(R.string.clear_saved_host_key))
+ }
+ if (progress != null) {
+ LinearProgressIndicator(Modifier.fillMaxWidth())
+ Text(stringResource(progress))
+ }
+ Button(onClick = onSubmit, modifier = Modifier.fillMaxWidth(), enabled = idle) {
+ Text(stringResource(if (clone) R.string.clone_button else R.string.crypto_save))
+ }
+ }
+ }
+}
+
+@StringRes
+private fun AuthMode.label(): Int =
+ when (this) {
+ AuthMode.SshKey -> R.string.connection_mode_ssh_key
+ AuthMode.Password -> R.string.connection_mode_basic_authentication
+ AuthMode.None -> R.string.connection_mode_none
+ }
+
+private sealed interface ServerDialog {
+ data class Url(val problem: UrlProblem) : ServerDialog
+
+ data class Username(val protocol: Protocol) : ServerDialog
+
+ data object DeleteStore : ServerDialog
+}
+
+/**
+ * The git server screen: wires [GitServerScreen] to the [viewModel], the URL checks, the save and,
+ * with [clone], the clone into the store folder. [onCloned] gets whether the store still needs a
+ * PGP key. A failed clone stays here, so URL or SSH key can be fixed and the clone retried.
+ */
+@Composable
+fun GitServerDestination(
+ activity: MainActivity,
+ clone: Boolean,
+ onClose: () -> Unit,
+ onSshKey: () -> Unit,
+ onCloned: (keyNeeded: Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: GitServerViewModel = hiltViewModel(),
+) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ val keyboard = LocalSoftwareKeyboardController.current
+ var dialog by remember { mutableStateOf<ServerDialog?>(null) }
+
+ // Back from the SSH key screen (or from an instance it opened) with another key.
+ LifecycleResumeEffect(Unit) {
+ viewModel.refresh()
+ onPauseOrDispose {}
+ }
+ // A finished clone moves on from here, also when the screen was recreated in between.
+ val cloned = viewModel.cloned
+ LaunchedEffect(cloned) { if (cloned != null) onCloned(cloned) }
+ // A running clone holds the screen: leaving would cancel it half way.
+ BackHandler(enabled = viewModel.progress != null) {}
+
+ fun say(message: String) {
+ activity.snackbar(message = message, length = Snackbar.LENGTH_LONG)
+ }
+
+ // ponytail: a theme or locale change cancels the clone with the activity's scope, like every
+ // git operation of the app; the finally block frees the screen, and a new clone first offers to
+ // delete what was left. Once the clone succeeded, the key check runs in the ViewModel's scope
+ // (finishClone), so a recreation from then on loses nothing.
+ fun startClone() {
+ viewModel.progress = R.string.git_clone_progress
+ activity.lifecycleScope.launch {
+ try {
+ // A folder that could not be emptied is reported as such, not as a vague clone error.
+ viewModel.clearStore(root)?.let { failure ->
+ say(activity.getString(failure))
+ return@launch
+ }
+ activity
+ .launchGitOperation(GitOp.CLONE)
+ .fold(
+ success = { viewModel.finishClone(root) },
+ failure = { activity.promptOnErrorHandler(it) },
+ )
+ } finally {
+ viewModel.cloneEnded()
+ }
+ }
+ }
+
+ fun submit() {
+ if (viewModel.progress != null) return
+ keyboard?.hide()
+ val problem = urlProblem(viewModel.url.trim())
+ if (problem != null) {
+ dialog = ServerDialog.Url(problem)
+ return
+ }
+ when (val result = viewModel.save()) {
+ UpdateConnectionSettingsResult.FailedToParseUrl ->
+ say(activity.getString(R.string.git_server_config_save_error))
+ is UpdateConnectionSettingsResult.MissingUsername ->
+ dialog = ServerDialog.Username(result.newProtocol)
+ is UpdateConnectionSettingsResult.AuthModeMismatch ->
+ say(
+ activity.getString(
+ R.string.git_server_config_save_auth_mode_mismatch,
+ result.newProtocol,
+ result.validModes.joinToString(", "),
+ )
+ )
+ UpdateConnectionSettingsResult.Valid ->
+ if (!clone) {
+ Toast.makeText(activity, R.string.git_server_config_save_success, Toast.LENGTH_SHORT)
+ .show()
+ onClose()
+ } else {
+ if (PasswordRepository.repository == null) PasswordRepository.initialize()
+ if (cloneTarget(root) == CloneTarget.Occupied) dialog = ServerDialog.DeleteStore
+ else startClone()
+ }
+ }
+ }
+
+ Box(modifier) {
+ GitServerScreen(
+ clone = clone,
+ url = viewModel.url,
+ authMode = viewModel.authMode,
+ sshKey = viewModel.sshKey,
+ hasHostKey = viewModel.hasHostKey,
+ progress = viewModel.progress,
+ onUrlChange = viewModel::changeUrl,
+ onAuthModeChange = { viewModel.authMode = it },
+ onSshKey = onSshKey,
+ onClearHostKey = {
+ viewModel.clearHostKey()
+ say(activity.getString(R.string.clear_saved_host_key_success))
+ },
+ onSubmit = ::submit,
+ onBack = { if (viewModel.progress == null) onClose() },
+ )
+ when (val current = dialog) {
+ null -> Unit
+ is ServerDialog.Url -> {
+ val (title, text) =
+ when (current.problem) {
+ UrlProblem.HttpsPort ->
+ R.string.https_scheme_with_port_title to R.string.https_scheme_with_port_message
+ UrlProblem.SshScheme ->
+ R.string.ssh_scheme_needed_title to R.string.ssh_scheme_needed_message
+ UrlProblem.GitScheme ->
+ R.string.git_scheme_disallowed_title to R.string.git_scheme_disallowed_message
+ }
+ val fixed = fixUrl(viewModel.url.trim(), current.problem)
+ SetupDialog(
+ title = stringResource(title),
+ text = stringResource(text),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = {
+ dialog = null
+ fixed?.let(viewModel::changeUrl)
+ },
+ onDismiss = { dialog = null },
+ dismiss = if (fixed != null) stringResource(R.string.dialog_cancel) else null,
+ )
+ }
+ is ServerDialog.Username ->
+ SetupDialog(
+ title = stringResource(R.string.ssh_scheme_needed_title),
+ text =
+ stringResource(
+ if (current.protocol == Protocol.Https)
+ R.string.git_server_config_save_missing_username_https
+ else R.string.git_server_config_save_missing_username_ssh
+ ),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = { dialog = null },
+ onDismiss = { dialog = null },
+ )
+ ServerDialog.DeleteStore ->
+ SetupDialog(
+ title = stringResource(R.string.dialog_delete_title),
+ text = stringResource(R.string.dialog_delete_msg, root.toString()),
+ confirm = stringResource(R.string.dialog_delete),
+ onConfirm = {
+ dialog = null
+ startClone()
+ },
+ onDismiss = { dialog = null },
+ dismiss = stringResource(R.string.dialog_do_not_delete),
+ )
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Checks and commit**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:assembleDebug :app:lintDebug
+git add app/src/main/res/values/strings.xml app/src/main/res/values-it/strings.xml \
+ app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt \
+ app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt
+git commit -m "feat(app): add the Compose welcome and git server screens
+
+The welcome screen merges the old welcome and repository type screens.
+The git server screen serves clone and edit: the old URL checks as
+dialogs, authentication mode buttons, the SSH key's state with Manage,
+the host key reset, and a clone that keeps the screen on failure.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+Expected: `BUILD SUCCESSFUL`. If `SingleChoiceSegmentedButtonRow` or `SegmentedButton` is
+reported as not experimental and the opt-in as needless, keep the opt-in for `TopAppBar`; if the
+compiler reports `SegmentedButton`'s trailing lambda as the wrong slot, name it `label = { ... }`.
+
+---
+
+### Task 4: SSH key and key generation screens
+
+**Files:**
+- Modify: `app/src/main/res/values/strings.xml`, `app/src/main/res/values-it/strings.xml`
+- Create: `app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt`
+
+- [ ] **Step 1: String**
+
+In `app/src/main/res/values/strings.xml`, after `<string name="ssh_key_manage">Manage</string>`:
+
+```xml
+ <string name="ssh_key_public_hint">Provide this public key to your Git server.</string>
+```
+
+In `app/src/main/res/values-it/strings.xml`, after `<string name="ssh_key_manage">Gestisci</string>`:
+
+```xml
+ <string name="ssh_key_public_hint">Fornisci questa chiave pubblica al tuo server Git.</string>
+```
+
+- [ ] **Step 2: The screens and destinations**
+
+`app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import android.content.Intent
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontFamily
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import app.passwordstore.R
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.crypto.copyTextToClipboard
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.util.auth.BiometricAuthenticator
+import app.passwordstore.util.extensions.keyguardManager
+import app.passwordstore.util.git.sshj.SshKey
+
+/**
+ * The SSH key: its state ([summary], a string resource), the public key with copy and share when
+ * the device generated it, import and generation.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SshKeyScreen(
+ @StringRes summary: Int,
+ publicKey: String?,
+ busy: Boolean,
+ onImport: () -> Unit,
+ onGenerate: () -> Unit,
+ onCopy: () -> Unit,
+ onShare: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.ssh_keygen_existing_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) {
+ if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
+ GroupedSurface {
+ Text(
+ stringResource(summary),
+ Modifier.padding(SpacingLarge),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ if (publicKey != null)
+ GroupedSurface {
+ Column(
+ Modifier.padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingMedium),
+ ) {
+ Text(
+ stringResource(R.string.your_public_key),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ SelectionContainer {
+ Text(
+ publicKey,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ )
+ }
+ Text(
+ stringResource(R.string.ssh_key_public_hint),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Row {
+ TextButton(onClick = onCopy) { Text(stringResource(R.string.copy_label)) }
+ TextButton(onClick = onShare) { Text(stringResource(R.string.ssh_keygen_share)) }
+ }
+ }
+ }
+ Column(
+ Modifier.padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingMedium),
+ ) {
+ Button(onClick = onImport, enabled = !busy, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.pref_import_ssh_key_title))
+ }
+ OutlinedButton(onClick = onGenerate, enabled = !busy, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.pref_ssh_keygen_title))
+ }
+ }
+ }
+ }
+}
+
+/** A chooser sharing [publicKey] as text. */
+private fun shareKey(publicKey: String): Intent =
+ Intent.createChooser(
+ Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, publicKey),
+ null,
+ )
+
+/**
+ * The SSH key screen: wires [SshKeyScreen] to the [viewModel], the document picker and the replace
+ * confirmation. [onGenerate] opens the generation screen.
+ */
+@Composable
+fun SshKeyDestination(
+ activity: MainActivity,
+ onClose: () -> Unit,
+ onGenerate: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: SshKeyViewModel = hiltViewModel(),
+) {
+ var resumes by remember { mutableIntStateOf(0) }
+ // Back from the generation screen (or another instance) with a new key: read it again.
+ LifecycleResumeEffect(Unit) {
+ resumes++
+ onPauseOrDispose {}
+ }
+ val version = viewModel.version
+ val summary = remember(resumes, version) { sshKeySummary() }
+ val publicKey =
+ remember(resumes, version) { if (SshKey.canShowSshPublicKey) SshKey.sshPublicKey else null }
+ var replace by remember { mutableStateOf(false) }
+ val picker =
+ rememberLauncherForActivityResult(OpenDocument()) { uri -> if (uri != null) viewModel.import(uri) }
+
+ LaunchedEffect(viewModel.imported) {
+ if (viewModel.imported) {
+ Toast.makeText(activity, R.string.ssh_key_success_dialog_title, Toast.LENGTH_LONG).show()
+ viewModel.announced()
+ }
+ }
+ BackHandler(enabled = viewModel.busy) {}
+
+ Box(modifier) {
+ SshKeyScreen(
+ summary = summary,
+ publicKey = publicKey,
+ busy = viewModel.busy,
+ onImport = { if (SshKey.exists) replace = true else picker.launch(arrayOf("*/*")) },
+ onGenerate = onGenerate,
+ onCopy = { publicKey?.let { activity.copyTextToClipboard(it) } },
+ onShare = { publicKey?.let { activity.startActivity(shareKey(it)) } },
+ onBack = { if (!viewModel.busy) onClose() },
+ )
+ if (replace)
+ SetupDialog(
+ title = stringResource(R.string.ssh_keygen_existing_title),
+ text = stringResource(R.string.ssh_keygen_existing_message),
+ confirm = stringResource(R.string.ssh_keygen_existing_replace),
+ onConfirm = {
+ replace = false
+ picker.launch(arrayOf("*/*"))
+ },
+ onDismiss = { replace = false },
+ dismiss = stringResource(R.string.ssh_keygen_existing_keep),
+ )
+ viewModel.failure?.let { message ->
+ SetupDialog(
+ title = stringResource(R.string.ssh_key_error_dialog_title),
+ text = message,
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = viewModel::dismissFailure,
+ onDismiss = viewModel::dismissFailure,
+ )
+ }
+ }
+}
+
+/** Key type with its explanation, the screen lock switch and Generate. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SshKeyGenScreen(
+ type: SshKeyType,
+ protect: Boolean,
+ canProtect: Boolean,
+ busy: Boolean,
+ onTypeChange: (SshKeyType) -> Unit,
+ onProtectChange: (Boolean) -> Unit,
+ onGenerate: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val types = SshKeyType.entries
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.pref_ssh_keygen_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ ) {
+ SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
+ types.forEachIndexed { index, entry ->
+ SegmentedButton(
+ selected = entry == type,
+ onClick = { onTypeChange(entry) },
+ shape = SegmentedButtonDefaults.itemShape(index, types.size),
+ enabled = !busy,
+ ) {
+ Text(stringResource(entry.label))
+ }
+ }
+ }
+ // ponytail: stringResource drops the explanations' bold title; plain text reads fine.
+ Text(stringResource(type.explanation), style = MaterialTheme.typography.bodyMedium)
+ Row(
+ Modifier.fillMaxWidth()
+ .toggleable(
+ value = protect,
+ enabled = canProtect && !busy,
+ role = Role.Switch,
+ onValueChange = onProtectChange,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(stringResource(R.string.ssh_keygen_require_authentication), Modifier.weight(1f))
+ Switch(checked = protect, onCheckedChange = null, enabled = canProtect && !busy)
+ }
+ Button(onClick = onGenerate, enabled = !busy, modifier = Modifier.fillMaxWidth()) {
+ Text(
+ stringResource(
+ if (busy) R.string.ssh_key_gen_generating_progress else R.string.ssh_keygen_generate
+ )
+ )
+ }
+ }
+ }
+}
+
+/**
+ * The key generation screen: replace confirmation, screen lock prompt when protected, then the
+ * generation in the [viewModel] and the public key (or error) dialog; both dialogs close it.
+ */
+@Composable
+fun SshKeyGenDestination(
+ activity: MainActivity,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: SshKeyViewModel = hiltViewModel(),
+) {
+ val canProtect = remember { activity.keyguardManager.isDeviceSecure }
+ var replace by remember { mutableStateOf(false) }
+ // While the screen lock prompt is up. Plain remember: a recreation drops the prompt's callback,
+ // and the screen must not stay disabled for it.
+ var asking by remember { mutableStateOf(false) }
+
+ fun generate() {
+ if (!(canProtect && viewModel.protect)) return viewModel.generate(requireAuthentication = false)
+ asking = true
+ BiometricAuthenticator.authenticate(activity, R.string.biometric_prompt_title_ssh_keygen) {
+ result ->
+ // Failed attempts are handled by the prompt itself.
+ if (result is BiometricAuthenticator.Result.Retry) return@authenticate
+ asking = false
+ if (result is BiometricAuthenticator.Result.Success)
+ viewModel.generate(requireAuthentication = true)
+ else viewModel.fail(activity.getString(R.string.biometric_auth_generic_failure))
+ }
+ }
+
+ BackHandler(enabled = viewModel.busy) {}
+
+ Box(modifier) {
+ SshKeyGenScreen(
+ type = viewModel.type,
+ protect = canProtect && viewModel.protect,
+ canProtect = canProtect,
+ busy = viewModel.busy || asking,
+ onTypeChange = { viewModel.type = it },
+ onProtectChange = { viewModel.protect = it },
+ onGenerate = { if (SshKey.exists) replace = true else generate() },
+ onBack = { if (!viewModel.busy) onClose() },
+ )
+ if (replace)
+ SetupDialog(
+ title = stringResource(R.string.ssh_keygen_existing_title),
+ text = stringResource(R.string.ssh_keygen_existing_message),
+ confirm = stringResource(R.string.ssh_keygen_existing_replace),
+ onConfirm = {
+ replace = false
+ generate()
+ },
+ onDismiss = { replace = false },
+ dismiss = stringResource(R.string.ssh_keygen_existing_keep),
+ onDismissButton = onClose,
+ )
+ viewModel.generated?.let { key ->
+ SetupDialog(
+ title = stringResource(R.string.your_public_key),
+ text = stringResource(R.string.ssh_keygen_message, key),
+ confirm = stringResource(R.string.ssh_keygen_share),
+ onConfirm = {
+ activity.startActivity(shareKey(key))
+ onClose()
+ },
+ onDismiss = onClose,
+ dismiss = stringResource(R.string.ssh_keygen_later),
+ )
+ }
+ viewModel.failure?.let { message ->
+ SetupDialog(
+ title = stringResource(R.string.error_generate_ssh_key),
+ text = stringResource(R.string.ssh_key_error_dialog_text) + message,
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = onClose,
+ onDismiss = onClose,
+ )
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Checks and commit**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:assembleDebug :app:lintDebug
+git add app/src/main/res/values/strings.xml app/src/main/res/values-it/strings.xml \
+ app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt
+git commit -m "feat(app): add the Compose SSH key and key generation screens
+
+The SSH key screen shows the key's state and, for a generated key, the
+public key with copy and share, and imports a key file after the old
+replace confirmation. The generation screen ports the old one; the
+key is generated in the ViewModel after the screen lock prompt.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+Expected: `BUILD SUCCESSFUL`.
+
+---
+
+### Task 5: PGP keys screen
+
+**Files:**
+- Modify: `app/src/main/res/values/strings.xml`, `app/src/main/res/values-it/strings.xml`
+- Create: `app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt`
+
+`KeysMode`, the argument of `PgpKeysRoute` (Task 6), is declared in this file, so this task
+builds on its own; Task 6 does not declare it again.
+
+- [ ] **Step 1: Strings**
+
+In `app/src/main/res/values/strings.xml`, after
+`<string name="ssh_key_public_hint">Provide this public key to your Git server.</string>`:
+
+```xml
+ <string name="pgp_key_needed_message">Import the private key this store is encrypted for, so its entries can be read and saved.</string>
+ <string name="pgp_key_needed_recipients">Store recipients</string>
+ <string name="store_create_failed">Could not create the store: %1$s</string>
+```
+
+In `app/src/main/res/values-it/strings.xml`, after
+`<string name="ssh_key_public_hint">Fornisci questa chiave pubblica al tuo server Git.</string>`:
+
+```xml
+ <string name="pgp_key_needed_message">Importa la chiave privata per cui è cifrato questo archivio, così le sue voci si possono leggere e salvare.</string>
+ <string name="pgp_key_needed_recipients">Destinatari dell\'archivio</string>
+ <string name="store_create_failed">Impossibile creare l\'archivio: %1$s</string>
+```
+
+- [ ] **Step 2: The screen and destination**
+
+`app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.util.extensions.commitChange
+import app.passwordstore.util.extensions.snackbar
+import com.github.michaelbull.result.onFailure
+import com.google.android.material.snackbar.Snackbar
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.launch
+import kotlinx.serialization.Serializable
+
+/** What the PGP keys screen is for. */
+@Serializable
+enum class KeysMode {
+ /** Import and delete keys (Settings). */
+ Manage,
+ /** Pick the key a new local store is encrypted for. */
+ Choose,
+ /** A key is needed (after a clone, or by an entry): import it, or go on without. */
+ Need,
+}
+
+/**
+ * The imported [keys] with the import FAB. [mode] decides what a row does (select in
+ * [KeysMode.Choose], delete in [KeysMode.Manage]) and the guidance shown; [recipients] are the
+ * store's `.gpg-id` entries, listed in [KeysMode.Need].
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PgpKeysScreen(
+ mode: KeysMode,
+ keys: ImmutableList<KeyRow>,
+ recipients: ImmutableList<String>,
+ busy: Boolean,
+ onImport: () -> Unit,
+ onSelect: (KeyRow) -> Unit,
+ onDelete: (KeyRow) -> Unit,
+ onLater: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var deleting by remember { mutableStateOf<KeyRow?>(null) }
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ stringResource(
+ when (mode) {
+ KeysMode.Manage -> R.string.activity_label_pgp_key_manager
+ KeysMode.Choose -> R.string.gpg_key_select
+ KeysMode.Need -> R.string.pref_import_pgp_key_title
+ }
+ )
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ actions = {
+ if (mode == KeysMode.Need)
+ TextButton(onClick = onLater) { Text(stringResource(R.string.ssh_keygen_later)) }
+ },
+ )
+ },
+ floatingActionButton = {
+ // M3 clears the label's semantics; the FAB takes its description from the icon.
+ ExtendedFloatingActionButton(
+ text = { Text(stringResource(R.string.pref_import_pgp_key_title)) },
+ icon = { Icon(Icons.Filled.Add, stringResource(R.string.pref_import_pgp_key_title)) },
+ onClick = { if (!busy) onImport() },
+ )
+ },
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) {
+ if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
+ val message =
+ when (mode) {
+ KeysMode.Manage -> null
+ KeysMode.Choose -> R.string.select_gpg_key_message
+ KeysMode.Need -> R.string.pgp_key_needed_message
+ }
+ if (message != null)
+ Text(
+ stringResource(message),
+ Modifier.padding(SpacingLarge),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ if (mode == KeysMode.Need && recipients.isNotEmpty())
+ GroupedSurface {
+ Column(Modifier.padding(SpacingLarge)) {
+ Text(
+ stringResource(R.string.pgp_key_needed_recipients),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ recipients.forEach { Text(it, fontFamily = FontFamily.Monospace) }
+ }
+ }
+ if (keys.isEmpty())
+ Text(
+ stringResource(R.string.pgp_key_manager_no_keys_guidance),
+ Modifier.padding(SpacingLarge),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ else
+ GroupedSurface {
+ keys.forEach { row ->
+ KeyItem(row = row, mode = mode, onSelect = onSelect, onDelete = { deleting = row })
+ }
+ }
+ // Room for the FAB below the last row.
+ Spacer(Modifier.height(88.dp))
+ }
+ }
+ deleting?.let { row ->
+ SetupDialog(
+ title = stringResource(R.string.pgp_key_manager_delete_confirmation_dialog_title),
+ text = row.userId ?: row.keyId.toString(),
+ confirm = stringResource(R.string.dialog_yes),
+ onConfirm = {
+ deleting = null
+ onDelete(row)
+ },
+ onDismiss = { deleting = null },
+ dismiss = stringResource(R.string.dialog_no),
+ )
+ }
+}
+
+@Composable
+private fun KeyItem(
+ row: KeyRow,
+ mode: KeysMode,
+ onSelect: (KeyRow) -> Unit,
+ onDelete: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier
+ .fillMaxWidth()
+ .then(if (mode == KeysMode.Choose) Modifier.clickable { onSelect(row) } else Modifier)
+ .padding(horizontal = SpacingLarge, vertical = SpacingMedium),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ row.userId ?: row.keyId.toString(),
+ style = MaterialTheme.typography.bodyLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (row.userId != null)
+ Text(
+ row.keyId.toString(),
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (mode == KeysMode.Manage)
+ IconButton(onClick = onDelete) {
+ Icon(Icons.Filled.Delete, stringResource(R.string.delete))
+ }
+ }
+}
+
+/**
+ * The PGP keys screen: wires [PgpKeysScreen] to the [viewModel], the document picker and the
+ * import dialogs. [onImported] runs after every successful import; [onDone] ends the flow: after
+ * an import, "Later" or back in [KeysMode.Need], after the new store is created in
+ * [KeysMode.Choose]. [onClose] leaves the other modes.
+ */
+@Composable
+fun PgpKeysDestination(
+ activity: MainActivity,
+ mode: KeysMode,
+ onClose: () -> Unit,
+ onDone: () -> Unit,
+ onImported: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: PgpKeysViewModel = hiltViewModel(),
+) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ // Read off the main thread, symlink- and size-checked (readGpgId).
+ LaunchedEffect(mode) { if (mode == KeysMode.Need) viewModel.loadRecipients(root) }
+ // A created store moves on from here, also when the screen was recreated in between.
+ val done = viewModel.done
+ LaunchedEffect(done) { if (done) onDone() }
+
+ fun leave() {
+ if (viewModel.busy) return
+ if (mode == KeysMode.Need) onDone() else onClose()
+ }
+ BackHandler(onBack = ::leave)
+
+ val picker =
+ rememberLauncherForActivityResult(OpenDocument()) { uri ->
+ if (uri != null) viewModel.import(uri, activity)
+ }
+
+ // ponytail: a theme or locale change in the middle cancels the commit; the next commit picks up
+ // the new `.gpg-id`, and choosing the key again re-creates nothing that would conflict.
+ fun choose(row: KeyRow) {
+ if (viewModel.busy) return
+ viewModel.busy = true
+ activity.lifecycleScope.launch {
+ try {
+ val error = viewModel.createStore(root, row)
+ if (error != null) {
+ activity.snackbar(
+ message = activity.getString(R.string.store_create_failed, error),
+ length = Snackbar.LENGTH_LONG,
+ )
+ } else {
+ // A failed commit is reported like any git error; the store exists either way, and
+ // the next commit picks up the `.gpg-id`.
+ activity
+ .commitChange(
+ activity.getString(R.string.git_commit_gpg_id, activity.getString(R.string.app_name))
+ )
+ .onFailure { activity.promptOnErrorHandler(it) }
+ viewModel.markDone()
+ }
+ } finally {
+ viewModel.busy = false
+ }
+ }
+ }
+
+ Box(modifier) {
+ PgpKeysScreen(
+ mode = mode,
+ keys = viewModel.keys,
+ recipients = viewModel.recipients,
+ busy = viewModel.busy,
+ onImport = { picker.launch(arrayOf("*/*")) },
+ onSelect = ::choose,
+ onDelete = viewModel::deleteKey,
+ onLater = onDone,
+ onBack = ::leave,
+ )
+ when (val result = viewModel.importResult) {
+ null -> Unit
+ is KeyImport.Imported -> {
+ fun accept() {
+ viewModel.dismissImport()
+ onImported()
+ if (mode == KeysMode.Need) onDone()
+ }
+ SetupDialog(
+ title = stringResource(R.string.pgp_key_import_succeeded),
+ text = stringResource(R.string.pgp_key_import_succeeded_message, result.keyId),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = ::accept,
+ onDismiss = ::accept,
+ )
+ }
+ KeyImport.Exists ->
+ SetupDialog(
+ title = stringResource(R.string.pgp_key_import_failed),
+ text = stringResource(R.string.pgp_key_import_failed_replace_message),
+ confirm = stringResource(R.string.dialog_yes),
+ onConfirm = viewModel::replace,
+ onDismiss = viewModel::dismissImport,
+ dismiss = stringResource(R.string.dialog_no),
+ )
+ is KeyImport.Failed ->
+ SetupDialog(
+ title = stringResource(R.string.pgp_key_import_failed),
+ text = result.message.orEmpty(),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = viewModel::dismissImport,
+ onDismiss = viewModel::dismissImport,
+ )
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Checks and commit**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:assembleDebug :app:lintDebug
+git add app/src/main/res/values/strings.xml app/src/main/res/values-it/strings.xml \
+ app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt
+git commit -m "feat(app): add the Compose PGP keys screen
+
+One screen for managing keys, choosing the key of a new local store
+and importing the key a store needs. Rows show user ID and key ID;
+the import keeps the old success, replace and failure dialogs.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+Expected: `BUILD SUCCESSFUL`.
+
+---
+
+### Task 6: Setup graph and its callers
+
+After this task every caller uses the Compose setup screens; the legacy activities are still
+compiled but unreachable (Task 7 deletes them).
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt`
+- Modify (replace whole file): `app/src/main/java/app/passwordstore/ui/main/MainActivity.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/list/ListDestination.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt`
+- Modify: `app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt`
+
+- [ ] **Step 1: The setup graph**
+
+`app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import android.app.Activity
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.composable
+import androidx.navigation.toRoute
+import app.passwordstore.ui.main.ListRoute
+import app.passwordstore.ui.main.MainActivity
+import kotlinx.serialization.Serializable
+
+/** The first screen without a store. */
+@Serializable data object WelcomeRoute
+
+/** Git server settings; with [clone], saving them clones the store. */
+@Serializable data class GitServerRoute(val clone: Boolean)
+
+/** The SSH key: state, public key, import. */
+@Serializable data object SshKeyRoute
+
+/** SSH key generation. */
+@Serializable data object SshKeyGenRoute
+
+/** The PGP keys, for [mode]. */
+@Serializable data class PgpKeysRoute(val mode: KeysMode = KeysMode.Manage)
+
+/** The setup screens [MainActivity.setupIntent] opens on their own. */
+enum class SetupStart(val route: Any) {
+ GitServer(GitServerRoute(clone = false)),
+ Ssh(SshKeyRoute),
+ SshGenerate(SshKeyGenRoute),
+ PgpKeys(PgpKeysRoute()),
+ PgpImport(PgpKeysRoute(KeysMode.Need)),
+}
+
+/**
+ * The setup destinations. In an instance started for one of them ([MainActivity.setupIntent]),
+ * leaving its first screen finishes the activity.
+ */
+fun NavGraphBuilder.setupGraph(activity: MainActivity, nav: NavHostController) {
+ fun onTop(entry: NavBackStackEntry) = nav.currentBackStackEntry?.id == entry.id
+
+ fun close(entry: NavBackStackEntry) {
+ if (!onTop(entry)) return
+ if (nav.previousBackStackEntry == null) activity.finish() else nav.popBackStack()
+ }
+
+ // Setup is complete: the new store's list replaces the whole stack. An instance started for
+ // this screen alone just finishes.
+ fun complete(entry: NavBackStackEntry) {
+ if (!onTop(entry)) return
+ if (nav.previousBackStackEntry == null) activity.finish()
+ else nav.navigate(ListRoute()) { popUpTo(nav.graph.id) { inclusive = true } }
+ }
+
+ composable<WelcomeRoute> { entry ->
+ WelcomeScreen(
+ onClone = { if (onTop(entry)) nav.navigate(GitServerRoute(clone = true)) },
+ onCreate = { if (onTop(entry)) nav.navigate(PgpKeysRoute(KeysMode.Choose)) },
+ onSettings = activity::openSettings,
+ )
+ }
+ composable<GitServerRoute> { entry ->
+ GitServerDestination(
+ activity = activity,
+ clone = entry.toRoute<GitServerRoute>().clone,
+ onClose = { close(entry) },
+ onSshKey = { if (onTop(entry)) nav.navigate(SshKeyRoute) },
+ onCloned = { keyNeeded ->
+ if (!keyNeeded) complete(entry)
+ else if (onTop(entry)) nav.navigate(PgpKeysRoute(KeysMode.Need))
+ },
+ )
+ }
+ composable<SshKeyRoute> { entry ->
+ SshKeyDestination(
+ activity = activity,
+ onClose = { close(entry) },
+ onGenerate = { if (onTop(entry)) nav.navigate(SshKeyGenRoute) },
+ )
+ }
+ composable<SshKeyGenRoute> { entry ->
+ SshKeyGenDestination(activity = activity, onClose = { close(entry) })
+ }
+ composable<PgpKeysRoute> { entry ->
+ PgpKeysDestination(
+ activity = activity,
+ mode = entry.toRoute<PgpKeysRoute>().mode,
+ onClose = { close(entry) },
+ onDone = { complete(entry) },
+ onImported = { activity.setResult(Activity.RESULT_OK) },
+ )
+ }
+}
+```
+
+- [ ] **Step 2: MainActivity**
+
+Replace `app/src/main/java/app/passwordstore/ui/main/MainActivity.kt` with:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.main
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.WindowManager
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.hilt.navigation.compose.hiltViewModel
+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.editor.EditorDestination
+import app.passwordstore.ui.editor.EditorForm
+import app.passwordstore.ui.editor.editorForm
+import app.passwordstore.ui.entry.EntryDestination
+import app.passwordstore.ui.entry.EntryViewModel
+import app.passwordstore.ui.git.base.BaseGitActivity
+import app.passwordstore.ui.list.ListDestination
+import app.passwordstore.ui.list.folderMoves
+import app.passwordstore.ui.settings.SettingsActivity
+import app.passwordstore.ui.setup.GitServerRoute
+import app.passwordstore.ui.setup.SetupStart
+import app.passwordstore.ui.setup.WelcomeRoute
+import app.passwordstore.ui.setup.setupGraph
+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, by absolute path. Paths from [LaunchActivity] passed [LaunchActivity.isFileInRepo];
+ * the entry destination checks again before decrypting.
+ */
+@Serializable data class EntryRoute(val filePath: String)
+
+/**
+ * The editor: a new entry in [dir] (relative to the store root, "" is the root), or the entry
+ * [name] (file name without `.gpg`) there. An edit takes its plaintext from the entry view below it
+ * on the back stack, never from the route.
+ */
+@Serializable data class EditRoute(val dir: String, val name: String? = null)
+
+/**
+ * Single-activity host for the Compose UI. Not exported: it is only started by [LaunchActivity],
+ * after its biometric gate and path check, and by the app itself through [setupIntent]. 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
+
+ /** Composed destinations that show secrets; FLAG_SECURE is on while this is above zero. */
+ private var secureHolds = 0
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val filePath = intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH)
+ val setup =
+ intent.getStringExtra(EXTRA_SETUP)?.let { name ->
+ SetupStart.entries.firstOrNull { it.name == name }
+ }
+ // A restored back stack wins over this choice, so a recreation stays where it was.
+ val start: Any =
+ when {
+ filePath != null -> EntryRoute(filePath)
+ setup != null -> setup.route
+ checkStore() -> ListRoute()
+ else -> WelcomeRoute
+ }
+ setContent {
+ APSTheme {
+ val nav = rememberNavController()
+ NavHost(nav, startDestination = start) {
+ composable<ListRoute> { entry ->
+ val path = entry.toRoute<ListRoute>().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)) }
+ }
+ },
+ onOpenEntry = { file ->
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ addShortcut(file)
+ nav.navigate(EntryRoute(file.absolutePath))
+ }
+ },
+ onNewEntry = {
+ if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(EditRoute(path))
+ },
+ onClone = {
+ if (nav.currentBackStackEntry?.id == entry.id)
+ nav.navigate(GitServerRoute(clone = true))
+ },
+ onNoStore = {
+ // The store is gone (deleted from Settings): setup starts over.
+ if (nav.currentBackStackEntry?.id == entry.id)
+ nav.navigate(WelcomeRoute) { popUpTo(nav.graph.id) { inclusive = true } }
+ },
+ )
+ }
+ composable<EntryRoute> { entry ->
+ val file = File(entry.toRoute<EntryRoute>().filePath)
+ SecureWindow(this@MainActivity)
+ EntryDestination(
+ activity = this@MainActivity,
+ file = file,
+ onClose = {
+ // Only the entry on top closes: the auto-close timer can race a back press.
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ if (nav.previousBackStackEntry == null) finish() else nav.popBackStack()
+ }
+ },
+ onEdit = {
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ // Canonical folder, link's own name: a symlinked entry keeps its name.
+ val root = PasswordRepository.getRepositoryDirectory().canonicalFile
+ val dir = file.parentFile?.canonicalFile?.relativeTo(root)?.path.orEmpty()
+ nav.navigate(EditRoute(dir, file.nameWithoutExtension))
+ }
+ },
+ )
+ }
+ composable<EditRoute> { entry ->
+ val route = entry.toRoute<EditRoute>()
+ val name = route.name
+ // An edit reads the decrypted entry from the entry view below it. After process
+ // death that ViewModel is new and empty, and the editor closes.
+ val decrypted =
+ if (name == null) null
+ else
+ remember(entry) {
+ // No entry view below (none on the stack): getBackStackEntry throws.
+ try {
+ nav.getBackStackEntry<EntryRoute>()
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+ }
+ ?.let { hiltViewModel<EntryViewModel>(it).entry }
+ SecureWindow(this@MainActivity)
+ EditorDestination(
+ activity = this@MainActivity,
+ initial =
+ if (name == null) EditorForm(dir = route.dir)
+ else decrypted?.let { editorForm(it, route.dir, name) },
+ original =
+ name?.let {
+ File(File(PasswordRepository.getRepositoryDirectory(), route.dir), "$it.gpg")
+ },
+ onClose = { if (nav.currentBackStackEntry?.id == entry.id) nav.popBackStack() },
+ onSaved = {
+ // Back to the list, which re-reads the store on resume. An entry opened from a
+ // shortcut has no list below it: the list root replaces the stack.
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ if (!nav.popBackStack<ListRoute>(inclusive = false))
+ nav.navigate(ListRoute()) { popUpTo<EntryRoute> { inclusive = true } }
+ }
+ },
+ )
+ }
+ setupGraph(this@MainActivity, nav)
+ }
+ }
+ }
+ }
+
+ /** Counts a destination that shows secrets; the first one turns FLAG_SECURE on. */
+ fun holdSecure() {
+ if (secureHolds++ == 0) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ }
+
+ /** Ends a [holdSecure]; FLAG_SECURE goes off when no secret-showing destination is left. */
+ fun releaseSecure() {
+ if (--secureHolds == 0) window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ }
+
+ /** Re-reads the store; false when there is none yet and setup has to run. */
+ fun checkStore(): Boolean {
+ PasswordRepository.initialize()
+ return sharedPrefs.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
+ }
+
+ /** Git actions offered in the list's menu, chosen as the old list chose its menu. */
+ fun gitOps(): ImmutableList<GitOp> =
+ 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) }
+ }
+
+ /** Adds [file] to the launcher's dynamic shortcuts, as opening it from the list always did. */
+ fun addShortcut(file: File) {
+ val item = entryItem(file)
+ shortcutHandler.addDynamicShortcut(item, item.createAuthEnabledIntent(this))
+ }
+
+ fun pinEntry(file: File) {
+ val item = entryItem(file)
+ shortcutHandler.addPinnedShortcut(item, item.createAuthEnabledIntent(this))
+ }
+
+ fun openSettings() {
+ launchActivity(SettingsActivity::class.java)
+ }
+
+ private fun entryItem(file: File) =
+ PasswordItem.newPassword(file.name, file, PasswordRepository.getRepositoryDirectory())
+
+ companion object {
+
+ private const val EXTRA_SETUP = "SETUP"
+
+ /**
+ * Opens one setup screen in an instance of its own, for callers outside the graph (legacy
+ * Settings, autofill, git operations) and for those waiting for a result (the entry view's
+ * missing key). Leaving that screen finishes the instance; the PGP keys screen sets
+ * RESULT_OK once a key was imported.
+ */
+ // ponytail: a second instance instead of in-graph navigation, until steps 6 and 7 move
+ // Settings and autofill into the graph.
+ fun setupIntent(context: Context, start: SetupStart): Intent =
+ Intent(context, MainActivity::class.java).putExtra(EXTRA_SETUP, start.name)
+ }
+}
+
+/**
+ * FLAG_SECURE while composed. Counted in [MainActivity], because in a transition (entry view to
+ * editor and back) the entering destination is composed before the leaving one is disposed.
+ */
+@Composable
+private fun SecureWindow(activity: MainActivity) {
+ DisposableEffect(activity) {
+ activity.holdSecure()
+ onDispose { activity.releaseSecure() }
+ }
+}
+```
+
+(Compared with the current file: the setup imports, the start route, `onClone` and `onNoStore`,
+`setupGraph`, `checkStore()` without the onboarding launch, the companion; the
+`OnboardingActivity` import is gone.)
+
+- [ ] **Step 3: The list**
+
+In `app/src/main/java/app/passwordstore/ui/list/ListDestination.kt`:
+
+Delete the import
+
+```kotlin
+import app.passwordstore.ui.git.config.GitServerConfigActivity
+```
+
+Replace
+
+```kotlin
+/**
+ * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the
+ * folder picker, the legacy activities and the store operations. [onOpenFolder] takes a path
+ * relative to the root, [onOpenEntry] an entry file; [onNewEntry] opens the editor in this folder.
+ */
+@Composable
+fun ListDestination(
+ activity: MainActivity,
+ path: String,
+ onOpenFolder: (relativePath: String) -> Unit,
+ onOpenEntry: (File) -> Unit,
+ onNewEntry: () -> Unit,
+ modifier: Modifier = Modifier,
+```
+
+with
+
+```kotlin
+/**
+ * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the
+ * folder picker, the legacy activities and the store operations. [onOpenFolder] takes a path
+ * relative to the root, [onOpenEntry] an entry file; [onNewEntry] opens the editor in this folder,
+ * [onClone] the git server screen for a clone; [onNoStore] runs when the store is gone.
+ */
+@Composable
+fun ListDestination(
+ activity: MainActivity,
+ path: String,
+ onOpenFolder: (relativePath: String) -> Unit,
+ onOpenEntry: (File) -> Unit,
+ onNewEntry: () -> Unit,
+ onClone: () -> Unit,
+ onNoStore: () -> Unit,
+ modifier: Modifier = Modifier,
+```
+
+Replace
+
+```kotlin
+ if (activity.checkStore()) refresh()
+```
+
+with
+
+```kotlin
+ if (activity.checkStore()) refresh() else onNoStore()
+```
+
+and replace
+
+```kotlin
+ onConfirm = {
+ dialog = ListDialog.None
+ activity.startActivity(GitServerConfigActivity.createCloneIntent(activity))
+ },
+```
+
+with
+
+```kotlin
+ onConfirm = {
+ dialog = ListDialog.None
+ onClone()
+ },
+```
+
+- [ ] **Step 4: The entry view and autofill decryption ask through the setup intent**
+
+In `app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt`, replace the import
+
+```kotlin
+import app.passwordstore.ui.pgp.PGPKeyImportActivity
+```
+
+with
+
+```kotlin
+import app.passwordstore.ui.setup.SetupStart
+```
+
+and
+
+```kotlin
+ keyImport.launch(Intent(activity, PGPKeyImportActivity::class.java))
+```
+
+with
+
+```kotlin
+ keyImport.launch(MainActivity.setupIntent(activity, SetupStart.PgpImport))
+```
+
+(The launcher keeps its contract: `RESULT_OK` unlocks, anything else closes the entry.)
+
+In `app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt`, replace the import
+
+```kotlin
+import app.passwordstore.ui.pgp.PGPKeyImportActivity
+```
+
+with
+
+```kotlin
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.SetupStart
+```
+
+and
+
+```kotlin
+ keyImportAction.launch(Intent(this@BasePGPActivity, PGPKeyImportActivity::class.java))
+```
+
+with
+
+```kotlin
+ keyImportAction.launch(
+ MainActivity.setupIntent(this@BasePGPActivity, SetupStart.PgpImport)
+ )
+```
+
+- [ ] **Step 5: GitOperation**
+
+In `app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt`:
+
+Replace the imports
+
+```kotlin
+import app.passwordstore.ui.sshkeygen.SshKeyGenActivity
+import app.passwordstore.ui.sshkeygen.SshKeyImportActivity
+```
+
+with
+
+```kotlin
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.SetupStart
+```
+
+and delete the imports that only `getSshKey` used:
+
+```kotlin
+import app.passwordstore.util.extensions.launchActivity
+```
+```kotlin
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+```
+```kotlin
+import logcat.LogPriority.ERROR
+import logcat.asLog
+import logcat.logcat
+```
+
+(Check with `grep -n 'runCatching\|onFailure\|logcat\|asLog\|launchActivity' GitOperation.kt`
+after the edits below: only the import lines may have matched.)
+
+Replace
+
+```kotlin
+ private fun getSshKey(generateKey: Boolean) {
+ runCatching {
+ callingActivity.launchActivity(
+ if (generateKey) SshKeyGenActivity::class.java else SshKeyImportActivity::class.java
+ )
+ }
+ .onFailure { e -> logcat(ERROR) { e.asLog() } }
+ }
+```
+
+with
+
+```kotlin
+ private fun getSshKey(generateKey: Boolean) {
+ callingActivity.startActivity(
+ MainActivity.setupIntent(
+ callingActivity,
+ if (generateKey) SetupStart.SshGenerate else SetupStart.Ssh,
+ )
+ )
+ }
+```
+
+Replace
+
+```kotlin
+ .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ ->
+ // Finish the blank GitActivity so user doesn't have to press back
+ callingActivity.finish()
+ }
+```
+
+with
+
+```kotlin
+ // Cancel only closes the dialog: the calling activity is the app's own screen now.
+ .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel), null)
+```
+
+and, in `executeAfterAuthentication`, replace
+
+```kotlin
+ Toast.makeText(
+ callingActivity,
+ R.string.biometric_auth_generic_failure,
+ Toast.LENGTH_LONG,
+ )
+ .show()
+ callingActivity.finish()
+```
+
+with
+
+```kotlin
+ Toast.makeText(
+ callingActivity,
+ R.string.biometric_auth_generic_failure,
+ Toast.LENGTH_LONG,
+ )
+ .show()
+ return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
+```
+
+- [ ] **Step 6: Settings rows**
+
+In `app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt`, delete the imports
+
+```kotlin
+import android.content.Intent
+```
+```kotlin
+import app.passwordstore.ui.git.config.GitServerConfigActivity
+```
+```kotlin
+import app.passwordstore.ui.sshkeygen.ShowSshKeyFragment
+import app.passwordstore.ui.sshkeygen.SshKeyGenActivity
+import app.passwordstore.ui.sshkeygen.SshKeyImportActivity
+```
+
+add
+
+```kotlin
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.SetupStart
+```
+
+and replace, in order:
+
+```kotlin
+ activity.launchActivity(GitServerConfigActivity::class.java)
+```
+with
+```kotlin
+ activity.startActivity(MainActivity.setupIntent(activity, SetupStart.GitServer))
+```
+
+```kotlin
+ activity.launchActivity(SshKeyImportActivity::class.java)
+```
+with
+```kotlin
+ activity.startActivity(MainActivity.setupIntent(activity, SetupStart.Ssh))
+```
+
+```kotlin
+ generateSshKey.launch(Intent(activity, SshKeyGenActivity::class.java))
+```
+with
+```kotlin
+ generateSshKey.launch(MainActivity.setupIntent(activity, SetupStart.SshGenerate))
+```
+
+```kotlin
+ ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key")
+```
+with
+```kotlin
+ activity.startActivity(MainActivity.setupIntent(activity, SetupStart.Ssh))
+```
+
+In `app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt`, replace the imports
+
+```kotlin
+import app.passwordstore.ui.pgp.PGPKeyListActivity
+```
+```kotlin
+import app.passwordstore.util.extensions.launchActivity
+```
+
+with
+
+```kotlin
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.SetupStart
+```
+
+and
+
+```kotlin
+ activity.launchActivity(PGPKeyListActivity::class.java)
+```
+
+with
+
+```kotlin
+ activity.startActivity(MainActivity.setupIntent(activity, SetupStart.PgpKeys))
+```
+
+- [ ] **Step 7: Nothing outside the legacy files uses the legacy screens**
+
+```bash
+grep -rnwE 'OnboardingActivity|GitServerConfigActivity|SshKeyImportActivity|SshKeyGenActivity|ShowSshKeyFragment|PGPKeyImportActivity|PGPKeyListActivity' \
+ app/src/main/java | grep -vE 'ui/(onboarding|sshkeygen|pgp)/|ui/git/config/GitServerConfigActivity.kt'
+```
+Expected: no output.
+
+- [ ] **Step 8: Build and smoke check**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:assembleDebug test -PslimTests :app:lintDebug
+```
+Expected: `BUILD SUCCESSFUL`. `PgpKeysRoute.mode` stays an enum: navigation-common 2.8.9's
+type-safe route converter maps `SerialKind.ENUM` arguments to its built-in enum `NavType`
+(`InternalNavType$EnumNullableType` and `NavType.EnumType`, checked in the 2.8.9 jar), so no
+custom `typeMap` is needed. The next step confirms it on a device.
+
+- [ ] **Step 9: Launch smoke check on the emulator**
+
+The emulator only (`ANDROID_SERIAL`); a phone may be connected. Start the `aps-test` AVD as in
+Task 8, step 1, if it is not running (no e2e server needed).
+
+```bash
+export ANDROID_SERIAL=emulator-5554
+adb uninstall xyz.danix.hardenedpass.debug || true
+./gradlew :app:installDebug
+adb logcat -c
+adb shell am start -n xyz.danix.hardenedpass.debug/app.passwordstore.ui.main.LaunchActivity; sleep 3
+ui() { adb exec-out uiautomator dump /dev/tty; }
+tap() { local b; b=$(ui | grep -o "<node [^>]*$1[^>]*>" | head -1 | grep -o 'bounds="[^"]*"' | grep -oE '[0-9]+' | tr '\n' ' '); set -- $b; adb shell input tap $(( ($1 + $3) / 2 )) $(( ($2 + $4) / 2 )); sleep 2; }
+ui | grep -o 'text="Clone Remote Repo"'
+tap 'text="Create Local Repo"'; ui | grep -o 'text="Select key"'
+adb shell input keyevent 4; sleep 1
+tap 'text="Clone Remote Repo"'; ui | grep -o 'text="Repository information"'
+tap 'text="Manage"'; ui | grep -o 'text="No SSH key yet"'
+tap 'text="Generate SSH key pair"'; ui | grep -o 'text="ECDSA"'
+adb logcat -d -b crash | grep -c 'FATAL EXCEPTION'
+adb uninstall xyz.danix.hardenedpass.debug
+unset ANDROID_SERIAL
+```
+Expected: each `grep -o` prints its text (welcome, the `Choose` screen titled "Select key" with
+its enum route argument, the server, SSH key and generation screens), and the crash count is
+`0`. A crash here (for example a missing `NavType`) is fixed before the commit.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt \
+ app/src/main/java/app/passwordstore/ui/main/MainActivity.kt \
+ app/src/main/java/app/passwordstore/ui/list/ListDestination.kt \
+ app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt \
+ app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt \
+ app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt \
+ app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt \
+ app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt
+git commit -m "feat(app): run setup in MainActivity
+
+The welcome screen starts the app without a store, the list sends a
+vanished store back to it, and its clone prompt opens the git server
+screen. A clone asks for the store's PGP key when none matches it.
+Settings, the entry view, autofill decryption and git operations open
+the setup screens through MainActivity.setupIntent. The No SSH key
+dialog's Cancel and the unusable screen lock prompt no longer finish
+the calling activity, which is now the app's main screen.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 7: Remove the legacy setup screens
+
+**Files:**
+- Delete: the files listed in "Deleted in this step" above
+- Modify: `app/src/main/AndroidManifest.xml`
+- Modify: `app/lint.xml`
+- Modify: `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`
+- Modify (if lint says so): `app/src/main/res/values*/strings.xml`, `values/dimens.xml`, `res/drawable`
+
+- [ ] **Step 1: Delete the files**
+
+```bash
+git rm -r app/src/main/java/app/passwordstore/ui/onboarding \
+ app/src/main/java/app/passwordstore/ui/sshkeygen \
+ app/src/main/java/app/passwordstore/ui/pgp \
+ app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt \
+ app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt \
+ app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt \
+ ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.kt \
+ app/src/main/res/layout/activity_onboarding.xml \
+ app/src/main/res/layout/fragment_welcome.xml \
+ app/src/main/res/layout/fragment_clone.xml \
+ app/src/main/res/layout/fragment_key_selection.xml \
+ app/src/main/res/layout/activity_git_clone.xml \
+ app/src/main/res/layout/activity_ssh_keygen.xml \
+ app/src/main/res/layout/basic_bottom_sheet.xml
+```
+
+- [ ] **Step 2: Manifest and lint config**
+
+In `app/src/main/AndroidManifest.xml`, delete
+
+```xml
+ <activity
+ android:name=".ui.onboarding.activity.OnboardingActivity"
+ android:configChanges="orientation|screenSize"
+ android:exported="false" />
+
+```
+
+```xml
+ <activity
+ android:name=".ui.git.config.GitServerConfigActivity"
+ android:exported="false"
+ android:label="@string/title_activity_git_clone"
+ android:windowSoftInputMode="adjustResize" />
+
+```
+
+```xml
+ <activity
+ android:name=".ui.sshkeygen.SshKeyImportActivity"
+ android:exported="false"
+ android:theme="@style/NoBackgroundThemeM3"
+ android:windowSoftInputMode="adjustResize" />
+ <activity
+ android:name=".ui.sshkeygen.SshKeyGenActivity"
+ android:exported="false"
+ android:label="@string/pref_ssh_keygen_title"
+ android:windowSoftInputMode="adjustResize" />
+```
+
+and
+
+```xml
+ <activity
+ android:name=".ui.pgp.PGPKeyImportActivity"
+ android:theme="@style/NoBackgroundThemeM3" />
+ <activity
+ android:name=".ui.pgp.PGPKeyListActivity"
+ android:exported="false"
+ android:label="@string/activity_label_pgp_key_manager"
+ android:parentActivityName=".ui.settings.SettingsActivity" />
+```
+
+In `app/lint.xml`, the `Overdraw` issue ignores four of the deleted layouts. Replace
+
+```xml
+ <issue id="Overdraw">
+ <ignore path="src/main/res/layout/activity_git_clone.xml" />
+ <ignore path="src/main/res/layout/fragment_clone.xml" />
+ <ignore path="src/main/res/layout/fragment_key_selection.xml" />
+ <ignore path="src/main/res/layout/fragment_welcome.xml" />
+ </issue>
+```
+
+with nothing (delete the block; no other path is listed under it).
+
+- [ ] **Step 3: Nothing refers to the removed code**
+
+```bash
+grep -rnwE 'OnboardingActivity|WelcomeFragment|CloneFragment|KeySelectionFragment|GitServerConfigActivity|SshKeyImportActivity|SshKeyGenActivity|ShowSshKeyFragment|PGPKeyImportActivity|PGPKeyListActivity|KeyList|PGPKeyListViewModel|BasicBottomSheet|APSAppBar' \
+ app/src ui/compose/src
+grep -rnE 'ActivityOnboardingBinding|FragmentWelcomeBinding|FragmentCloneBinding|FragmentKeySelectionBinding|ActivityGitCloneBinding|ActivitySshKeygenBinding|R\.layout\.(activity_onboarding|fragment_welcome|fragment_clone|fragment_key_selection|activity_git_clone|activity_ssh_keygen|basic_bottom_sheet)' \
+ app/src
+```
+Expected: no output from either.
+
+- [ ] **Step 4: The spec**
+
+In `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`, insert before
+`## Architecture`:
+
+```
+### Setup
+
+- Without a store the app opens on a welcome screen: clone a remote repository or create a
+ local store; Settings is reachable from it.
+- The git server screen serves the clone and later edits: URL, authentication mode (SSH key or
+ password; none or password for HTTP(S)), the SSH key's state with "Manage", "Clear saved host
+ key". A failed clone stays on the screen, so URL or key can be fixed and the clone retried.
+- The SSH key screen shows the key's state and, for a key generated on the device, the public
+ key (copy, share); it imports a key file (refusing files sshj cannot read, such as OpenPGP
+ keys) and opens key generation (RSA, ECDSA, Ed25519, optionally behind the screen lock).
+- One PGP keys screen manages keys (import, delete), picks the key of a new local store (the
+ repository is created only then, its `.gpg-id` naming the key by its uppercase long key ID, as
+ `pass init <key ID>` does), and imports the key a store needs: after a clone that no
+ imported key can decrypt, and when an entry is opened with no key imported.
+- Screens outside the graph (Settings, autofill, git operations) and the entry view's key
+ import open these through a `MainActivity` setup intent until steps 6 and 7.
+
+```
+
+- [ ] **Step 5: 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 unused resources, they were used only by the
+deleted files (likely: `let_s_go`, `select_n_repository_type`, `select_gpg_key_title`,
+`gpg_key_select_mandatory`, `app_icon_hint`, `welcome_text`, the `onboarding_*` dimens in
+`values/dimens.xml`, `ic_add_48dp`, `ic_arrow_back_black_24dp`, `ic_delete_24dp`, and strings or
+styles only `basic_bottom_sheet.xml` used): confirm with `grep -rn` that nothing else uses
+them, then delete them from every `values*/` file (a string left only in a translation is an
+`ExtraTranslation` error) or from `res/drawable`, and re-run. (Only `autofill-parser` has a
+metalava API file; ui:compose has none, so removing `APSAppBar` needs no API update.) If lint
+reports an issue in a file from Tasks 1 to 6, fix it there and include it in this commit.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A app/src app/lint.xml ui/compose \
+ docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md
+git commit -m "refactor(app): remove the legacy setup screens
+
+The Compose welcome, git server, SSH key, key generation and PGP keys
+screens replace OnboardingActivity with its fragments,
+GitServerConfigActivity with BasicBottomSheet, SshKeyImportActivity,
+SshKeyGenActivity, ShowSshKeyFragment, PGPKeyImportActivity and
+PGPKeyListActivity with KeyList and PGPKeyListViewModel, their layouts
+and manifest entries, and ui:compose's APSAppBar.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 8: 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. The PIN `1234` below is the emulator's own test
+PIN, never the owner's.
+
+- [ ] **Step 1: Server, emulator, unlock, 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
+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 xyz.danix.hardenedpass.debug || true
+./gradlew :app:installDebug
+scripts/e2e-env.sh push-keys
+OUT=$(mktemp -d)
+```
+
+(`ANDROID_HOME` is the `sdk.dir` from `local.properties`. `push-keys` only after the unlock.
+The uninstall also avoids a signature mismatch between checkouts, see AGENTS.md.)
+
+Helpers for this shell. `center`, `tap` and `field` take an optional match index (1 = first).
+`pick` chooses a file in the system document picker, opening its Downloads root when the file is
+not listed at once.
+
+```bash
+APP=xyz.danix.hardenedpass.debug
+ui() { adb exec-out uiautomator dump /dev/tty; }
+center() { local b; b=$(ui | grep -o "<node [^>]*$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 ))"; }
+tap() { local c; c=$(center "$1" "$2") && adb shell input tap $c; sleep 1; }
+field() { tap 'class="android.widget.EditText"' "$1"; adb shell input keycombination 113 29; adb shell input keyevent 67; adb shell input text "$2"; }
+value() { ui | grep -o '<node [^>]*class="android.widget.EditText"[^>]*>' | sed -n "${1}p" | grep -o ' text="[^"]*"' | cut -d'"' -f2; }
+seen() { ui | grep -q "$1" && echo "ok: $1" || echo "MISSING: $1"; }
+top() { adb shell dumpsys activity activities | grep topResumedActivity; }
+mains() { adb shell dumpsys activity activities | grep -c 'Hist.*ui.main.MainActivity'; }
+open() { adb shell am start -n $APP/app.passwordstore.ui.main.LaunchActivity; sleep 3; }
+pass() { tap 'class="android.widget.EditText"'; adb shell input text "$1"; tap 'text="OK"'; sleep 2; }
+pick() { sleep 2; ui | grep -q "text=\"$1\"" || { tap 'content-desc="Show roots"'; tap 'text="Downloads"'; sleep 1; }; tap "text=\"$1\""; sleep 2; }
+store() { adb shell run-as $APP ls -a "files/store/$1"; }
+```
+
+(If `pick` finds neither the file nor "Downloads", the picker's root list differs on this
+image: dump it with `ui` and adapt the two taps; the files are in `/sdcard/Download`.)
+
+- [ ] **Step 2: Welcome screen, URL check, rotation**
+
+```bash
+open; top; mains
+seen 'text="Clone Remote Repo"'; seen 'text="Create Local Repo"'; seen 'text="Settings"'
+adb exec-out screencap -p > "$OUT/welcome.png"
+tap 'text="Clone Remote Repo"'
+seen 'text="Repository information"'; seen 'text="SSH key"'; seen 'text="No SSH key yet"'
+field 1 git@10.0.2.2:2222/srv/git/store.git
+adb shell settings put system accelerometer_rotation 0; adb shell settings put system user_rotation 1; sleep 2
+value 1
+adb shell settings put system user_rotation 0; sleep 2
+tap 'text="Clone"'; seen 'text="Potentially incorrect URL"'
+tap 'text="OK"'; value 1
+adb exec-out screencap -p > "$OUT/server.png"
+```
+Expected: `MainActivity` on top, `1` instance; the welcome screen with both choices and
+Settings; the server screen with the SSH key row; the URL survives the rotation; Clone shows the
+scheme dialog and OK turns the URL into `ssh://git@10.0.2.2:2222/srv/git/store.git`. Both
+screenshots show the screens (setup screens are not `FLAG_SECURE`).
+
+- [ ] **Step 3: No SSH key: generate in a second instance; a failed clone stays**
+
+```bash
+tap 'text="Clone"'; sleep 3
+seen 'text="No SSH key"'; tap 'text="Generate"'; sleep 2
+top; mains
+seen 'text="Generate SSH key pair"'; tap 'text="Ed25519"'; seen 'only supported by rather modern servers'
+tap 'text="Protect with screen lock credential"'
+ui | grep -o 'checkable="true" checked="[a-z]*"'
+tap 'text="Generate"'; sleep 5
+seen 'text="Your public key"'; seen 'ssh-ed25519 '
+tap 'text="Later"'; sleep 2; mains
+seen 'text="Generated on this device"'
+tap 'text="Clone"'; sleep 12
+seen 'text="An error occurred during a Git operation"'; tap 'text="OK"'
+seen 'text="Clone"'; seen 'text="Clear saved host key"'; value 1
+```
+Expected: the "No SSH key" dialog from the git operation; Generate opens a second
+`MainActivity` (`mains` prints `2`) on the generation screen; Ed25519 shows its explanation; the
+switch reads `checked="false"` after the tap; the public key dialog shows an `ssh-ed25519` key;
+Later closes the second instance (`mains` prints `1`) and the server screen now says "Generated
+on this device". The clone fails (the server does not know this key) with the git error dialog,
+and the screen stays usable with its URL; the host key was saved on the way, so "Clear saved
+host key" is offered.
+
+- [ ] **Step 4: SSH key screen: a wrong file is refused, the right one replaces the key**
+
+```bash
+tap 'text="Manage"'; seen 'text="Generated on this device"'; seen 'ssh-ed25519 '
+tap 'text="Import SSH key"'; seen 'Replace existing SSH key'; tap 'text="Replace"'
+pick aps-e2e-pgp-key.asc
+seen 'text="Key import error"'; seen 'does not appear to be an SSH private key'; tap 'text="OK"'
+seen 'text="Generated on this device"'; seen 'ssh-ed25519 '
+tap 'text="Import SSH key"'; tap 'text="Replace"'; pick aps-e2e-ssh-key
+seen 'text="Imported private key"'; seen 'ssh-ed25519 '
+adb shell input keyevent 4; seen 'text="Imported private key"'
+```
+Expected: the PGP key file is refused and the generated key stays (state and public key
+unchanged); the SSH key file is imported: "Imported private key" and `MISSING: ssh-ed25519`
+(an imported key has no public key to show); back on the server screen the row agrees.
+
+- [ ] **Step 5: Clone, the store's key, recreation, decrypt**
+
+```bash
+tap 'text="Clone"'; sleep 15
+seen 'text="Import PGP key"'; seen 'text="f8993a789cdfb014"'; seen 'text="Later"'
+seen 'Import a key using the add button below'
+adb exec-out screencap -p > "$OUT/keys.png"
+adb shell cmd uimode night yes; sleep 3; seen 'text="f8993a789cdfb014"'
+adb shell cmd uimode night no; sleep 3
+tap 'text="Import PGP key"' 2; pick aps-e2e-pgp-key.asc
+seen 'text="Successfully imported PGP key"'; seen 'f8993a789cdfb014'
+tap 'text="OK"'; sleep 2
+top; seen 'text="example.org"'; seen 'text="otp"'
+tap 'text="example.org"'; pass test; seen 'text="hunter2"'
+adb shell input keyevent 4
+```
+Expected: after the clone the `Need` screen lists the store's recipient `f8993a789cdfb014` (the
+long key ID of the fingerprint in `.e2e/seed/.gpg-id`) and no imported key; a dark mode switch
+recreates the activity and the screen stays (the restored back stack wins over the start route);
+the FAB (second "Import PGP key" node; the first is the title) imports the key, the dialog shows
+its key ID, OK opens the list of the cloned store, and `example.org` decrypts. (If the FAB is the
+first match on this image, use index 1.)
+
+- [ ] **Step 6: Settings entry points**
+
+```bash
+tap 'content-desc="More options"'; tap 'text="Settings"'; top
+tap 'text="Repository"'; tap 'text="Edit Git server settings"'; sleep 2
+top; mains; value 1; seen 'text="Save"'; seen 'text="Imported private key"'
+tap 'text="Save"'; sleep 2; top
+tap 'text="Import SSH key"'; sleep 2; top; seen 'text="Imported private key"'
+adb shell input keyevent 4; sleep 1; top
+tap 'text="Generate SSH key pair"'; sleep 2; seen 'text="ECDSA"'
+adb shell input keyevent 4; sleep 1; seen 'View generated public SSH key'
+adb shell input keyevent 4; tap 'text="PGP settings"'; tap 'text="Key manager"'; sleep 2
+top; seen 'text="PGP Key Manager"'; seen 'text="f8993a789cdfb014"'
+tap 'content-desc="Delete"'; seen 'text="Delete key?"'; tap 'text="Yes"'; sleep 2
+seen 'Import a key using the add button below'
+adb shell input keyevent 4; adb shell input keyevent 4; adb shell input keyevent 4; sleep 1; top
+```
+Expected: each Settings row opens `MainActivity` (`mains` prints `2` while it is up) on the right
+screen, and leaving it returns to `SettingsActivity`: the server screen in edit mode (button
+"Save", the cloned URL, the key's state; Save closes it), the SSH key screen, the generation
+screen (left without generating). "View generated public SSH key" is hidden
+(`MISSING`: the key is imported). The key manager lists the key and deletes it after the
+confirmation. The last backs return to the list (`MainActivity` on top).
+
+- [ ] **Step 7: Entry without keys: import through the setup intent, then decrypt**
+
+```bash
+tap 'text="example.org"'; sleep 2; seen 'text="No keys imported"'
+tap 'text="Import"'; sleep 2; mains; seen 'text="Later"'
+tap 'text="Import PGP key"' 2; pick aps-e2e-pgp-key.asc; tap 'text="OK"'; sleep 2
+mains; pass test; seen 'text="hunter2"'
+adb shell input keyevent 4
+```
+Expected: the entry view's prompt opens a second `MainActivity` on the `Need` screen (`2`);
+after the import it finishes with `RESULT_OK` (`1`) and the entry view goes on to the passphrase
+prompt and decrypts. The same intent serves `AutofillDecryptActivity`; the maintainer checks that
+path on the tablet with Firefox (delete the key in the key manager, autofill a login, import when
+asked).
+
+- [ ] **Step 8: A vanished store sends the list back to the welcome screen**
+
+```bash
+adb shell input keyevent 3; sleep 1
+adb shell run-as $APP sh -c 'rm -rf files/store; mkdir files/store'
+adb shell monkey -p $APP -c android.intent.category.LAUNCHER 1; sleep 3
+top; mains; seen 'text="Clone Remote Repo"'
+adb shell input keyevent 4; sleep 1; adb shell dumpsys window | grep mCurrentFocus
+```
+Expected: the existing task comes back (`mains` prints `1`), the list's resume finds no store and
+the welcome screen replaces the stack; back leaves the app (focus outside `$APP`).
+
+- [ ] **Step 9: Local store: backing out creates nothing; import, choose, use**
+
+```bash
+adb shell pm clear $APP
+open; tap 'text="Create Local Repo"'
+seen 'Select a GPG key to initialize your store with'; seen 'Import a key using the add button below'
+adb shell input keyevent 4; seen 'text="Clone Remote Repo"'
+adb shell run-as $APP ls -a files; store
+tap 'text="Create Local Repo"'; tap 'text="Import PGP key"'; pick aps-e2e-pgp-key.asc
+tap 'text="OK"'; sleep 1; seen 'text="f8993a789cdfb014"'
+tap 'text="f8993a789cdfb014"'; sleep 3
+top; seen 'text="New entry"'
+adb shell run-as $APP cat files/store/.gpg-id
+adb shell run-as $APP cat files/store/.git/logs/HEAD | grep -o 'Initialize GPG IDs in [^.]*\.'
+tap 'text="New entry"'; tap 'text="Create new password"'
+adb shell input text local-test; field 2 x1y2
+tap 'text="Save"'; sleep 3; seen 'text="local-test"'
+tap 'text="local-test"'; pass test; seen 'text="x1y2"'
+adb shell input keyevent 4
+```
+Expected: after `pm clear` the app opens on the welcome screen; the `Choose` screen has no keys
+yet; backing out returns to the welcome screen and `files` holds no store (`store` lists nothing
+or only `.` and `..`). After importing and tapping the key, the empty list of the new store
+appears; `.gpg-id` holds exactly `F8993A789CDFB014` on one line (the uppercase long key ID,
+maintainer decision 3); the git log has `Initialize GPG IDs in
+HardenedPass Debug.`; a new entry saves, is encrypted to that key and decrypts. (The `Choose`
+title is "Select key", the FAB is the only "Import PGP key" node there.)
+
+- [ ] **Step 10: Tear down and push**
+
+```bash
+adb shell settings put system user_rotation 0; adb shell settings put system accelerometer_rotation 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
+unset ANDROID_SERIAL
+rm -rf "$OUT"
+git push
+```
+
+Expected: push succeeds to every `origin` push URL. Look at the three screenshots before
+deleting `$OUT` if a visual check is wanted (copy them elsewhere first).
+
+---
+
+## Self-review notes
+
+- **Spec coverage for step 5:** onboarding as Compose destinations in `MainActivity` (welcome,
+ Tasks 3 and 6; checked in Task 6 step 9 and Task 8 steps 2, 8, 9); git server config and
+ clone (Tasks 2, 3, 6; Task 8 steps 2, 3, 5, 6); SSH keygen and import (Tasks 1, 2, 4, 6;
+ steps 3, 4, 6); PGP import and key list (Tasks 1, 2, 5, 6; steps 5, 6, 7, 9). Legacy
+ activities, fragments, layouts and their lint ignores deleted (Task 7). Strings in `values/`
+ and `values-it/`, each added by the task that uses it ("voci" for entries). TDD for the pure
+ logic (Task 1). `:app:lintDebug` in every task, full local CI in Task 7. Emulator check pinned
+ to `emulator-5554` with the e2e server (Task 8), plus a launch smoke check in Task 6. Spec
+ gains a Setup section (Task 7).
+- **Step 4 findings:** the PGP key after a clone (`Need`, Task 8 step 5), now only satisfied by
+ a secret key; a wrong SSH key replaceable from the server screen and an OpenPGP file refused
+ as SSH key (Task 1, Task 8 step 4).
+- **Maintainer decisions applied:** 1 and 2 as planned (Tasks 3, 5); 3: `gpgIdText` writes
+ uppercase long key IDs, one per line (Task 1, tested; used by `PgpKeysViewModel.createStore`
+ in Task 2; checked in Task 8 step 9); 4 and 5 as planned (Tasks 4, 6).
+- **Review findings applied:** symlink-safe store deletion before a clone (Task 2); `:app:lintDebug`
+ per task (Tasks 1 to 6), which also moved the screen strings into the tasks that use them;
+ the empty size cursor in `SshKey.import` (Task 1); `tryGetEmail` safe on a key without user
+ ID (Task 1); the after-clone check requires a secret key (`KeyUtils.isSecretKey`, Tasks 1 and
+ 2); `.gpg-id` read through `readGpgId` (regular file, no symlink, at most 64 KiB), off the
+ main thread (Tasks 1, 2, 5); the key check in `viewModelScope` (Tasks 2, 3); pending key bytes
+ zeroed (Task 2); a failed `.gpg-id` commit reported (Task 5); `KeysMode` stays an enum route
+ argument, verified against the navigation-common 2.8.9 jar and smoke-checked (Task 6).
+- **Parity summary:** ported: every old dialog and message (URL warnings, missing user name,
+ wrong mode, host key reset, delete before clone, SSH replace confirmation, import toast and
+ error, key generation with types, explanations, screen lock prompt, public key dialog, PGP
+ success, replace and failure dialogs, delete confirmation, the commit message). Changed:
+ welcome merged with the repository type screen, toast instead of a delayed close on save,
+ segmented buttons, the public key on the SSH key screen instead of a dialog, key ID shown in
+ key rows, `.gpg-id` with the long key ID instead of the user ID, the local repository created
+ only once a key is chosen, the clone screen staying after a failure, setup screens opened from
+ outside through a second instance. Fixed: OpenPGP files imported as SSH keys, the empty
+ cursor crash, `git://` and `http://` URL checks, deletion following symlinks, delete by user
+ ID, keys without user ID, the PGP import on the main thread with `runBlocking`, "No SSH key"
+ Cancel and the unusable prompt finishing `MainActivity`. New: SSH key row with Manage, `Need`
+ screen after a clone.
+- **Deliberate shortcuts (`ponytail:`):** the second `MainActivity` instance for setup intents
+ (Task 6); a clone or `.gpg-id` commit cancelled by a theme or locale change (Tasks 3, 5); key
+ type explanations without their bold title (Task 4).
+- **Type and name consistency:** `UrlProblem`, `urlProblem`, `fixUrl`, `authModes`,
+ `fitAuthMode`, `CloneTarget`, `cloneTarget`, `storeRecipients`, `gpgIdText(keys:
+ List<PGPIdentifier.KeyId>)`, `readGpgId(dir: File): String?` (Task 1) are used with these
+ signatures in Tasks 2, 3 and 5; `isSshPrivateKey` (Task 1) only in `SshKey.import`;
+ `KeyUtils.isSecretKey` and the fixed `KeyUtils.tryGetEmail` (Task 1) in Task 2;
+ `sshKeySummary`, `SshKeyType`, `SshKeyViewModel.import/generate/fail/dismissFailure/announced`,
+ `type`, `protect`, `busy`, `version`, `imported`, `generated`, `failure` (Task 2) match Task 4;
+ `GitServerViewModel.url/changeUrl/authMode/progress/cloned/hasHostKey/sshKey/refresh/
+ clearHostKey/save/clearStore` (suspend, returns null or a failure string resource) `/finishClone` (not suspend, launches in
+ `viewModelScope`) `/cloneEnded` (Task 2) match Task 3; `KeyRow(userId, keyId)` (no `gpgId`
+ member any more), `KeyImport`, `PgpKeysViewModel.keys/recipients/loadRecipients/busy/
+ importResult/done/import/replace/dismissImport/deleteKey/createStore/markDone` (Task 2) match
+ Task 5; `KeysMode` is declared once, in Task 5; `SetupStart.GitServer/Ssh/SshGenerate/PgpKeys/
+ PgpImport` and `MainActivity.setupIntent` (Task 6) match the callers in Task 6;
+ `ListDestination(..., onClone, onNoStore)` matches `MainActivity`; `SetupDialog(text, confirm,
+ onConfirm, onDismiss, modifier, title, dismiss, onDismissButton)` (Task 3) matches its uses in
+ Tasks 3 to 5.
+- **Build order:** Task 1 changes `crypto:pgpainless` before the app uses `isSecretKey`; Task 2
+ adds the four strings its ViewModels need, Tasks 3 to 5 the strings of their screens; Task 5
+ declares `KeysMode` so it builds before Task 6; Task 6 leaves the legacy files compiling and
+ unreferenced; Task 7 deletes them.
+
+## Maintainer decisions (2026-11-03)
+
+1. **Welcome screen:** the old "Let's Go" screen and the repository type screen are merged into
+ one (icon, name, explanation, "Clone Remote Repo", "Create Local Repo", "Settings"), as
+ planned (Task 3).
+2. **"Later" on the `Need` key screen:** offered, as planned; back does the same (Task 5).
+3. **`.gpg-id` of a new local store:** changed. It holds the key's 16-hex long key ID in
+ uppercase hex, one line per key (`F8993A789CDFB014`), the format of the maintainer's desktop
+ `pass init` store, instead of the user ID (`gpgIdText`, Task 1; `createStore`, Task 2;
+ checked in Task 8 step 9).
+4. **Import SSH key from Settings and from the "No SSH key" dialog:** opens the SSH key screen,
+ as planned (Tasks 4, 6).
+5. **`GitConfigActivity`:** stays a legacy activity until step 6, as planned.
diff --git a/docs/superpowers/plans/2026-11-04-compose-autofill.md b/docs/superpowers/plans/2026-11-04-compose-autofill.md
new file mode 100644
index 00000000..cd225857
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-04-compose-autofill.md
@@ -0,0 +1,1864 @@
+# Compose UI overhaul, step 7: Autofill activities. 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:** Move the UI of the autofill service's activities to Compose with the shared theme: the entry picker (`AutofillFilterView` with `PasswordViewHolder` and `SearchableRepositoryAdapter`), the publisher warning (`AutofillPublisherChangedActivity`) and the dialogs of the decryption (`AutofillDecryptActivity` with `PasswordDialog` and `BasePGPActivity.requireKeysExist`). `AutofillSaveActivity` already hosts the Compose editor since step 4 and does not change. Intents, extras, results, autofill parsing, crypto and preference keys stay as they are. Two security fixes come with it (maintainer decisions): autofill caches a passphrase only after a successful screen lock prompt, as the entry view does, and the publisher warning drops touches that reach it through another app's window.
+
+**Architecture:** The four activities stay separate (the autofill service starts them through `PendingIntent`s) and keep their window themes (`DialogLikeThemeM3` for the picker and the warning, `NoBackgroundThemeM3` for the decryption). Each calls `setContent { APSTheme { ... } }`. Two stateless screens are new, in `app.passwordstore.ui.autofill`: `AutofillFilterScreen` (reusing the list's `EntryRow`, `GroupedSurface` and `ListRow`, Settings' `PreferenceSwitch`, and the existing `SearchableRepositoryViewModel`) and `PublisherChangedScreen`. Two pure functions are unit tested: `autofillRows` turns search results into picker rows, `cachesPassphrase` decides whether autofill may cache a passphrase. The decryption has no screen: its window shows only the "No keys imported" dialog and the passphrase dialog, which becomes the entry view's `PassphraseDialog` (made `internal`). No ViewModel is added: the picker reuses `SearchableRepositoryViewModel`, the warning has no state worth one, and the decryption keeps its flow in the activity, as before. Legacy layouts, `PasswordViewHolder`, `PasswordDialog`, `requireKeysExist` and the RecyclerView adapter are deleted by the task that replaces them, because lint (warnings as errors) reports unused resources at once.
+
+**Tech Stack:** Kotlin 2.4.21, Compose BOM 2024.09.03 (Material 3 1.3.0: `OutlinedTextField`, `AlertDialog`, `Button`, `OutlinedButton`, `Surface`; foundation 1.7.3: `LazyColumn`, `SelectionContainer`, `verticalScroll`), activity-compose (`setContent`), lifecycle-runtime-compose (`collectAsStateWithLifecycle`), kotlinx-collections-immutable, androidx biometric through `BiometricAuthenticator` (unchanged), kotlin-result 2.0.0. No new dependencies.
+
+Spec: `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`. Earlier plans: `2026-11-02-compose-foundation.md`, `2026-11-02-compose-list.md`, `2026-11-02-compose-entry.md`, `2026-11-02-compose-editor.md`, `2026-11-03-compose-setup.md`, `2026-11-03-compose-settings.md` (same folder).
+
+## Decisions taken in this plan
+
+- **Separate activities, shared theme.** As the spec says, the autofill service launches these activities directly, so they cannot be destinations of `MainActivity`'s graph. They keep their manifest entries, `exported="false"`, window themes, `PendingIntent` factories (request codes, `FLAG_MUTABLE` where the system must add extras) and extras unchanged. Only their content moves to Compose.
+- **Setup intent.** The decryption still opens the PGP key import through `MainActivity.setupIntent(this, SetupStart.PgpImport)` for a result. The spec's "until step 7" assumed autofill would move into the graph; it cannot, so the setup intent stays for autofill for good. `MainActivity`'s `ponytail:` note and the spec's Setup bullet are reworded (Task 5). Nothing else changes there.
+- **Picker rows.** `EntryRow` takes a plain name, so the old bold, underlined identifier inside "path/**identifier**" becomes the row's name: the origin identifier (the part that must match the site or app) is the headline, and the subtitle holds the account part and the folders above it ("john@doe.org · work"). The letter avatar follows the identifier. Without an identifier (directory based structure, entry at the root) the account part is the name, as before (`autofillRows`, tested).
+- **Picker list.** One `GroupedSurface` wrapping a `LazyColumn` with one item per row, so a cleared search over a large store stays virtualized (the list's one-item-per-group shortcut would compose every entry). Search results arrive from `SearchableRepositoryViewModel.searchResult` with the old arguments (fuzzy or strict domain, recursive, files only); rows are built off the main thread. `null` rows mean "first search still running" and show nothing, so "No results." never flashes before the first result. The first search starts in `onCreate`, before anything is composed, so the ViewModel's default root listing (folders included) never reaches the picker. The rows flow is cold: each return to `STARTED` walks the store again (`ponytail:`, fine for a picker open a few seconds).
+- **Picker state.** Query and the three switches (phishing-resistant search, match, clear existing matches) are `rememberSaveable` with the old defaults: query prefilled with the origin's trusted pretty identifier, strict search on and shown for websites only, match on, clear off. A recreation keeps them (the old views kept them through view state). The first tap on a row wins: a double tap no longer starts two decryptions. The window keeps its size (75 % wide, 90 % high), `setFinishOnTouchOutside(true)` and `adjustNothing`.
+- **Publisher warning.** Same content and order of decisions: warning sign, the existing "Possible phishing attempt" title (the dataset's title, already in every locale) instead of the app logo and name, the header, the app name in quotes, the install date, the footer, then **Keep Autofill disabled** (now the filled, primary button; it was outlined like the others) and **Advanced information**. Re-enabling stays behind two taps: "Re-enable Autofill" and the package and certificate hash (monospace, selectable) appear only after "Advanced information". Re-enabling clears the app's matches and returns the prepared fill response, exactly as before; "Keep Autofill disabled", back and a tap outside return nothing. The expanded state is `rememberSaveable` (no secret; a recreation never skips the tap that expanded it). An unknown package still finishes at once. `stringResource` drops the footer's `<b>` in every locale but Italian (whose footer has none); the red title carries the emphasis instead (`ponytail:`). **Tapjacking guard** (maintainer decision): `window.decorView.filterTouchesWhenObscured = true`, so a touch that reaches the warning through another app's window (`MotionEvent.FLAG_WINDOW_IS_OBSCURED`) is dropped before Compose sees it; `ViewGroup.dispatchTouchEvent` checks the decor view's own flag first, which covers the `ComposeView` below it. Back still works under an overlay.
+- **Decryption dialogs.** The window stays transparent with `FLAG_SECURE` (from `BasePGPActivity`). "No keys imported" becomes a Compose `AlertDialog` whose Import opens the key import; dismissing it now finishes the activity (the old View dialog could be dismissed and left an invisible, dimmed window over the app being filled). The passphrase dialog is the entry view's `PassphraseDialog` (`SecureFlagPolicy.SecureOn`, passphrase only in `remember`, never saved state), with the old "clear cached password on screen off" box when the cache is enabled. Cancel finishes with `RESULT_CANCELED`, as before. A wrong passphrase still cancels the fill without retry (maintainer decision). Dialog state is two fields, `noKeys` and `passphraseFor` (the recipients being asked for, cleared before decrypting); no closure is kept. Decryption and dataset building are unchanged.
+- **Passphrase cache only after the screen lock** (maintainer decision). The old activity cached the passphrase whenever the cache feature was on, so a passphrase typed after a cancelled, failed or unavailable screen lock prompt was cached too; the entry view caches only after a successful prompt. Autofill now follows the entry view: `cachesPassphrase(cacheEnabled, auth)` is true only for `BiometricResult.Success` (unit tested), and the "clear on screen off" preference is written only together with a cached passphrase.
+- **Decryption start.** The flow starts on the first window focus, not in `onStart`. `onStart` ran it again on every return to the activity (screen off and on while the passphrase dialog was up showed a second dialog; returning from the key import ran it twice, once from `onStart` and once from the import callback). Waiting for focus is also what `LaunchActivity` needed on Android 14, whose SystemUI evicts a screen lock prompt shown before its activity is on top (commit `3f5dec64`): the passphrase cache's prompt was likely affected the same way and fell back to the passphrase dialog. After a key import the flow restarts on the next window focus (the import's result arrives while its activity is still on top, where a prompt would be evicted). Rotation, resizing and keyboard changes no longer recreate the activity (`configChanges`); any other recreation (theme, locale) finishes it with `RESULT_CANCELED`, and the user taps the field again (`ponytail:`).
+- **No FLAG_SECURE on the picker and the warning**: neither shows a secret (entry names are shown by the list too, without it). The decryption window keeps it; the save activity keeps its own.
+- **No new strings.** Every text already exists in `values/` and `values-it/`.
+- **Firefox release save support is out of scope** (maintainer decision): a change to `autofill-parser`'s browser table, made separately on `master` later (see "Out of scope").
+
+## Feature parity
+
+### `AutofillFilterView`
+
+| Behavior today | Decision |
+|---|---|
+| Floating window, 75 % by 90 % of the screen, tap outside closes, `RESULT_CANCELED` unless a fill succeeds | Port |
+| Finishes without client state or form origin | Port |
+| Header: app logo, "Select entry to fill into" and the origin (untrusted pretty identifier) in bold | Port |
+| Search field prefilled with the trusted pretty identifier, clear button | Port (`OutlinedTextField`, clear `IconButton` with "Clear search") |
+| RecyclerView rows: "path/**identifier**" (bold, underlined), account part as subtitle | Changed: identifier as the row name, "account · folders" as the subtitle (`EntryRow` in a `GroupedSurface`) |
+| Empty result: "No results." | Port; nothing shown before the first result |
+| Phishing-resistant search: websites only, on by default | Port (`PreferenceSwitch`) |
+| "Match with X" on, "Clear existing matches" off | Port (`PreferenceSwitch`) |
+| Tap: clear matches if asked, record the match if asked, start the decryption with the forwarded extras, return its result | Port; a double tap starts one decryption only |
+
+### `AutofillPublisherChangedActivity`
+
+| Behavior today | Decision |
+|---|---|
+| Floating window, tap outside closes | Port |
+| Logo and app name, warning sign, header, app name in quotes, install time, bold footer | Changed: warning sign and "Possible phishing attempt" title instead of the logo; footer without bold |
+| "Keep Autofill disabled" (outlined), finishes without a result | Port as the primary button |
+| "Advanced information" reveals package and hash and "Re-enable Autofill" | Port (`rememberSaveable`) |
+| "Re-enable Autofill": clears the app's matches, returns the prepared fill response | Port, unchanged |
+| Package info unavailable: logged, finishes | Port |
+| Touches through another app's overlay reach the buttons | New: dropped (`filterTouchesWhenObscured`) |
+
+### `AutofillDecryptActivity`
+
+| Behavior today | Decision |
+|---|---|
+| Transparent window, `FLAG_SECURE` | Port |
+| Flow from `onStart`, again on every restart | Changed: once, on the first window focus |
+| No keys: View dialog, Import opens the key import, dismissable into an invisible window | Port as a Compose dialog; dismissing finishes |
+| Passphrase cache with screen lock prompt, cached passphrase, cache cleared after screen off | Port |
+| Passphrase cached whenever the cache feature is on, also after a cancelled or unavailable prompt | Fix: cached only after a successful prompt, as in the entry view (`cachesPassphrase`) |
+| Key import result resumes the flow at once (and `onStart` ran it again) | Changed: restarts once, on the next window focus |
+| `PasswordDialog` (View, `FLAG_SECURE`, clear-cache box) | Changed: the entry view's Compose `PassphraseDialog` |
+| Wrong passphrase or failure: `RESULT_CANCELED` | Port (maintainer decision) |
+| Rotation recreated the activity, the restarted flow showed a second prompt | Changed: no recreation on rotation, resizing or keyboard changes; other recreations cancel the fill |
+
+### `AutofillSaveActivity`
+
+| Behavior today | Decision |
+|---|---|
+| Compose editor since step 4, `FLAG_SECURE`, match and generated fill | Unchanged |
+
+### Deleted in this step
+
+`ui/autofill/PasswordViewHolder.kt`, `ui/crypto/PasswordDialog.kt`, `SearchableRepositoryAdapter` and `PasswordItemDiffCallback` (in `util/viewmodel/SearchableRepositoryViewModel.kt`), `BasePGPActivity.requireKeysExist` with its result launcher, layouts `activity_oreo_autofill_filter.xml`, `oreo_autofill_filter_row.xml`, `activity_oreo_autofill_publisher_changed.xml`, `dialog_password_entry.xml`, drawables `password_row_background.xml` and `ic_keyboard_arrow_right_24dp.xml`, `@color/list_multiselect_background` (`values/` and `values-night/colors_material3.xml`, used only by that drawable), `res/values/dimens.xml` (its two margins were used only by those layouts), the `SelectableText` entries of the deleted layouts in `app/lint-baseline.xml`, and whatever lint then reports unused.
+
+**Kept:** `oreo_autofill_dataset.xml` (the fill suggestions are `RemoteViews` drawn by the system, which cannot be Compose: it stays for good), `ic_person_black_24dp` and `ic_warning_red_24dp` (used by `AutofillViewUtils`), the `DialogLikeThemeM3` and `NoBackgroundThemeM3` window themes. **Kept for step 8:** the `fastscroll`, `recyclerview` and `recyclerview-selection` dependencies (unused after Task 2, removed with the others), `FragmentExtensions` (its `Fragment.finish` loses its last user with `PasswordDialog`), `BasePGPActivity` itself (only `AutofillDecryptActivity` extends it now; folding it in is cleanup), `SearchableRepositoryViewModel`'s RecyclerView-era navigation stack.
+
+## Lessons from steps 1 to 6, designed in
+
+- **Hosting.** Activities pass values and lambdas to stateless screens; no composable members on activities (content is inline in `setContent`). `modifier: Modifier = Modifier` is the first optional parameter of every public composable; collections passed to composables are `ImmutableList`.
+- **Recreation.** Drafts and switches are `rememberSaveable`; nothing secret is. The decryption cannot resume a half-done flow after a recreation, so it cancels (`ponytail:`), and rotation no longer recreates it.
+- **Android 14 screen lock prompt.** Prompt on window focus, never from `onCreate`, `onStart` or an activity result callback (commit `3f5dec64`).
+- **Slack compose-lint and lint.** Lint runs with warnings as errors: a layout, drawable or dimension left without a user fails `UnusedResources`, so each task deletes what it orphans. No `kotlin.runCatching` in new code (the moved code keeps kotlin-result's `runCatching`, as before).
+- **Accessibility.** Icon-only controls carry content descriptions (clear search, warning sign); the app logo in the picker keeps its "HardenedPass" description (it tells the user which app is asking); switch rows are `PreferenceSwitch` (`toggleable`, `Role.Switch`).
+- **Security.** No secret in a Bundle, an extra written by new code, `rememberSaveable` or a log. The passphrase dialog sets `FLAG_SECURE` on its own window; the decryption window keeps it. The publisher warning's re-enable path stays behind an explicit second tap and ignores touches through overlays. The picker records a match only for the row the user tapped. A passphrase is cached only after a successful screen lock prompt.
+- **Lint baseline.** `app/lint-baseline.xml` lists issues by file: a task that deletes a layout also deletes that layout's baseline entries.
+- **Lint per task.** Each task's check includes `:app:lintDebug`; Task 5 runs the full CI set from AGENTS.md.
+- **Emulator.** Every `adb` command targets `ANDROID_SERIAL=emulator-5554`; a physical tablet may be connected and is never touched. Autofill itself cannot run on the emulator (AGENTS.md): Task 6 launches the activities directly as root with test extras, and the real fill is the maintainer's tablet check.
+
+**Security-relevant tasks** (code review after implementation, before the next task): Task 2 (picker: which entry is filled into which origin, match recording), Task 3 (publisher warning: must not be bypassable, tapjacking guard), Task 4 (decryption: passphrase handling and caching, `FLAG_SECURE`, fill result).
+
+**Conventions for every task**
+
+- New files get this header:
+ ```kotlin
+ /*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+ ```
+ Modified upstream files keep their header.
+- Commits are GPG-signed (default config, never disable). End each message with
+ `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
+- No em dashes in docs, comments or commit messages.
+- Before each commit run `./gradlew spotlessApply`, then the task's checks.
+- Work on the `compose` branch.
+
+---
+
+### Task 1: Picker rows (TDD)
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt`
+- Test: `app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.autofill
+
+import app.passwordstore.util.settings.DirectoryStructure
+import java.io.File
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class AutofillModelTest {
+
+ private val root = File("/store")
+
+ private fun rows(structure: DirectoryStructure, vararg paths: String) =
+ autofillRows(paths.map { File(root, it) }, root, structure).map {
+ Triple(it.path, it.label, it.subtitle)
+ }
+
+ @Test
+ fun fileBasedNamesTheIdentifier() {
+ assertEquals(
+ listOf(
+ Triple("/store/work/example.org/john@doe.org.gpg", "example.org", "john@doe.org · work"),
+ Triple("/store/example.org/john.gpg", "example.org", "john"),
+ Triple("/store/example.org.gpg", "example.org", null),
+ ),
+ rows(
+ DirectoryStructure.FileBased,
+ "work/example.org/john@doe.org.gpg",
+ "example.org/john.gpg",
+ "example.org.gpg",
+ ),
+ )
+ }
+
+ @Test
+ fun directoryBasedFallsBackToTheAccountPart() {
+ assertEquals(
+ listOf(
+ Triple(
+ "/store/example.org/john@doe.org/password.gpg",
+ "example.org",
+ "john@doe.org/password",
+ ),
+ Triple("/store/Temporary PIN.gpg", "Temporary PIN", null),
+ ),
+ rows(
+ DirectoryStructure.DirectoryBased,
+ "example.org/john@doe.org/password.gpg",
+ "Temporary PIN.gpg",
+ ),
+ )
+ }
+
+ @Test
+ fun encryptedUsernameShowsTheFolders() {
+ assertEquals(
+ listOf(Triple("/store/work/example.org.gpg", "example.org", "work")),
+ rows(DirectoryStructure.EncryptedUsername, "work/example.org.gpg"),
+ )
+ }
+
+ @Test
+ fun rowsKeepTheRankingOrder() {
+ assertEquals(
+ listOf("b.org", "a.org"),
+ rows(DirectoryStructure.FileBased, "b.org.gpg", "a.org.gpg").map { it.second },
+ )
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.autofill.AutofillModelTest`
+Expected: compilation FAIL, `Unresolved reference 'autofillRows'`.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.autofill
+
+import app.passwordstore.ui.list.ListRow
+import app.passwordstore.util.settings.DirectoryStructure
+import java.io.File
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+/**
+ * The autofill picker's rows for [files] (entries under [root], already ranked), in their order.
+ * The name is the origin identifier per [structure], the part that must match the site or app, so
+ * it stands out as the old picker's bold part did; the subtitle holds the account part and the
+ * folders above the identifier. Without an identifier the account part is the name.
+ */
+fun autofillRows(
+ files: List<File>,
+ root: File,
+ structure: DirectoryStructure,
+): ImmutableList<ListRow> =
+ files
+ .map { file ->
+ val relative = file.relativeTo(root)
+ val identifier = structure.getIdentifierFor(relative)
+ val account = structure.getAccountPartFor(relative)
+ val subtitle =
+ if (identifier == null) null
+ else
+ listOfNotNull(account, structure.getPathToIdentifierFor(relative))
+ .joinToString(" · ")
+ .ifEmpty { null }
+ ListRow(file.absolutePath, identifier ?: account.orEmpty(), subtitle, 0)
+ }
+ .toImmutableList()
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.autofill.AutofillModelTest`
+Expected: PASS, 4 tests.
+
+- [ ] **Step 5: Checks and commit**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:assembleDebug test -PslimTests :app:lintDebug
+git add app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt \
+ app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt
+git commit -m "feat(app): add the autofill picker's row model
+
+autofillRows turns the picker's search results into list rows: the
+origin identifier, the part that must match the site or app, becomes
+the row's name, and the account part and the folders above it the
+subtitle, as the old picker's bold title and subtitle showed them.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+`autofillRows` has no caller yet; Kotlin does not warn about an unused public function, and lint does not either.
+
+---
+
+### Task 2: The picker in Compose (security-relevant)
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt` (whole file)
+- Modify: `app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt`
+- Delete: `app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt`
+- Delete: `app/src/main/res/layout/activity_oreo_autofill_filter.xml`, `app/src/main/res/layout/oreo_autofill_filter_row.xml`
+- Delete: `app/src/main/res/drawable/password_row_background.xml`, `app/src/main/res/drawable/ic_keyboard_arrow_right_24dp.xml`
+- Modify: `app/src/main/res/values/colors_material3.xml`, `app/src/main/res/values-night/colors_material3.xml` (delete `list_multiselect_background`)
+- Modify: `app/lint-baseline.xml` (delete the deleted layouts' entries)
+
+- [ ] **Step 1: The screen**
+
+Create `app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.autofill
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import app.passwordstore.R
+import app.passwordstore.ui.compose.EntryRow
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.list.ListRow
+import app.passwordstore.ui.settings.PreferenceSwitch
+import kotlinx.collections.immutable.ImmutableList
+
+/**
+ * The autofill picker: which site or app is being filled ([origin], as the user can check it), a
+ * search over the store's entries, the matching [rows] (null while the first search runs), and the
+ * switches for phishing-resistant search ([showStrict]: websites only), recording the pick as a
+ * match ([matchLabel]) and clearing earlier matches. [onPick] takes the entry's absolute path.
+ */
+@Composable
+fun AutofillFilterScreen(
+ origin: String,
+ query: String,
+ rows: ImmutableList<ListRow>?,
+ showStrict: Boolean,
+ strict: Boolean,
+ matchLabel: String,
+ match: Boolean,
+ clear: Boolean,
+ onQueryChange: (String) -> Unit,
+ onStrictChange: (Boolean) -> Unit,
+ onMatchChange: (Boolean) -> Unit,
+ onClearChange: (Boolean) -> Unit,
+ onPick: (path: String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val fillInto = stringResource(R.string.oreo_autofill_select_and_fill_into)
+ Surface(modifier.fillMaxSize()) {
+ Column(Modifier.padding(vertical = SpacingLarge)) {
+ Image(
+ painterResource(R.drawable.ic_launcher_foreground),
+ contentDescription = stringResource(R.string.app_name),
+ modifier = Modifier.size(60.dp).align(Alignment.CenterHorizontally),
+ )
+ Text(
+ buildAnnotatedString {
+ append(fillInto)
+ append("\n")
+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(origin) }
+ },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = SpacingLarge),
+ textAlign = TextAlign.Center,
+ )
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ modifier = Modifier.fillMaxWidth().padding(SpacingLarge),
+ label = { Text(stringResource(R.string.action_search)) },
+ leadingIcon = {
+ Icon(painterResource(R.drawable.ic_search_24dp), contentDescription = null)
+ },
+ trailingIcon =
+ if (query.isNotEmpty()) {
+ {
+ IconButton(onClick = { onQueryChange("") }) {
+ Icon(Icons.Filled.Close, stringResource(R.string.list_clear_search))
+ }
+ }
+ } else null,
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ )
+ Box(Modifier.weight(1f).fillMaxWidth()) {
+ when {
+ rows == null -> Unit
+ rows.isEmpty() ->
+ Text(
+ stringResource(R.string.oreo_autofill_filter_no_results),
+ modifier = Modifier.padding(horizontal = SpacingLarge),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ else ->
+ // One lazy item per row: a cleared search lists the whole store. The surface wraps
+ // a short list and is capped by the box for a long one.
+ GroupedSurface(Modifier.fillMaxWidth()) {
+ LazyColumn {
+ items(rows, key = { it.path }) { row ->
+ EntryRow(name = row.label, subtitle = row.subtitle, onClick = { onPick(row.path) })
+ }
+ }
+ }
+ }
+ }
+ if (showStrict)
+ PreferenceSwitch(
+ stringResource(R.string.oreo_autofill_strict_domain_search),
+ strict,
+ onStrictChange,
+ )
+ PreferenceSwitch(matchLabel, match, onMatchChange)
+ PreferenceSwitch(
+ stringResource(R.string.oreo_autofill_matches_clear_existing),
+ clear,
+ onClearChange,
+ )
+ }
+ }
+}
+```
+
+- [ ] **Step 2: The activity**
+
+Replace the whole of `app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt` with (the companion object is unchanged):
+
+```kotlin
+/*
+ * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package app.passwordstore.ui.autofill
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.os.Build
+import android.os.Bundle
+import android.view.autofill.AutofillManager
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.ui.list.ListRow
+import app.passwordstore.util.autofill.AutofillMatcher
+import app.passwordstore.util.autofill.AutofillPreferences
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.viewmodel.FilterMode
+import app.passwordstore.util.viewmodel.ListMode
+import app.passwordstore.util.viewmodel.SearchMode
+import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
+import com.github.androidpasswordstore.autofillparser.FormOrigin
+import dagger.hilt.android.AndroidEntryPoint
+import java.io.File
+import javax.inject.Inject
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import logcat.LogPriority.ERROR
+import logcat.logcat
+
+@AndroidEntryPoint
+class AutofillFilterView : AppCompatActivity() {
+
+ @Inject lateinit var dispatcherProvider: DispatcherProvider
+
+ companion object {
+
+ private const val HEIGHT_PERCENTAGE = 0.9
+ private const val WIDTH_PERCENTAGE = 0.75
+
+ private const val EXTRA_FORM_ORIGIN_WEB =
+ "app.passwordstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB"
+ private const val EXTRA_FORM_ORIGIN_APP =
+ "app.passwordstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP"
+ private var matchAndDecryptFileRequestCode = 1
+
+ fun makeMatchAndDecryptFileIntentSender(
+ context: Context,
+ formOrigin: FormOrigin,
+ ): IntentSender {
+ val intent =
+ Intent(context, AutofillFilterView::class.java).apply {
+ when (formOrigin) {
+ is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier)
+ is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier)
+ }
+ }
+ return PendingIntent.getActivity(
+ context,
+ matchAndDecryptFileRequestCode++,
+ intent,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE
+ } else {
+ PendingIntent.FLAG_CANCEL_CURRENT
+ },
+ )
+ .intentSender
+ }
+ }
+
+ private lateinit var formOrigin: FormOrigin
+
+ private val model: SearchableRepositoryViewModel by viewModels()
+
+ /** Set by the first tap on a row: a double tap must not start a second decryption. */
+ private var picked = false
+
+ private val decryptAction =
+ registerForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ setResult(RESULT_OK, result.data)
+ }
+ finish()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setFinishOnTouchOutside(true)
+
+ val params = window.attributes
+ params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt()
+ params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt()
+ window.attributes = params
+
+ if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) {
+ logcat(ERROR) { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" }
+ finish()
+ return
+ }
+ formOrigin =
+ when {
+ intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> {
+ FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!)
+ }
+ intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> {
+ FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!)
+ }
+ else -> {
+ logcat(ERROR) {
+ "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP"
+ }
+ finish()
+ return
+ }
+ }
+ supportActionBar?.hide()
+ setResult(RESULT_CANCELED)
+
+ val isWeb = formOrigin is FormOrigin.Web
+ val initialQuery = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
+ // Before anything is composed, so the ViewModel's default root listing (folders included) is
+ // never shown. The LaunchedEffect below repeats it with equal arguments, which emits nothing.
+ search(initialQuery, isWeb)
+ val root = PasswordRepository.getRepositoryDirectory()
+ val directoryStructure = AutofillPreferences.directoryStructure(this)
+ // ponytail: cold flow, so each return to STARTED walks the store again; fine for a picker open
+ // a few seconds. Share it with stateIn in the ViewModel if a large store makes it slow.
+ val rows =
+ model.searchResult
+ .map { result ->
+ autofillRows(result.passwordItems.map { it.file }, root, directoryStructure)
+ }
+ .flowOn(dispatcherProvider.io())
+ val origin = formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)
+ val matchLabel =
+ getString(R.string.oreo_autofill_match_with, formOrigin.getPrettyIdentifier(applicationContext))
+
+ setContent {
+ APSTheme {
+ var query by rememberSaveable { mutableStateOf(initialQuery) }
+ var strict by rememberSaveable { mutableStateOf(isWeb) }
+ var match by rememberSaveable { mutableStateOf(true) }
+ var clear by rememberSaveable { mutableStateOf(false) }
+ LaunchedEffect(query, strict) { search(query, strict) }
+ val shown by rows.collectAsStateWithLifecycle<ImmutableList<ListRow>?>(initialValue = null)
+ AutofillFilterScreen(
+ origin = origin,
+ query = query,
+ rows = shown,
+ showStrict = isWeb,
+ strict = strict,
+ matchLabel = matchLabel,
+ match = match,
+ clear = clear,
+ onQueryChange = { query = it },
+ onStrictChange = { strict = it },
+ onMatchChange = { match = it },
+ onClearChange = { clear = it },
+ onPick = { path -> decryptAndFill(File(path), match, clear) },
+ )
+ }
+ }
+ }
+
+ /** The old picker's search: fuzzy or strict domain, recursive, entries only. */
+ private fun search(query: String, strict: Boolean) {
+ model.search(
+ query.trim(),
+ filterMode = if (strict) FilterMode.StrictDomain else FilterMode.Fuzzy,
+ searchMode = SearchMode.RecursivelyInSubdirectories,
+ listMode = ListMode.FilesOnly,
+ )
+ }
+
+ private fun decryptAndFill(file: File, match: Boolean, clear: Boolean) {
+ if (picked) return
+ picked = true
+ if (clear) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
+ if (match) AutofillMatcher.addMatchFor(applicationContext, formOrigin, file)
+ // intent?.extras? is checked to be non-null in onCreate
+ decryptAction.launch(
+ AutofillDecryptActivity.makeDecryptFileIntent(file, intent!!.extras!!, this)
+ )
+ }
+}
+```
+
+The order of the match calls, the forwarded extras and the result handling are the old ones. `path` comes only from rows built from the store walk, never from outside input.
+
+- [ ] **Step 3: Delete the RecyclerView pieces**
+
+```bash
+git rm app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt \
+ app/src/main/res/layout/activity_oreo_autofill_filter.xml \
+ app/src/main/res/layout/oreo_autofill_filter_row.xml \
+ app/src/main/res/drawable/password_row_background.xml \
+ app/src/main/res/drawable/ic_keyboard_arrow_right_24dp.xml
+sed -i '/name="list_multiselect_background"/d' \
+ app/src/main/res/values/colors_material3.xml app/src/main/res/values-night/colors_material3.xml
+python3 - <<'EOF'
+p = "app/lint-baseline.xml"
+gone = ("layout/activity_oreo_autofill_filter.xml", "layout/oreo_autofill_filter_row.xml")
+blocks = open(p).read().split("\n\n")
+open(p, "w").write("\n\n".join(b for b in blocks if not any(g + '"' in b for g in gone)))
+EOF
+grep -rn 'password_row_background\|ic_keyboard_arrow_right_24dp\|list_multiselect_background\|PasswordViewHolder\|SearchableRepositoryAdapter\|activity_oreo_autofill_filter\|oreo_autofill_filter_row' \
+ app/src app/lint-baseline.xml --include=*.kt --include=*.xml
+```
+Expected from the grep: only the adapter class and its own references in `SearchableRepositoryViewModel.kt`. The baseline file keeps its `<issues>` wrapper and the blank lines between issues (the script splits on them); `git diff app/lint-baseline.xml` shows exactly three removed `<issue>` blocks.
+
+In `app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt`, delete everything from `private object PasswordItemDiffCallback` to the end of the file (the diff callback and the whole `SearchableRepositoryAdapter` class), so the file ends with the closing brace of `SearchableRepositoryViewModel`. Then delete the imports only they used:
+
+```kotlin
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.selection.ItemDetailsLookup
+import androidx.recyclerview.selection.ItemKeyProvider
+import androidx.recyclerview.selection.Selection
+import androidx.recyclerview.selection.SelectionPredicates
+import androidx.recyclerview.selection.SelectionTracker
+import androidx.recyclerview.selection.StorageStrategy
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import java.util.Locale
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import me.zhanghai.android.fastscroll.PopupTextProvider
+```
+
+(`android.os.Parcelable` stays: the navigation stack still uses it.) If the compiler reports one of these as still used, keep it; `spotlessApply` drops any import left unused.
+
+- [ ] **Step 4: Checks**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:assembleDebug test -PslimTests :app:lintDebug
+```
+Expected: `BUILD SUCCESSFUL`. If lint reports a resource unused, it was used only by the deleted layouts: confirm with `grep -rn` and delete it (strings from every `values*/` file, an `ExtraTranslation` error otherwise). The two margins in `dimens.xml` are still used by the publisher layout and the password dialog until Tasks 3 and 4.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A app/src app/lint-baseline.xml
+git commit -m "feat(app): move the autofill picker to Compose
+
+AutofillFilterView draws the picker with the shared theme: origin
+header, search field, entry rows in a grouped surface and the three
+switches, over the same SearchableRepositoryViewModel search. Rows
+name the origin identifier and show the account and folders below it.
+The first tap wins, so a double tap no longer starts two decryptions.
+PasswordViewHolder, the RecyclerView adapter, the two layouts, their
+drawables, the multiselect color and their lint baseline entries go.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+**Review after this task:** the match recording (only the tapped row, only with "Match with" on), the forwarded extras, the result path, the strict search default for websites, no secret in saved state.
+
+---
+
+### Task 3: The publisher warning in Compose (security-relevant)
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt` (whole file)
+- Delete: `app/src/main/res/layout/activity_oreo_autofill_publisher_changed.xml`
+- Modify: `app/lint-baseline.xml` (delete the layout's entries)
+
+- [ ] **Step 1: The screen**
+
+Create `app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.autofill
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingLargest
+
+/**
+ * Warns that the app being filled ([appName], installed [installed]) is signed by another publisher
+ * than when an entry was first matched to it. [onKeepDisabled] fills nothing. [onReset] forgets the
+ * old matches and fills; it is offered only after "Advanced information", which shows [details]
+ * (package and certificate hash), so re-enabling always takes two deliberate taps.
+ */
+@Composable
+fun PublisherChangedScreen(
+ appName: String,
+ installed: String,
+ details: String,
+ onKeepDisabled: () -> Unit,
+ onReset: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var advanced by rememberSaveable { mutableStateOf(false) }
+ val center = Modifier.fillMaxWidth()
+ Surface(modifier) {
+ Column(
+ Modifier.verticalScroll(rememberScrollState()).padding(SpacingLargest),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ painterResource(R.drawable.ic_warning_red_24dp),
+ contentDescription =
+ stringResource(R.string.oreo_autofill_warning_publisher_warning_sign_description),
+ modifier = Modifier.size(40.dp),
+ tint = MaterialTheme.colorScheme.error,
+ )
+ // ponytail: stringResource drops the footer's <b> in every locale but Italian (whose footer
+ // has none); the red title carries the emphasis. Keep spans with getText if it is missed.
+ Text(
+ stringResource(R.string.oreo_autofill_warning_publisher_dataset_title),
+ modifier = center,
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.titleLarge,
+ )
+ Text(
+ stringResource(R.string.oreo_autofill_warning_publisher_header),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Text(
+ appName,
+ modifier = center,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Text(
+ installed,
+ modifier = center,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Text(
+ stringResource(R.string.oreo_autofill_warning_publisher_footer),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Button(onClick = onKeepDisabled, modifier = center) {
+ Text(stringResource(R.string.oreo_autofill_warning_publisher_changed_disable_autofill_button))
+ }
+ if (!advanced) {
+ OutlinedButton(onClick = { advanced = true }, modifier = center) {
+ Text(stringResource(R.string.oreo_autofill_warning_publisher_advanced_info_button))
+ }
+ } else {
+ OutlinedButton(onClick = onReset, modifier = center) {
+ Text(stringResource(R.string.oreo_autofill_warning_publisher_reenable_button))
+ }
+ SelectionContainer {
+ Text(
+ details,
+ modifier = center,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
+ )
+ }
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: The activity**
+
+Replace the whole of `app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt` with (the companion object is unchanged):
+
+```kotlin
+/*
+ * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package app.passwordstore.ui.autofill
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.service.autofill.FillResponse
+import android.text.format.DateUtils
+import android.view.autofill.AutofillManager
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.IntentCompat
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.util.autofill.AutofillMatcher
+import app.passwordstore.util.autofill.AutofillPublisherChangedException
+import app.passwordstore.util.extensions.asLog
+import app.passwordstore.util.extensions.getApplicationInfoCompat
+import app.passwordstore.util.extensions.getPackageInfoCompat
+import com.github.androidpasswordstore.autofillparser.FormOrigin
+import com.github.androidpasswordstore.autofillparser.computeCertificatesHash
+import com.github.michaelbull.result.getOrElse
+import com.github.michaelbull.result.runCatching
+import logcat.LogPriority.ERROR
+import logcat.logcat
+
+class AutofillPublisherChangedActivity : AppCompatActivity() {
+
+ companion object {
+
+ private const val EXTRA_APP_PACKAGE = "app.passwordstore.autofill.oreo.ui.EXTRA_APP_PACKAGE"
+ private const val EXTRA_FILL_RESPONSE_AFTER_RESET =
+ "app.passwordstore.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET"
+ private var publisherChangedRequestCode = 1
+
+ fun makePublisherChangedIntentSender(
+ context: Context,
+ publisherChangedException: AutofillPublisherChangedException,
+ fillResponseAfterReset: FillResponse?,
+ ): IntentSender {
+ val intent =
+ Intent(context, AutofillPublisherChangedActivity::class.java).apply {
+ putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier)
+ putExtra(EXTRA_FILL_RESPONSE_AFTER_RESET, fillResponseAfterReset)
+ }
+ return PendingIntent.getActivity(
+ context,
+ publisherChangedRequestCode++,
+ intent,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE
+ } else {
+ PendingIntent.FLAG_CANCEL_CURRENT
+ },
+ )
+ .intentSender
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setFinishOnTouchOutside(true)
+ // Tapjacking guard: a touch that reaches this window through another app's window (an overlay
+ // drawn to trick the user into re-enabling) is dropped. ViewGroup.dispatchTouchEvent checks the
+ // decor view's own flag before dispatching to any child, so the ComposeView is covered.
+ window.decorView.filterTouchesWhenObscured = true
+
+ val appPackage =
+ intent.getStringExtra(EXTRA_APP_PACKAGE)
+ ?: run {
+ logcat(ERROR) { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" }
+ finish()
+ return
+ }
+ supportActionBar?.hide()
+ val (packageInfo, appInfo, hash) =
+ runCatching {
+ Triple(
+ packageManager.getPackageInfoCompat(appPackage, PackageManager.GET_META_DATA),
+ packageManager.getApplicationInfoCompat(appPackage, PackageManager.GET_META_DATA),
+ computeCertificatesHash(this, appPackage),
+ )
+ }
+ .getOrElse { e ->
+ logcat(ERROR) { e.asLog("Failed to retrieve package info for $appPackage") }
+ finish()
+ return
+ }
+ val appName =
+ getString(
+ R.string.oreo_autofill_warning_publisher_app_name,
+ packageManager.getApplicationLabel(appInfo),
+ )
+ val installed =
+ getString(
+ R.string.oreo_autofill_warning_publisher_install_time,
+ DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime),
+ )
+ val details =
+ getString(R.string.oreo_autofill_warning_publisher_advanced_info_template, appPackage, hash)
+ setContent {
+ APSTheme {
+ PublisherChangedScreen(
+ appName = appName,
+ installed = installed,
+ details = details,
+ onKeepDisabled = { finish() },
+ onReset = { reset(appPackage) },
+ )
+ }
+ }
+ }
+
+ /** Forgets the app's matches and fills with the response the service prepared for this case. */
+ private fun reset(appPackage: String) {
+ AutofillMatcher.clearMatchesFor(this, FormOrigin.App(appPackage))
+ val fillResponse =
+ IntentCompat.getParcelableExtra(
+ intent,
+ EXTRA_FILL_RESPONSE_AFTER_RESET,
+ FillResponse::class.java,
+ )
+ setResult(
+ RESULT_OK,
+ Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) },
+ )
+ finish()
+ }
+}
+```
+
+`setResult` is called only in `reset`; every other way out (button, back, tap outside, missing package) returns `RESULT_CANCELED`, so the system fills nothing, as before. kotlin-result's `getOrElse` is inline, so the `return` inside it leaves `onCreate` (the old `decryptCredential` relies on the same).
+
+- [ ] **Step 3: Delete the layout**
+
+```bash
+git rm app/src/main/res/layout/activity_oreo_autofill_publisher_changed.xml
+python3 - <<'EOF'
+p = "app/lint-baseline.xml"
+blocks = open(p).read().split("\n\n")
+gone = 'layout/activity_oreo_autofill_publisher_changed.xml"'
+open(p, "w").write("\n\n".join(b for b in blocks if gone not in b))
+EOF
+grep -rn 'activity_oreo_autofill_publisher_changed\|ActivityOreoAutofillPublisherChangedBinding' app/src app/lint-baseline.xml
+```
+Expected: no output; `git diff app/lint-baseline.xml` shows two removed `<issue>` blocks.
+
+- [ ] **Step 4: Checks**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:assembleDebug test -PslimTests :app:lintDebug
+```
+Expected: `BUILD SUCCESSFUL`. The margins in `dimens.xml` are still used by `dialog_password_entry.xml` (Task 4). If lint reports another resource only the deleted layout used, confirm with `grep -rn` and delete it.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A app/src app/lint-baseline.xml
+git commit -m "feat(app): move the publisher warning to Compose
+
+AutofillPublisherChangedActivity draws the warning with the shared
+theme. Keep Autofill disabled is now the primary button, and the
+Possible phishing attempt title replaces the logo. Re-enabling still
+takes two taps (Advanced information first) and still clears the
+app's matches before returning the prepared fill response; every other
+way out fills nothing. Touches that reach the warning through another
+app's window are now dropped (filterTouchesWhenObscured on the decor
+view), so an overlay cannot trick the user into re-enabling.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+**Review after this task:** no path to `RESULT_OK` without "Advanced information" then "Re-enable Autofill"; the reset clears matches before the result; the touch filter is set before any content; untrusted text (the app label) is only displayed.
+
+---
+
+### Task 4: The decryption's dialogs in Compose, passphrase cache only after the screen lock (security-relevant)
+
+**Files:**
+- Modify: `app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt` (add `cachesPassphrase`)
+- Test: `app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt` (add a test)
+- Modify: `app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt` (whole file)
+- Modify: `app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt` (`PassphraseDialog` visibility)
+- Modify: `app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt` (remove `requireKeysExist`)
+- Modify: `app/src/main/AndroidManifest.xml` (decrypt activity `configChanges`)
+- Delete: `app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt`, `app/src/main/res/layout/dialog_password_entry.xml`, `app/src/main/res/values/dimens.xml`
+
+- [ ] **Step 1: Failing test for the cache rule**
+
+In `app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt` add these imports:
+
+```kotlin
+import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+```
+
+and append inside the class, after the last test:
+
+```kotlin
+ @Test
+ fun passphraseIsCachedOnlyAfterTheScreenLock() {
+ assertTrue(cachesPassphrase(true, BiometricResult.Success(null)))
+ val notConfirmed =
+ listOf(
+ BiometricResult.CanceledByUser,
+ BiometricResult.CanceledBySystem,
+ BiometricResult.HardwareUnavailableOrDisabled,
+ BiometricResult.Failure(null, "failed"),
+ BiometricResult.Retry,
+ )
+ for (auth in notConfirmed) assertFalse(cachesPassphrase(true, auth), auth.toString())
+ assertFalse(cachesPassphrase(false, BiometricResult.Success(null)))
+ }
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.autofill.AutofillModelTest`
+Expected: compilation FAIL, `Unresolved reference 'cachesPassphrase'`.
+
+- [ ] **Step 3: The rule**
+
+Append to `app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt`, and add the import `app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult`:
+
+```kotlin
+/**
+ * Whether autofill may cache the passphrase it just decrypted with: only with the cache feature on
+ * ([cacheEnabled]) and after a successful screen lock prompt ([auth]), as the entry view does. A
+ * passphrase typed after a cancelled, failed or unavailable prompt is never kept.
+ */
+fun cachesPassphrase(cacheEnabled: Boolean, auth: BiometricResult): Boolean =
+ cacheEnabled && auth is BiometricResult.Success
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `./gradlew :app:testDebugUnitTest --tests app.passwordstore.ui.autofill.AutofillModelTest`
+Expected: PASS, 5 tests.
+
+- [ ] **Step 5: Share the passphrase dialog**
+
+In `app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt` change
+
+```kotlin
+/** Asks for the PGP passphrase, as the old `PasswordDialog` did. */
+@Composable
+private fun PassphraseDialog(
+```
+
+to
+
+```kotlin
+/**
+ * Asks for the PGP passphrase (entry view and autofill). The passphrase lives only in `remember`
+ * and the dialog's window is FLAG_SECURE.
+ */
+@Composable
+internal fun PassphraseDialog(
+```
+
+Nothing else in the file changes.
+
+- [ ] **Step 6: The activity**
+
+Replace the whole of `app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt` with (the companion object, `decryptWithPassphrase`'s body and `decryptCredential` are the old code, with the file, client state and action read from fields instead of parameters, and the cache condition replaced by `cachesPassphrase`):
+
+```kotlin
+/*
+ * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package app.passwordstore.ui.autofill
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.os.Build
+import android.os.Bundle
+import android.view.autofill.AutofillManager
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.res.stringResource
+import androidx.core.content.edit
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.Application.Companion.screenWasOff
+import app.passwordstore.R
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.data.crypto.PGPPassphraseCache
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.ui.crypto.BasePGPActivity
+import app.passwordstore.ui.crypto.getPGPIdentifiers
+import app.passwordstore.ui.entry.PassphraseDialog
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.SetupStart
+import app.passwordstore.util.auth.BiometricAuthenticator
+import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult
+import app.passwordstore.util.autofill.AutofillPreferences
+import app.passwordstore.util.autofill.AutofillResponseBuilder
+import app.passwordstore.util.extensions.asLog
+import app.passwordstore.util.features.Feature.EnablePGPPassphraseCache
+import app.passwordstore.util.features.Features
+import app.passwordstore.util.settings.DirectoryStructure
+import app.passwordstore.util.settings.PreferenceKeys
+import com.github.androidpasswordstore.autofillparser.AutofillAction
+import com.github.androidpasswordstore.autofillparser.Credentials
+import com.github.michaelbull.result.getOrElse
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.onSuccess
+import com.github.michaelbull.result.runCatching
+import dagger.hilt.android.AndroidEntryPoint
+import java.io.ByteArrayOutputStream
+import java.io.File
+import javax.inject.Inject
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import logcat.LogPriority.ERROR
+import logcat.asLog
+import logcat.logcat
+
+/**
+ * Decrypts one entry for the autofill service and returns the dataset to fill. Its transparent,
+ * FLAG_SECURE window shows only the "no keys" and passphrase dialogs.
+ */
+@AndroidEntryPoint
+class AutofillDecryptActivity : BasePGPActivity() {
+
+ @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
+ @Inject lateinit var features: Features
+ @Inject lateinit var passphraseCache: PGPPassphraseCache
+
+ private lateinit var directoryStructure: DirectoryStructure
+ private lateinit var filePath: String
+ private lateinit var clientState: Bundle
+ private lateinit var action: AutofillAction
+ private var clearCache = true
+
+ /** The screen lock prompt's outcome: the passphrase is cached only after a success. */
+ private var auth: BiometricResult = BiometricResult.CanceledByUser
+
+ /** Set once the flow started; reset after a key import, so the next window focus restarts it. */
+ private var started = false
+
+ /** Shows the "No keys imported" dialog. */
+ private var noKeys by mutableStateOf(false)
+
+ /** The recipients the passphrase dialog asks for; null while it is not shown. */
+ private var passphraseFor by mutableStateOf<List<PGPIdentifier>?>(null)
+
+ // The import's activity is still on top when its result arrives: the flow restarts on the next
+ // window focus, so the screen lock prompt is not shown (and evicted) behind it.
+ private val keyImport =
+ registerForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) started = false else finish()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // ponytail: a recreation (theme or locale change; rotation and resizing do not recreate, see
+ // the manifest) cancels the fill instead of resuming a half-done flow; the user taps the field
+ // again. Keep the flow's state in a ViewModel if this ever matters.
+ if (savedInstanceState != null) {
+ finish()
+ return
+ }
+ filePath =
+ intent?.getStringExtra(EXTRA_FILE_PATH)
+ ?: run {
+ logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
+ finish()
+ return
+ }
+ clientState =
+ intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
+ ?: run {
+ logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
+ finish()
+ return
+ }
+ val isSearchAction = intent.getBooleanExtra(EXTRA_SEARCH_ACTION, true)
+ action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
+ directoryStructure = AutofillPreferences.directoryStructure(this)
+ logcat { action.toString() }
+ setContent {
+ APSTheme {
+ if (noKeys)
+ AlertDialog(
+ onDismissRequest = { finish() },
+ title = { Text(stringResource(R.string.no_keys_imported_dialog_title)) },
+ text = { Text(stringResource(R.string.no_keys_imported_dialog_message)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ noKeys = false
+ keyImport.launch(
+ MainActivity.setupIntent(this@AutofillDecryptActivity, SetupStart.PgpImport)
+ )
+ }
+ ) {
+ Text(stringResource(R.string.button_label_import))
+ }
+ },
+ )
+ val identifiers = passphraseFor
+ if (identifiers != null)
+ PassphraseDialog(
+ wrong = false,
+ cacheEnabled = features.isEnabled(EnablePGPPassphraseCache),
+ clearCache = clearCache,
+ onSubmit = onSubmit@{ passphrase, clear ->
+ // Cleared before decrypting: a second submit in the same frame does nothing.
+ if (passphraseFor == null) return@onSubmit
+ passphraseFor = null
+ clearCache = clear
+ lifecycleScope.launch(dispatcherProvider.main()) {
+ decryptWithPassphrase(identifiers, passphrase)
+ }
+ },
+ onCancel = { finish() },
+ )
+ }
+ }
+ }
+
+ // The flow waits for window focus, as LaunchActivity's prompt does: Android 14's SystemUI
+ // dismisses a screen lock prompt shown before its activity is on top. It runs once (again only
+ // after a key import); the old onStart start ran it on every return to this activity.
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ super.onWindowFocusChanged(hasFocus)
+ if (!hasFocus || started || isFinishing) return
+ started = true
+ start()
+ }
+
+ /** Asks for a key import first while no PGP key is imported. */
+ private fun start() {
+ if (isFinishing) return
+ lifecycleScope.launch { if (repository.hasKeys()) unlock() else noKeys = true }
+ }
+
+ private fun unlock() {
+ if (
+ features.isEnabled(EnablePGPPassphraseCache) && BiometricAuthenticator.canAuthenticate(this)
+ ) {
+ BiometricAuthenticator.authenticate(
+ this,
+ R.string.biometric_prompt_title_gpg_passphrase_cache,
+ ) { authResult ->
+ decrypt(authResult)
+ }
+ } else {
+ decrypt(BiometricResult.CanceledByUser)
+ }
+ }
+
+ private fun decrypt(authResult: BiometricResult) {
+ auth = authResult
+ val gpgIdentifiers =
+ getPGPIdentifiers(
+ getParentPath(filePath, PasswordRepository.getRepositoryDirectory().toString())
+ ) ?: return
+ lifecycleScope.launch(dispatcherProvider.main()) {
+ when (authResult) {
+ // Internally handled by the prompt dialog
+ is BiometricResult.Retry -> {}
+ // If the dialog is dismissed for any reason, prompt for passphrase
+ is BiometricResult.CanceledBySystem,
+ is BiometricResult.CanceledByUser,
+ is BiometricResult.Failure,
+ is BiometricResult.HardwareUnavailableOrDisabled -> askPassphrase(gpgIdentifiers)
+ is BiometricResult.Success -> {
+ /* clear passphrase cache on first use after application startup or if screen was off;
+ also make sure to purge a stale cache after caching has been disabled via PGP settings */
+ clearCache = settings.getBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, true)
+ if (screenWasOff && clearCache) {
+ passphraseCache.clearAllCachedPassphrases(this@AutofillDecryptActivity)
+ screenWasOff = false
+ }
+ val cachedPassphrase =
+ passphraseCache.retrieveCachedPassphrase(
+ this@AutofillDecryptActivity,
+ gpgIdentifiers.first(),
+ )
+ if (cachedPassphrase != null) {
+ decryptWithPassphrase(gpgIdentifiers, cachedPassphrase)
+ } else {
+ askPassphrase(gpgIdentifiers)
+ }
+ }
+ }
+ }
+ }
+
+ private suspend fun askPassphrase(identifiers: List<PGPIdentifier>) {
+ if (!repository.isPasswordProtected(identifiers)) {
+ decryptWithPassphrase(identifiers, password = "")
+ return
+ }
+ passphraseFor = identifiers
+ }
+
+ private suspend fun decryptWithPassphrase(identifiers: List<PGPIdentifier>, password: String) {
+ val credentials = decryptCredential(File(filePath), password, identifiers)
+ if (credentials == null) {
+ setResult(RESULT_CANCELED)
+ } else {
+ val fillInDataset =
+ AutofillResponseBuilder.makeFillInDataset(
+ this@AutofillDecryptActivity,
+ credentials,
+ clientState,
+ action,
+ )
+ withContext(dispatcherProvider.main()) {
+ setResult(
+ RESULT_OK,
+ Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) },
+ )
+ }
+ }
+ withContext(dispatcherProvider.main()) { finish() }
+ }
+
+ private suspend fun decryptCredential(
+ file: File,
+ password: String,
+ identifiers: List<PGPIdentifier>,
+ ): Credentials? {
+ runCatching { file.readBytes().inputStream() }
+ .onFailure { e ->
+ logcat(ERROR) { e.asLog("File to decrypt not found") }
+ return null
+ }
+ .onSuccess { encryptedInput ->
+ val outputStream = ByteArrayOutputStream()
+ repository
+ .decrypt(password, identifiers, encryptedInput, outputStream)
+ .onFailure { e ->
+ logcat(ERROR) { e.asLog("Decryption failed") }
+ return null
+ }
+ .onSuccess { result ->
+ return runCatching {
+ runCatching {
+ if (cachesPassphrase(features.isEnabled(EnablePGPPassphraseCache), auth)) {
+ passphraseCache.cachePassphrase(this, identifiers.first(), password)
+ settings.edit {
+ putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, clearCache)
+ }
+ }
+ }
+ .onFailure { e -> logcat { e.asLog() } }
+ val entry = passwordEntryFactory.create(result.toByteArray())
+ AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
+ }
+ .getOrElse { e ->
+ logcat(ERROR) { e.asLog("Failed to parse password entry") }
+ return null
+ }
+ }
+ }
+ return null
+ }
+
+ companion object {
+
+ private const val EXTRA_FILE_PATH = "app.passwordstore.autofill.oreo.EXTRA_FILE_PATH"
+ private const val EXTRA_SEARCH_ACTION = "app.passwordstore.autofill.oreo.EXTRA_SEARCH_ACTION"
+
+ private var decryptFileRequestCode = 1
+
+ fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent {
+ return Intent(context, AutofillDecryptActivity::class.java).apply {
+ putExtras(forwardedExtras)
+ putExtra(EXTRA_SEARCH_ACTION, true)
+ putExtra(EXTRA_FILE_PATH, file.absolutePath)
+ }
+ }
+
+ fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
+ val intent =
+ Intent(context, AutofillDecryptActivity::class.java).apply {
+ putExtra(EXTRA_SEARCH_ACTION, false)
+ putExtra(EXTRA_FILE_PATH, file.absolutePath)
+ }
+ return PendingIntent.getActivity(
+ context,
+ decryptFileRequestCode++,
+ intent,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE
+ } else {
+ PendingIntent.FLAG_CANCEL_CURRENT
+ },
+ )
+ .intentSender
+ }
+ }
+}
+```
+
+Notes for the implementer:
+- The passphrase goes from the dialog's `remember` straight into `decryptWithPassphrase`; `passphraseFor` holds the recipients, never the passphrase. No new log line mentions it.
+- `auth` is set at the start of every `decrypt`, so a cancelled prompt (`CanceledByUser`), an unavailable one (`HardwareUnavailableOrDisabled`, or no prompt at all because the device cannot authenticate) and a failed one never lead to caching. With a successful prompt, a passphrase typed because nothing was cached yet is cached, as in the entry view; a cached passphrase that decrypts is written back unchanged, as before.
+- The "clear cached password on screen off" box still shows whenever the cache feature is on, as in the entry view; its value is stored only together with a cached passphrase.
+- The old `intent?.getBooleanExtra(...)!!` becomes `intent.getBooleanExtra(...)` (an activity's `intent` is never null after the two checks above); if the compiler disagrees, keep the old form.
+
+- [ ] **Step 7: No recreation on rotation or resizing**
+
+In `app/src/main/AndroidManifest.xml` replace
+
+```xml
+ <activity
+ android:name=".ui.autofill.AutofillDecryptActivity"
+ android:exported="false"
+ android:theme="@style/NoBackgroundThemeM3" />
+```
+
+with
+
+```xml
+ <!-- ponytail: rotation, resizing and keyboard changes must not cancel a fill in progress;
+ other recreations (theme, locale) cancel it. -->
+ <activity
+ android:name=".ui.autofill.AutofillDecryptActivity"
+ android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
+ android:exported="false"
+ android:theme="@style/NoBackgroundThemeM3" />
+```
+
+- [ ] **Step 8: Remove `requireKeysExist` and `PasswordDialog`**
+
+In `app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt` delete the `onKeyImport` field with its KDoc, the `keyImportAction` launcher, and the whole `requireKeysExist` function with its KDoc. Then delete the imports only they used:
+
+```kotlin
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.SetupStart
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+```
+
+The class keeps its injected `settings`, `repository` and `dispatcherProvider`, its `onCreate` (FLAG_SECURE) and its companion.
+
+```bash
+git rm app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt \
+ app/src/main/res/layout/dialog_password_entry.xml \
+ app/src/main/res/values/dimens.xml
+grep -rn 'PasswordDialog\b\|DialogPasswordEntryBinding\|requireKeysExist\|activity_horizontal_margin\|activity_vertical_margin\|dialog_password_entry' \
+ app/src app/lint-baseline.xml --include=*.kt --include=*.xml
+```
+Expected: no output (`dimens.xml` held only the two margins, whose last users were the three deleted layouts; `PassphraseDialog` does not match `PasswordDialog\b`; the lint baseline has no entry for `dialog_password_entry.xml`).
+
+- [ ] **Step 9: Checks**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew :app:assembleDebug test -PslimTests :app:lintDebug
+```
+Expected: `BUILD SUCCESSFUL`. If lint reports a resource only the deleted dialog layout used, confirm with `grep -rn` and delete it.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add -A app/src app/lint-baseline.xml
+git commit -m "feat(app): move the autofill decryption prompts to Compose
+
+AutofillDecryptActivity shows the no keys dialog and the entry view's
+passphrase dialog in Compose over its transparent FLAG_SECURE window.
+The flow starts once, on the first window focus, instead of on every
+onStart: Android 14 evicts a screen lock prompt shown before the
+activity is on top, and a restart showed a second passphrase dialog.
+After a key import it restarts on the next focus. Dismissing the no
+keys dialog now finishes instead of leaving an invisible window.
+Rotation and resizing no longer recreate the activity; other
+recreations cancel the fill.
+
+The passphrase is now cached only after a successful screen lock
+prompt, as the entry view does: a passphrase typed after a cancelled
+or unavailable prompt was cached whenever the cache feature was on.
+
+PasswordDialog, its layout, dimens.xml and requireKeysExist lose their
+last user and go.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+**Review after this task:** the passphrase never reaches saved state, an extra or a log; `FLAG_SECURE` on the window and the dialog; caching only after `BiometricResult.Success` (`auth` set on every path into `decrypt`); every exit without a dataset returns `RESULT_CANCELED`; the key import result path (restart on focus, never a second concurrent flow); the one-time start.
+
+---
+
+### Task 5: Setup intent wording, spec, full check
+
+**Files:**
+- Modify: `app/src/main/java/app/passwordstore/ui/main/MainActivity.kt` (`setupIntent` comment)
+- Modify: `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`
+
+- [ ] **Step 1: `MainActivity.setupIntent`**
+
+Replace
+
+```kotlin
+ // ponytail: a second instance instead of in-graph navigation, until step 7 moves autofill
+ // into the graph and the git operations' dialogs become Compose dialogs.
+```
+
+with
+
+```kotlin
+ // ponytail: a second instance instead of in-graph navigation. Autofill's activities stay
+ // outside the graph for good (the autofill service starts them); the git operations' "No SSH
+ // key" dialog could navigate in-graph once it becomes a Compose dialog.
+```
+
+- [ ] **Step 2: The spec**
+
+In `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md` replace
+
+```
+- Settings opens these within the graph. Autofill, the git operations' "No SSH key" dialog and
+ the entry view's key import open them through a `MainActivity` setup intent until step 7.
+```
+
+with
+
+```
+- Settings opens these within the graph. Autofill, the git operations' "No SSH key" dialog and
+ the entry view's key import open them through a `MainActivity` setup intent: the autofill
+ activities live outside the graph, and the other two wait for a result.
+```
+
+and insert after the Settings section (before `## Architecture`):
+
+```
+### Autofill
+
+- The autofill service's activities keep their windows (floating picker and warning, transparent
+ decryption) and draw them with the shared theme. The picker shows the site or app being filled,
+ a search prefilled with it, entry rows named by their origin identifier, and the
+ phishing-resistant search, match and clear-matches switches.
+- The publisher warning offers "Keep Autofill disabled" first; re-enabling stays behind "Advanced
+ information", and touches through another app's window are ignored. The decryption shows only
+ the "no keys" and passphrase dialogs, starts once the window has focus, and caches the
+ passphrase only after a successful screen lock prompt, as the entry view does. Saving and
+ generating host the editor.
+- Intents, extras, results and matching are unchanged.
+```
+
+- [ ] **Step 3: Full local check**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug :app:assembleRelease \
+ lint metalavaCheckCompatibilityRelease
+```
+Expected: `BUILD SUCCESSFUL`. If lint reports an issue in a file from Tasks 1 to 4, fix it there and include it in this commit.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add app/src/main/java/app/passwordstore/ui/main/MainActivity.kt \
+ docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md
+git commit -m "docs: record the autofill activities' Compose UI in the spec
+
+The autofill activities cannot join MainActivity's graph, so they keep
+opening the PGP key import through the setup intent; the spec and the
+ponytail note no longer promise otherwise. The spec gains an Autofill
+section.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: Emulator check, tablet checklist, push
+
+No real store or keys: the e2e environment from AGENTS.md only. A physical tablet may be connected over USB: every `adb` and Gradle install command in this task targets the emulator through `ANDROID_SERIAL`; never touch the tablet. The PIN `1234` below is the emulator's own test PIN, never the owner's.
+
+Autofill itself cannot run on the emulator (AGENTS.md: Settings is denylisted, WebView Shell is not a trusted browser). The activities are not exported, so they are launched directly from a root shell (`adb root`, available on the AOSP image without Google services) with test extras: the picker and the warning take only strings, so they can be driven fully. The decryption needs a `Bundle` client state, which `am start` cannot pass: here it is only checked to finish cleanly when started without one; its dialogs are covered by the entry view's shared `PassphraseDialog` (Step 3) and by the maintainer's tablet check.
+
+- [ ] **Step 1: Server, emulator, unlock, clean install, helpers**
+
+```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
+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 xyz.danix.hardenedpass.debug || true
+./gradlew :app:installDebug
+scripts/e2e-env.sh push-keys
+OUT=$(mktemp -d)
+```
+
+(`ANDROID_HOME` is the `sdk.dir` from `local.properties`. `set-pin` fails harmlessly when the AVD already has the PIN.)
+
+Helpers, the step 6 set plus `checked`, `logs` and the component names:
+
+```bash
+APP=xyz.danix.hardenedpass.debug
+X=app.passwordstore.autofill.oreo.ui
+CS=android.view.autofill.extra.CLIENT_STATE
+FILTER=$APP/app.passwordstore.ui.autofill.AutofillFilterView
+PUB=$APP/app.passwordstore.ui.autofill.AutofillPublisherChangedActivity
+DEC=$APP/app.passwordstore.ui.autofill.AutofillDecryptActivity
+SAVE=$APP/app.passwordstore.ui.autofill.AutofillSaveActivity
+ui() { adb exec-out uiautomator dump /dev/tty; }
+center() { local b; b=$(ui | grep -o "<node [^>]*$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 ))"; }
+tap() { local c; c=$(center "$1" "$2") && adb shell input tap $c; sleep 1; }
+field() { tap 'class="android.widget.EditText"' "$1"; adb shell input keycombination 113 29; adb shell input keyevent 67; adb shell input text "$2"; }
+value() { ui | grep -o '<node [^>]*class="android.widget.EditText"[^>]*>' | sed -n "${1}p" | grep -o ' text="[^"]*"' | cut -d'"' -f2; }
+seen() { ui | grep -q "$1" && echo "ok: $1" || echo "MISSING: $1"; }
+checked() { ui | grep -o "<node [^>]*$1[^>]*>" | head -1 | grep -o 'checked="[a-z]*"'; }
+top() { adb shell dumpsys activity activities | grep topResumedActivity; }
+open() { adb shell am start -n $APP/app.passwordstore.ui.main.LaunchActivity; sleep 3; }
+pick() { sleep 2; ui | grep -q "text=\"$1\"" || { tap 'content-desc="Show roots"'; tap 'text="Downloads"'; sleep 1; }; tap "text=\"$1\""; sleep 2; }
+secure() { adb shell dumpsys window | grep -c 'fl=.*SECURE'; }
+logs() { adb logcat -d | grep -c "$1"; }
+```
+
+- [ ] **Step 2: A cloned store to work with**
+
+```bash
+open; tap 'text="Clone Remote Repo"'
+field 1 ssh://git@10.0.2.2:2222/srv/git/store.git
+tap 'text="Manage"'; tap 'text="Import SSH key"'; pick aps-e2e-ssh-key
+seen 'text="Imported private key"'; adb shell input keyevent 4; sleep 1
+tap 'text="Clone"'; sleep 15
+tap 'text="Import PGP key"' 2; pick aps-e2e-pgp-key.asc; tap 'text="OK"'; sleep 2
+seen 'text="example.org"'
+```
+Expected: the step 5 flow ends on the cloned store's list with `example.org`. (If the FAB is the first "Import PGP key" match on this image, use index 1, as in step 5.)
+
+- [ ] **Step 3: The shared passphrase dialog still works in the entry view**
+
+```bash
+tap 'text="example.org"'; sleep 2
+seen 'text="Password"'; secure
+adb shell input text test; tap 'text="OK"'; sleep 3
+seen 'text="alice"'
+adb shell input keyevent 4; sleep 1
+```
+Expected: the passphrase dialog (title "Password") with `secure` at `1` or more; after the e2e passphrase the entry shows the username `alice`; back returns to the list. (The entry screen is FLAG_SECURE: read it from the dump, never a screenshot.)
+
+- [ ] **Step 4: Root shell**
+
+```bash
+adb root; adb wait-for-device; sleep 2
+adb shell whoami
+```
+Expected: `root`. If `adb root` is refused on this image, skip Steps 5 to 8 (they need it) and record that the picker and the warning were only built and linted; the tablet checklist then covers them.
+
+- [ ] **Step 5: The picker (website)**
+
+```bash
+adb logcat -c
+adb shell am start -n $FILTER --es $CS x --es $X.EXTRA_FORM_ORIGIN_WEB example.org; sleep 3
+top
+seen 'Select entry to fill into'; ui | grep -o 'text="example.org"' | wc -l
+value 1
+seen 'text="work"'; seen 'text="mail.example.org"'; seen 'text="otp"'
+seen 'text="Phishing-resistant search"'; checked 'text="Phishing-resistant search"'
+checked 'text="Match with example.org"'; checked 'text="Clear existing matches"'
+adb exec-out screencap -p > "$OUT/picker.png"
+tap 'text="Phishing-resistant search"'; field 1 example; sleep 2
+seen 'text="otp"'; seen 'text="example.com"'
+field 1 nothing.invalid; sleep 2; seen 'text="No results."'
+adb shell cmd uimode night yes; sleep 3
+value 1; checked 'text="Phishing-resistant search"'; seen 'text="No results."'
+adb exec-out screencap -p > "$OUT/picker-dark.png"
+adb shell cmd uimode night no; sleep 3
+tap 'content-desc="Clear search"'; sleep 2; seen 'text="otp"'
+field 1 example.org; tap 'text="Phishing-resistant search"'; sleep 2
+tap 'text="example.org"' 2; sleep 3
+top
+adb shell run-as $APP cat shared_prefs/oreo_autofill_web_matches.xml
+logs 'AutofillDecryptActivity started without EXTRA_CLIENT_STATE'
+```
+Expected: the picker is on top (`AutofillFilterView`) in a window narrower and shorter than the screen; the header names `example.org`; `text="example.org"` counts `2` (the field and the row; the header is one longer text, so the row is the second match, which the final tap uses); the field holds `example.org`; with strict search (`checked="true"`) the rows are `example.org` and `work` (subtitle `mail.example.org`) and `otp` is `MISSING`; match is `checked="true"`, clear `checked="false"`. With strict search off and `example`, `otp` (subtitle `example.com`) appears; `nothing.invalid` shows "No results."; the night mode recreation keeps `nothing.invalid`, strict off and the empty state; clearing the search lists every entry. Tapping `example.org` records the match (`matches;example.org` holding the path of `example.org.gpg`), starts the decryption, which finishes at once (count `1`: the string extra is not a `Bundle`), and the picker finishes with it: `MainActivity` is on top again.
+
+- [ ] **Step 6: The picker (app) and tap outside**
+
+```bash
+adb shell am start -n $FILTER --es $CS x --es $X.EXTRA_FORM_ORIGIN_APP com.android.settings; sleep 3
+seen 'text="Phishing-resistant search"'; seen 'Match with'; value 1
+read W H < <(adb shell wm size | grep -oE '[0-9]+x[0-9]+' | tail -1 | tr x ' ')
+adb shell input tap $((W / 40)) $((H / 2)); sleep 2; top
+adb shell am start -n $FILTER --es $X.EXTRA_FORM_ORIGIN_WEB example.org; sleep 3; top
+```
+Expected: for an app origin the strict search switch is `MISSING`, the match label names the app ("Settings" in quotes) and the field holds `Settings`; a tap in the side margin outside the window (the picker is 75 % wide and centered, the tap is at 2.5 % of the width, mid-height) closes it (`MainActivity` on top); started without client state the picker finishes at once.
+
+- [ ] **Step 7: The publisher warning**
+
+```bash
+adb shell am start -n $PUB --es $X.EXTRA_APP_PACKAGE com.android.settings; sleep 3
+seen 'text="Possible phishing attempt"'; seen 'Settings'; seen 'Installed:'
+seen 'text="Keep Autofill disabled"'; seen 'text="Advanced information"'
+seen 'text="Re-enable Autofill"'; seen 'Package:'
+adb exec-out screencap -p > "$OUT/warning.png"
+tap 'text="Keep Autofill disabled"'; sleep 2; top
+adb shell am start -n $PUB --es $X.EXTRA_APP_PACKAGE com.android.settings; sleep 3
+tap 'text="Advanced information"'
+seen 'text="Re-enable Autofill"'; seen 'com.android.settings'; seen 'text="Advanced information"'
+adb shell cmd uimode night yes; sleep 3; seen 'text="Re-enable Autofill"'
+adb exec-out screencap -p > "$OUT/warning-dark.png"
+adb shell cmd uimode night no; sleep 3
+tap 'text="Re-enable Autofill"'; sleep 2; top
+adb logcat -c
+adb shell am start -n $PUB --es $X.EXTRA_APP_PACKAGE does.not.exist; sleep 3; top
+logs 'Failed to retrieve package info'
+```
+Expected: the warning shows title, app name, install time and the two buttons; "Re-enable Autofill" and the package details are `MISSING` at first. "Keep Autofill disabled" closes it (`MainActivity` on top). After "Advanced information" the re-enable button and the details (package and hash) appear and "Advanced information" is `MISSING`; a recreation keeps them; "Re-enable Autofill" closes it. An unknown package closes at once with one log line (count `1`).
+
+- [ ] **Step 8: Save activity regression (unchanged since step 4)**
+
+```bash
+adb shell am start -n $SAVE --es $X.EXTRA_NAME carol --es $X.EXTRA_FOLDER_NAME example.net \
+ --es $X.EXTRA_PASSWORD e2e-only-pw --es $X.EXTRA_SHOULD_MATCH_WEB example.net \
+ --ez $X.EXTRA_GENERATE_PASSWORD false; sleep 3
+secure; value 1
+tap 'text="Save"'; sleep 4; top
+adb shell run-as $APP cat shared_prefs/oreo_autofill_web_matches.xml
+open; seen 'text="example.net"'
+```
+Expected: the editor prefilled with `carol` in folder `example.net`, `secure` at `1` or more; Save closes it, the web matches gain `matches;example.net`, and the list shows the `example.net` folder.
+
+- [ ] **Step 9: Tear down and push**
+
+```bash
+adb unroot; adb wait-for-device
+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
+unset ANDROID_SERIAL
+rm -rf "$OUT"
+git push
+```
+Expected: push succeeds to every `origin` push URL. Look at the four screenshots before deleting `$OUT` if a visual check is wanted (copy them elsewhere first).
+
+- [ ] **Step 10: Hand the tablet checklist to the maintainer**
+
+Do not run this: it uses the maintainer's tablet. Report it with the results of Steps 1 to 8. Setup per AGENTS.md ("Autofill cannot be tested on the emulator image"): record `secure autofill_service` and `global stay_on_while_plugged_in`, `svc power stayon usb`, `adb reverse tcp:2222 tcp:2222` for the e2e server, a local login page through `python3 -m http.server` and `adb reverse`, the debug build set as autofill service, the e2e store cloned over `ssh://git@127.0.0.1:2222/srv/git/store.git`; restore everything afterwards.
+
+1. **Fill from a match** (Firefox, the test page as `example.org` or its own matched entry): the suggestion fills username and password. With the passphrase cache off, the Compose passphrase dialog appears over Firefox; a screenshot of it is blocked (FLAG_SECURE); Cancel fills nothing and leaves no dimmed window; a wrong passphrase fills nothing and leaves no window.
+2. **Passphrase cache on** (Settings, PGP): the screen lock prompt appears (Android 14: not dismissed at once), the first fill asks the passphrase, the next one uses the cache; screen off and on, then the cache is cleared if "clear on screen off" is checked.
+3. **Cache only after the screen lock** (clear the cache first: screen off and on with "clear on screen off" checked): fill, cancel the screen lock prompt, type the passphrase: the fill works. Fill again and approve the prompt: the passphrase is asked again (nothing was cached after the cancelled prompt). Type it: the next fill with the prompt approved uses the cache.
+4. **No keys imported** (delete the key in the key manager first): "No keys imported" appears over Firefox; Import opens the PGP keys screen; after importing the e2e key, with the passphrase cache on, the screen lock prompt appears over Firefox and stays up until answered (not dismissed at once), then the fill continues. Dismissing the "No keys imported" dialog leaves no window.
+5. **Search entry** (the "Search entry" chip or dataset): the picker opens floating with the site prefilled, phishing-resistant search on, rows named by identifier; search, clear, toggle strict search; tap an entry: it fills, and the next visit offers it directly as a match. "Clear existing matches" on the next pick replaces the old match. A tap outside closes it. Light and dark theme, Italian locale.
+6. **Create entry** (generate): the editor opens with a generated password, Save fills it and stores the entry (unchanged since step 4).
+7. **Save** after submitting a login form, in a browser on the save list (Firefox Nightly or Fenix builds; Firefox release cannot, see "Out of scope"): the editor opens prefilled, Save stores it.
+8. **Publisher changed**, if reproducible: match an app's login to an entry, then replace the app with the same package signed by another key (for example the F-Droid build after the upstream one); its login offers "Possible phishing attempt"; the warning shows the app and install time; "Keep Autofill disabled" fills nothing; "Advanced information" then "Re-enable Autofill" fills and the warning does not return for it. If a translucent overlay app (a screen dimmer, say) is at hand: with it drawn over the warning, taps on the buttons do nothing and back still closes the warning.
+9. **Still owed from step 6:** the OK of Settings' autofill instructions dialog (it opens the system's autofill service picker).
+
+---
+
+## Self-review notes
+
+- **Spec coverage for step 7:** the four activities stay separate (Architecture, Activities) and draw with `APSTheme` (Tasks 2, 3, 4; `AutofillSaveActivity` since step 4); the setup intent question is settled and the spec reworded (Task 5); intents, parsing, crypto and preference keys unchanged (feature parity tables). The one behavior change outside the UI, the passphrase cache rule, is a maintainer decision and brings autofill in line with the entry view. The spec gains an Autofill section (Task 5).
+- **Security:** the picker records a match only for the tapped row and only with "Match with" on, and starts one decryption (Task 2); the publisher warning has no path to `RESULT_OK` but "Advanced information" then "Re-enable Autofill", the reset still clears matches first, and touches arriving through another app's window are dropped (Task 3; Task 6 Step 7); the decryption keeps `FLAG_SECURE`, the passphrase stays in the dialog's `remember` and goes straight to decryption, it is cached only after a successful screen lock prompt (unit tested), every exit without a dataset is `RESULT_CANCELED`, and the no-keys dialog can no longer leave an invisible window (Task 4). No new log lines carry secrets; the moved log lines are the old ones. No new strings, extras or exported components.
+- **Fixes found while reading the legacy code:** the decryption flow ran on every `onStart` (two passphrase dialogs after screen off and on, a double run after the key import); the screen lock prompt from `onStart` likely hit the Android 14 eviction fixed for `LaunchActivity`; a dismissed "No keys imported" dialog left a transparent, dimmed window over the app being filled; a double tap in the picker started two decryptions; rotation during the decryption restarted the flow; autofill cached a passphrase typed after a cancelled or unavailable screen lock prompt.
+- **Review findings applied:** after a key import the flow restarts on the next window focus (`started = false`), never directly from the result callback; `start()` returns when finishing; the decrypt activity's `configChanges` cover resizing and keyboards too; `noKeys` and `passphraseFor` replace a prompt enum and a stored closure; the picker's first search runs in `onCreate` (no directory filter needed) and its list is `GroupedSurface(Modifier.fillMaxWidth())`; the cold search flow's re-walk on `STARTED` is a `ponytail:` note; `@color/list_multiselect_background` and the stale `SelectableText` baseline entries go with their layouts; the warning's package details are local values; the emulator taps outside the picker in its side margin; the footer note names every locale but Italian; Task 2's separate smoke check is gone (Task 6 covers it).
+- **Not changed on purpose:** a wrong passphrase cancels the fill without retry (maintainer decision); an invalid `.gpg-id` shows a snackbar in the transparent window and stays until back, as before; `addMatchFor` still throws when the publisher changed between listing and picking (unchanged, the service checks earlier); the `DirectoryBased` identifier is the parent's whole path, so a nested entry's row name can contain a slash (upstream's convention, `autofillRows` only presents it).
+- **Not checked on the emulator:** the decryption's dialogs, the screen lock prompt, the passphrase cache rule in a real fill, the fill itself, the save request, the real publisher change and the tapjacking guard (it needs an overlay app); all are in the tablet checklist (Task 6 Step 10) or covered by review and the unit test. The shared passphrase dialog is checked through the entry view (Task 6 Step 3).
+- **Deliberate shortcuts (`ponytail:`):** a recreation of the decryption cancels the fill (Task 4, activity and manifest); the warning footer loses its bold in every locale but Italian (Task 3); the picker's cold search flow walks the store again each time it returns to `STARTED` (Task 2).
+- **Type and name consistency:** `autofillRows(files: List<File>, root: File, structure: DirectoryStructure): ImmutableList<ListRow>` (Task 1) is used with these arguments in Task 2. `cachesPassphrase(cacheEnabled: Boolean, auth: BiometricResult): Boolean` (Task 4 Step 3) is used in `decryptCredential`. `ListRow(path, label, subtitle, count)` is the list's existing class. `AutofillFilterScreen(origin, query, rows, showStrict, strict, matchLabel, match, clear, onQueryChange, onStrictChange, onMatchChange, onClearChange, onPick, modifier)` (Task 2 Step 1) matches its call in Step 2. `PublisherChangedScreen(appName, installed, details, onKeepDisabled, onReset, modifier)` (Task 3) matches its call. `PassphraseDialog(wrong, cacheEnabled, clearCache, onSubmit, onCancel)` is the entry view's existing signature, made `internal` (Task 4 Step 5). `PreferenceSwitch(title, checked, onCheckedChange)`, `EntryRow(name, subtitle, onClick)`, `GroupedSurface(modifier, content)` and `MainActivity.setupIntent(context, SetupStart.PgpImport)` are existing APIs.
+- **Build order:** Task 1 adds an unused function; Tasks 2, 3 and 4 each replace one activity's UI and delete what it orphaned, so lint stays clean after every task (`dimens.xml` goes with its last user in Task 4); Task 5 changes only a comment and the spec and runs the full CI set.
+
+## Out of scope
+
+- **Firefox release in the autofill save list.** `org.mozilla.firefox` (and `firefox_beta`) are trusted for filling but missing from `BROWSER_SAVE_FLAG` in `autofill-parser/.../FeatureAndTrustDetection.kt`, so saving after a login does nothing there. Not in step 7 (maintainer decision): a separate fix on `master` later, after a tablet test that Firefox release sends save requests with the unmasked password.
+
+## Maintainer decisions (2026-11-04)
+
+1. **Firefox release save support:** not in step 7; a separate fix on `master` later (Out of scope).
+2. **Wrong passphrase during autofill:** keep cancelling the fill (Task 4).
+3. **Passphrase cache gap:** fixed in step 7. Autofill caches the passphrase only after a successful screen lock prompt, as the entry view does (`cachesPassphrase`, unit tested, Task 4; tablet checklist item 2).
+4. **Tapjacking guard on the publisher warning:** added, `filterTouchesWhenObscured` on the decor view (Task 3). The planned look stays: "Keep Autofill disabled" is the primary button, the title is red.
+5. **Layouts:** deleted in step 7, each by the task that replaces it (Tasks 2, 3, 4).
+6. **Picker rows:** identifier as the name, "account · folders" as the subtitle (Tasks 1, 2).
+
+No open questions remain.
diff --git a/docs/superpowers/plans/2026-11-04-compose-cleanup.md b/docs/superpowers/plans/2026-11-04-compose-cleanup.md
new file mode 100644
index 00000000..1e23ed22
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-04-compose-cleanup.md
@@ -0,0 +1,1712 @@
+# Compose UI overhaul, step 8: Cleanup and merge. 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:** Delete what the Compose overhaul left without users (Fragment and view binding helpers, the RecyclerView-era navigation stack, upstream leftovers, `BasePGPActivity`, the View-era dependencies, dead styles and attributes), name HardenedPass in every locale, record the finished overhaul in AGENTS.md, the spec and CONTRIBUTING.md, run the full CI set and an emulator regression pass, hand a signed release build of `compose` to the maintainer for the phone test, and only after the maintainer's explicit go merge `compose` into `master` and delete `compose`.
+
+**Architecture:** No new code paths. Five small commits on `compose` remove or move code (each builds, tests and lints clean on its own), one docs commit closes the overhaul, then a maintainer gate: `master` stays at 0.1.5 until the maintainer has tested a release build of `compose` on the phone. After the go, one signed `--no-ff` merge commit lands it on `master` and `compose` is deleted. Material Components (Views) stays for the window themes, the git operations' View dialogs, `DynamicColors` and the snackbars. Two layouts stay: `oreo_autofill_dataset.xml` (`RemoteViews`) and `git_credential_layout.xml` (the git credential prompt, moved off ConstraintLayout). No release in this step.
+
+**Tech Stack:** Kotlin 2.4.21, AGP 9.5.0, Gradle version catalog, Hilt (KSP), Compose BOM 2024.09.03, Material Components 1.12.0 (kept), JGit 5.13, the e2e Docker git server and the `aps-test` AVD.
+
+Spec: `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md` (sections "Removed at the end", "Branching", "Testing", "Migration order" step 8). Earlier plans: `2026-11-02-compose-foundation.md`, `2026-11-02-compose-list.md`, `2026-11-02-compose-entry.md`, `2026-11-02-compose-editor.md`, `2026-11-03-compose-setup.md`, `2026-11-03-compose-settings.md`, `2026-11-04-compose-autofill.md` (same folder).
+
+## Decisions taken in this plan
+
+- **Survey, not assumptions.** Every deletion below was checked with a grep for its users on `compose` at `9f4e4f12`. Each task repeats the grep before deleting, because a later fix commit could add a user.
+- **Material Components (Views) stays** (maintainer decision 1). Things that still need it: `AppThemeM3` and its children derive from `Theme.Material3.DayNight` (the AppCompat activities need an AppCompat theme), the git operations' View dialogs (`BaseGitActivity.promptOnErrorHandler`, `GitOperation.onMissingSshKeyFile`, `CredentialFinder` with `TextInputLayout` and `MaterialCheckBox`, `BreakOutOfDetached.preExecute`, `StoreActions.moveFile` and `confirm`), `DynamicColors` in `Application`, and `FragmentActivity.snackbar` (28 call sites, used by Compose destinations and by `GitCommandExecutor`). Moving those to Compose is a later change on `master`.
+- **`git_credential_layout.xml` stays, without ConstraintLayout** (maintainer decision 4). It is the only user of `androidx.constraintlayout`; a vertical `LinearLayout` gives the same layout, so our direct dependency goes. Material Components still pulls ConstraintLayout transitively; R8 drops its unused classes.
+- **`fragment-ktx` stays as the version pin of `androidx.fragment`.** Nothing calls its Kotlin extensions after Task 1, but without it `androidx.fragment` resolves through `appcompat` 1.7.0 to 1.5.4 instead of 1.8.4, a silent downgrade of the library `FragmentActivity` and `BiometricPrompt` run on. Keeping the declaration (with a comment) is the smallest diff; Task 3 checks the resolved version.
+- **`recyclerview` is not pinned.** After its direct declaration goes, it resolves transitively (through Material Components' `viewpager2`), possibly to a release candidate. Acceptable: no app code and no View still in use references `RecyclerView`, so R8 removes it from the release APK; pinning would keep a declaration for a library the app does not use. Task 3 records the resolved version.
+- **`BasePGPActivity` is folded into `AutofillDecryptActivity`,** its only subclass. Its `FLAG_SECURE` line and three injected fields move into the activity. Its companion and top-level helpers move to `ui/crypto/CryptoExtensions.kt` (the same file, renamed with `git mv`, so blame follows): `getParentPath` and `getLongName` become top-level functions, `getRelativePath` becomes private. `EXTRA_FILE_PATH` (`"FILE_PATH"`) moves to `LaunchActivity`'s companion next to `ACTION_DECRYPT_PASS`; its value must not change, home screen shortcuts carry it.
+- **`getPGPIdentifiers` reports an invalid `.gpg-id` with a Toast** on the application context. It showed a snackbar, which the autofill decryption never displays (it finishes at once on `null`). A Toast is drawn outside the activity's window, so it shows in the entry view, the editor and over the app being filled. The receiver becomes `Context`.
+- **`SearchableRepositoryViewModel` loses its RecyclerView-era navigation stack** and the unused `ListMode.DirectoriesOnly`. Only `navigateTo(dir, pushPreviousLocation = false)`, `search`, `forceRefresh`, `rows` and `searchResult` have callers. `navigateTo` keeps one parameter; the stack, `canNavigateBack`, `navigateBack`, `reset`, `NavigationStackEntry`, `PasswordItem.stableId` and `SearchResult.isFiltered` go.
+- **Upstream leftovers in Task 1:** `PasswordItem`'s four-argument `newCategory` and `newPassword` overloads (with `parent`), the `parent` property they alone filled, the `@JvmStatic` annotations (no Java code is left), the `"NAME"` extra that `createAuthEnabledIntent` writes and nothing reads, and the root `sentry.properties` (Sentry was removed with the `nonFree` flavor; no build file reads it).
+- **Dependencies removed:** `androidx.preference:preference`, `androidx.recyclerview:recyclerview`, `androidx.recyclerview:recyclerview-selection`, `me.zhanghai.android.fastscroll`, `de.maxr1998:modernandroidpreferences`, `androidx.constraintlayout:constraintlayout`, with their catalog entries. `swiperefreshlayout` is already gone. `androidx.activity.viewModels` still comes through `activity-compose`.
+- **View binding is switched off** in `build-logic` (`ApplicationPlugin`): nothing generates or reads binding classes. Lint's `UnusedIds` check, disabled "due to use of ViewBinding", is enabled again, and `FragmentFieldInjection` (fragments only) loses its disable line.
+- **Dead resources:** the four `animator/slide_*` files (only `FragmentExtensions` used them), `map_preference_widget_switch.xml` (an override of modernandroidpreferences' widget), `values/attrs.xml` (`Multiselected`, unused since step 7), `values/styles_material3.xml` and the `toolbarStyle` and `bottomSheetDialogTheme` theme items (no View `Toolbar` or `BottomSheetDialog` is left), and the stale `app/lint.xml` ignores for deleted layouts.
+- **Kept for good:** `oreo_autofill_dataset.xml`, `ic_person_black_24dp`, `ic_warning_red_24dp`, `DialogLikeThemeM3`, `NoBackgroundThemeM3`, `NoActionBarThemeM3`, `AppThemeM3`, `APSThemeM3`, `values/bools.xml`. Every drawable in `res/drawable` has a user. No manifest entry, ProGuard rule or `app/lint-baseline.xml` entry references a deleted class or file.
+- **Locales** (maintainer decision 3). Eight locales still say "Password Store" in 22 strings: 20 name the app (autofill dialog, publisher warning, biometric summary) and become "HardenedPass"; the two German ones that mean the store itself become "Repository", the word that translation uses 21 times. 0.1.4 fixed English and Italian only.
+- **Phone test before merge** (maintainer decision 6). `master` stays at 0.1.5 until the maintainer says go. The agent builds a release APK from `compose`, signed with the local release keystore, with `master`'s version (0.1.5, code 105) applied only for that build, so it installs over the phone's 0.1.5 in place; the agent never touches the phone.
+- **Merge with a signed `--no-ff` merge commit** (maintainer decision 5). A fast-forward is impossible (`master` has the 0.1.5 release); rebasing would rewrite 74 pushed commits. A trial `git merge-tree` shows one conflict, in AGENTS.md: the `LaunchActivity` bullet next to the sshj bullet. The public suffix list date auto-merges to `master`'s `2026-11-03` as long as `compose` does not touch that line (Task 7 does not).
+- **Order.** Code (Tasks 1 to 5), emulator regression (Task 6), docs and push of `compose` (Task 7), the maintainer's phone gate (Task 8), merge after the explicit go (Task 9), branch deletion (Task 10).
+
+## Lessons from steps 1 to 7, designed in
+
+- **Lint per task.** Lint runs with warnings as errors: a resource left without a user fails `UnusedResources`, so each task deletes what it orphans. Each code task's check includes `:app:lintDebug`; Tasks 6 and 9 run the full CI set from AGENTS.md.
+- **`CI=1`** turns Kotlin warnings into errors: every build check below uses it.
+- **Strings.** Deleting a string from `values/` means deleting it from every `values-*/strings.xml` (lint `ExtraTranslation`). This plan deletes no string; if lint reports one unused, delete it in all locales.
+- **Device safety.** The agent's shell keeps no functions or variables between Bash calls, and helper names like `pass`, `open`, `top` or `sync` would run real commands (`/usr/bin/pass` would reach the maintainer's real store). Task 6 writes its environment and `e2e_`-prefixed helpers to a file in the executing session's scratchpad, sources it at the top of every step and stops unless `ANDROID_SERIAL` is `emulator-5554`. A physical tablet may be connected and is never touched; the PIN `1234` is the emulator's own test PIN.
+- **Security.** No secret in a log, Bundle or saved state; `FLAG_SECURE` windows stay `FLAG_SECURE`; the `FILE_PATH` extra keeps its value and its `isFileInRepo` checks.
+
+**Security-relevant tasks** (code review after implementation, before the next task): Task 2 (the autofill decryption window's `FLAG_SECURE`, `.gpg-id` recipient parsing, the exported `LaunchActivity`'s extra), Task 4 (the git credential prompt).
+
+**Maintainer gates:** Task 8 (phone test of the `compose` release build; nothing merges before an explicit go) and Task 10 Step 1 (go to delete `compose`, implied by a successful Task 9 unless the maintainer says otherwise).
+
+**Conventions for every task**
+
+- New files get this header:
+ ```kotlin
+ /*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+ ```
+ Modified or moved upstream files keep their header.
+- Commits are GPG-signed (default config, never disable). End each message with
+ `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
+- No em dashes in docs, comments or commit messages. No personal details.
+- Before each commit run `./gradlew spotlessApply`, then the task's checks.
+- Tasks 1 to 8 work on `compose`; Task 9 on `master`.
+- Never stage `HANDOFF.md`.
+
+## File map
+
+| File | Task | Change |
+|---|---|---|
+| `app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt` | 1 | delete |
+| `app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt` | 1 | delete |
+| `app/src/main/res/animator/slide_{in,out}_{left,right}.xml` | 1 | delete |
+| `sentry.properties` | 1 | delete |
+| `app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt` | 1 | drop `resolveAttribute`, `isPermissionGranted`, `launchActivity`, `conditional` |
+| `app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt` | 1 | drop the navigation stack, `stableId`, `isFiltered`, `DirectoriesOnly` |
+| `app/src/main/java/app/passwordstore/data/password/PasswordItem.kt` | 1, 2 | drop `parent`, overloads, `@JvmStatic`, `"NAME"`; imports, extra constant |
+| `app/src/main/java/app/passwordstore/ui/list/ListDestination.kt` | 1 | `navigateTo(dir)`, two comments |
+| `app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt` | 2 | `git mv` to `CryptoExtensions.kt`, class removed |
+| `app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt` | 2 | create |
+| `app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt` | 2 | extend `AppCompatActivity`, own `FLAG_SECURE` and fields |
+| `app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt`, `MainActivity.kt` | 2 | `EXTRA_FILE_PATH` |
+| `app/src/main/java/app/passwordstore/ui/list/StoreActions.kt`, `ui/entry/EntryDestination.kt` | 2 | imports |
+| `app/build.gradle.kts`, `gradle/libs.versions.toml` | 3, 4 | dependencies |
+| `build-logic/src/main/kotlin/app/passwordstore/gradle/ApplicationPlugin.kt` | 3 | view binding off |
+| `build-logic/src/main/kotlin/app/passwordstore/gradle/LintConfig.kt` | 3 | two disables removed |
+| `app/lint.xml` | 3 | stale ignores removed |
+| `app/src/main/res/layout/map_preference_widget_switch.xml`, `values/attrs.xml`, `values/styles_material3.xml` | 3 | delete |
+| `app/src/main/res/values/themes_material3.xml` | 3 | two items and one style removed |
+| `app/src/main/res/layout/git_credential_layout.xml` | 4 | `LinearLayout` |
+| `app/src/main/res/values-{de,fr,gl,ko-rKR,pl-rPL,pt-rBR,ru,zh-rCN}/strings.xml` | 5 | 22 strings |
+| `AGENTS.md`, the spec, `CONTRIBUTING.md` | 7 | docs |
+
+---
+
+### Task 1: Dead Fragment, view, navigation stack and upstream leftovers
+
+**Files:**
+- Delete: `app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt`, `app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt`
+- Delete: `app/src/main/res/animator/slide_in_left.xml`, `slide_in_right.xml`, `slide_out_left.xml`, `slide_out_right.xml`
+- Delete: `sentry.properties`
+- Modify: `app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt`
+- Modify: `app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt`
+- Modify: `app/src/main/java/app/passwordstore/data/password/PasswordItem.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/list/ListDestination.kt`
+
+- [ ] **Step 1: Confirm the candidates have no users**
+
+```bash
+git switch compose && git status --short
+grep -rnw --include=*.kt -E 'performTransactionWithBackStack|viewBinding|FragmentViewBindingDelegate|resolveAttribute|launchActivity|conditional|isPermissionGranted|stableId|navigateBack|canNavigateBack|NavigationStackEntry|isFiltered|DirectoriesOnly' app/src
+grep -rn --include=*.kt -E 'R\.animator\.|\.reset\(\)' app/src/main
+grep -rn --include=*.kt 'navigateTo(' app/src
+grep -rn --include=*.kt -E 'newCategory|newPassword|\.parent\b.*PasswordItem|PasswordItem\(|"NAME"|getStringExtra\("NAME' app/src
+grep -rn --include=*.kts --include=*.properties --include=*.yml --include=*.pro -i sentry . | grep -v /build/
+```
+Expected:
+- First grep: only lines in `FragmentExtensions.kt`, `FragmentViewBindingDelegate.kt`, `AndroidExtensions.kt` (the definitions) and `SearchableRepositoryViewModel.kt`.
+- Second: only `FragmentExtensions.kt` (animators) and JGit `git.reset()` calls in `util/git/operation/` and `JGitBehaviourTest.kt` (not the ViewModel's `reset`).
+- Third: `ListDestination.kt:92` (`viewModel.navigateTo(dir, pushPreviousLocation = false)`), the definition, and the ViewModel's own internal calls `navigateTo(oldDir, pushPreviousLocation = false)` (in `navigateBack`) and `navigateTo(pushPreviousLocation = false)` (in `reset`), both removed below.
+- Fourth: only the three-argument calls in `SearchableRepositoryViewModel.kt` (`toPasswordItem`) and `PasswordRepository.kt`, the constructor calls inside `PasswordItem.kt`, and `PasswordItem.kt`'s `putExtra("NAME", ...)`; nothing reads `"NAME"`.
+- Fifth: only `sentry.properties` itself (no build file, workflow or ProGuard rule reads it).
+
+Anything else: stop and report.
+
+- [ ] **Step 2: Delete the Fragment helpers, their animators and `sentry.properties`**
+
+```bash
+git rm app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt \
+ app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt \
+ app/src/main/res/animator/slide_in_left.xml app/src/main/res/animator/slide_in_right.xml \
+ app/src/main/res/animator/slide_out_left.xml app/src/main/res/animator/slide_out_right.xml \
+ sentry.properties
+```
+
+- [ ] **Step 3: `AndroidExtensions.kt`**
+
+Delete these four functions:
+
+```kotlin
+/** Resolve [attr] from the [Context]'s theme */
+fun Context.resolveAttribute(attr: Int): Int {
+ val typedValue = TypedValue()
+ this.theme.resolveAttribute(attr, typedValue, true)
+ return typedValue.data
+}
+```
+
+```kotlin
+/** Check if [permission] has been granted to the app. */
+fun FragmentActivity.isPermissionGranted(permission: String): Boolean {
+ return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
+}
+```
+
+```kotlin
+/** Launch an activity denoted by [clazz]. */
+fun <T : ComponentActivity> ComponentActivity.launchActivity(clazz: Class<T>) {
+ startActivity(Intent(this, clazz).setAction(Intent.ACTION_VIEW))
+}
+```
+
+```kotlin
+/** Allows conditionally applying the given [modifier] if [isEnabled] is `true`. */
+fun Modifier.conditional(isEnabled: Boolean, modifier: Modifier.() -> Modifier): Modifier {
+ return if (isEnabled) {
+ then(modifier())
+ } else {
+ this
+ }
+}
+```
+
+and the imports only they used:
+
+```kotlin
+import android.content.Intent
+import android.util.TypedValue
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.Modifier
+import androidx.core.content.ContextCompat
+```
+
+(`PackageManager` stays: `getPackageInfoCompat` and `getApplicationInfoCompat` use it. `FragmentActivity` stays: `commitChange` and `snackbar` use it.)
+
+- [ ] **Step 4: `SearchableRepositoryViewModel.kt`**
+
+Delete `import android.os.Parcelable` and
+
+```kotlin
+val PasswordItem.stableId: String
+ get() = file.absolutePath
+
+```
+
+In `enum class ListMode`, delete the line ` DirectoriesOnly,`, and in the `prefilteredResultFlow` `when` delete
+
+```kotlin
+ ListMode.DirectoriesOnly ->
+ listResultFlow.filter { it.isDirectory }.flowOn(dispatcherProvider.io())
+```
+
+Replace
+
+```kotlin
+ data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean)
+```
+
+with
+
+```kotlin
+ data class SearchResult(val passwordItems: List<PasswordItem>)
+```
+
+and
+
+```kotlin
+ SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter)
+```
+
+with
+
+```kotlin
+ SearchResult(passwordList)
+```
+
+Replace everything from `data class NavigationStackEntry(...)` through the end of `fun reset() { ... }`:
+
+```kotlin
+ data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?)
+
+ private val navigationStack = ArrayDeque<NavigationStackEntry>()
+
+ fun navigateTo(
+ newDirectory: File = root,
+ listMode: ListMode = ListMode.AllEntries,
+ recyclerViewState: Parcelable? = null,
+ pushPreviousLocation: Boolean = true,
+ ) {
+ if (!newDirectory.exists()) return
+ require(newDirectory.isDirectory) { "Can only navigate to a directory" }
+ if (pushPreviousLocation) {
+ navigationStack.addFirst(NavigationStackEntry(_currentDir.value, recyclerViewState))
+ }
+ searchActionFlow.update {
+ makeSearchAction(
+ filter = "",
+ baseDirectory = newDirectory,
+ filterMode = FilterMode.NoFilter,
+ searchMode = SearchMode.InCurrentDirectoryOnly,
+ listMode = listMode,
+ )
+ }
+ _currentDir.update { newDirectory }
+ }
+
+ val canNavigateBack
+ get() = navigationStack.isNotEmpty()
+
+ /**
+ * Navigate back to the last location on the [navigationStack] and restore a cached scroll
+ * position if possible.
+ *
+ * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached.
+ */
+ fun navigateBack(): Parcelable? {
+ if (!canNavigateBack) return null
+ val (oldDir, oldRecyclerViewState) = navigationStack.removeFirst()
+ navigateTo(oldDir, pushPreviousLocation = false)
+ return oldRecyclerViewState
+ }
+
+ fun reset() {
+ navigationStack.clear()
+ forceUpdateOnNextSearchAction()
+ navigateTo(pushPreviousLocation = false)
+ }
+```
+
+with
+
+```kotlin
+ /** Lists [newDirectory]; the Compose list keeps the back stack (one route per folder). */
+ fun navigateTo(newDirectory: File) {
+ if (!newDirectory.exists()) return
+ require(newDirectory.isDirectory) { "Can only navigate to a directory" }
+ searchActionFlow.update {
+ makeSearchAction(
+ filter = "",
+ baseDirectory = newDirectory,
+ filterMode = FilterMode.NoFilter,
+ searchMode = SearchMode.InCurrentDirectoryOnly,
+ listMode = ListMode.AllEntries,
+ )
+ }
+ _currentDir.update { newDirectory }
+ }
+```
+
+(`forceUpdateOnNextSearchAction` keeps its caller, `forceRefresh`.)
+
+- [ ] **Step 5: `PasswordItem.kt`**
+
+Delete the constructor parameter ` val parent: PasswordItem? = null,` and the line ` intent.putExtra("NAME", toString())`. Replace the companion's four factories:
+
+```kotlin
+ @JvmStatic
+ fun newCategory(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
+ return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir)
+ }
+
+ @JvmStatic
+ fun newCategory(name: String, file: File, rootDir: File): PasswordItem {
+ return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir)
+ }
+
+ @JvmStatic
+ fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
+ return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir)
+ }
+
+ @JvmStatic
+ fun newPassword(name: String, file: File, rootDir: File): PasswordItem {
+ return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir)
+ }
+```
+
+with
+
+```kotlin
+ fun newCategory(name: String, file: File, rootDir: File): PasswordItem {
+ return PasswordItem(name, TYPE_CATEGORY, file, rootDir)
+ }
+
+ fun newPassword(name: String, file: File, rootDir: File): PasswordItem {
+ return PasswordItem(name, TYPE_PASSWORD, file, rootDir)
+ }
+```
+
+(`equals`, `hashCode` and `compareTo` never read `parent`. Shortcuts already pinned on a home screen carry a `"NAME"` extra that nothing reads; they keep working.)
+
+- [ ] **Step 6: `ListDestination.kt`**
+
+Replace ` viewModel.navigateTo(dir, pushPreviousLocation = false)` with ` viewModel.navigateTo(dir)`.
+
+In the KDoc above `fun ListDestination`, replace
+
+```kotlin
+ * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the
+ * folder picker, the legacy activities and the store operations. [onOpenFolder] takes a path
+```
+
+with
+
+```kotlin
+ * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the
+ * folder picker and the store operations. [onOpenFolder] takes a path
+```
+
+and replace
+
+```kotlin
+ // Every return to this folder (back from a child, or from a legacy activity) re-reads it.
+```
+
+with
+
+```kotlin
+ // Every return to this folder (back from a child, or from another activity) re-reads it.
+```
+
+- [ ] **Step 7: Check**
+
+```bash
+./gradlew spotlessApply
+CI=1 ./gradlew spotlessCheck :app:testDebugUnitTest -PslimTests :app:assembleDebug :app:lintDebug
+grep -rn --include=*.kt -E 'androidx\.fragment\.app\.(Fragment$|FragmentManager|commit)|ViewBinding|JvmStatic' app/src
+```
+Expected: `BUILD SUCCESSFUL` (`StrictDomainRegexTest` still passes: `generateStrictDomainRegex` is untouched); the grep prints nothing.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add -A app/src/main/java/app/passwordstore app/src/main/res/animator sentry.properties
+git status --short
+git commit -m "refactor(app): drop dead Fragment, view and navigation stack helpers
+
+The Compose screens use no Fragments or view binding, so
+FragmentExtensions (with its four slide animators) and
+FragmentViewBindingDelegate have no users, nor do resolveAttribute,
+isPermissionGranted, launchActivity and the unused Modifier.conditional.
+SearchableRepositoryViewModel keeps one navigateTo: the Compose list owns
+the back stack, so the RecyclerView-era navigation stack, stableId,
+isFiltered and the unused DirectoriesOnly mode go. PasswordItem loses
+the parent overloads, @JvmStatic and the unread NAME extra, and the
+leftover sentry.properties goes too.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+Expected: `git status --short` before the commit lists only the seven deletions and four modifications.
+
+---
+
+### Task 2: Fold `BasePGPActivity` into the autofill decryption (security-relevant)
+
+**Files:**
+- Move: `app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt` to `app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt`
+- Create: `app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt`, `MainActivity.kt`
+- Modify: `app/src/main/java/app/passwordstore/data/password/PasswordItem.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/list/StoreActions.kt`, `app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt`
+
+- [ ] **Step 1: Confirm the users**
+
+```bash
+grep -rn --include=*.kt --include=*.xml --include=*.pro -E 'BasePGPActivity|getParentPath|getLongName|getRelativePath|copyTextToClipboard|copyPasswordToClipboard|getPGPIdentifiers|"FILE_PATH"' app/src
+```
+Expected users outside `BasePGPActivity.kt`: `PasswordItem.kt` (`getLongName`, `"FILE_PATH"`), `SshKeyScreen.kt` (`copyTextToClipboard`), `EditorDestination.kt` (`copyPasswordToClipboard`, `getPGPIdentifiers`), `MainActivity.kt` and `LaunchActivity.kt` (`EXTRA_FILE_PATH`), `AutofillDecryptActivity.kt` (subclass, `getPGPIdentifiers`, `getParentPath`), `StoreActions.kt` (`getLongName`), `EntryDestination.kt` (`getParentPath`, both copies, `getPGPIdentifiers`). No manifest, ProGuard or test reference. Anything else: stop and report.
+
+- [ ] **Step 2: Write the failing test**
+
+Create `app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.crypto
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/** Pins the path helpers' behavior across their move out of BasePGPActivity. */
+class CryptoExtensionsTest {
+
+ @Test
+ fun parentPathIsRelativeToTheStore() {
+ assertEquals("/social/", getParentPath("/store/social/facebook.gpg", "/store"))
+ assertEquals("/", getParentPath("/store/example.org.gpg", "/store"))
+ }
+
+ @Test
+ fun longNameJoinsFolderAndName() {
+ // StoreActions passes absolute folders, PasswordItem its path relative to the store.
+ assertEquals("work/x", getLongName("/store/work", "/store", "x"))
+ assertEquals("social/facebook", getLongName("/social/", "/store", "facebook"))
+ assertEquals("x", getLongName("/store", "/store", "x"))
+ }
+}
+```
+
+- [ ] **Step 3: Run it to see it fail**
+
+Run: `./gradlew :app:testDebugUnitTest -PslimTests --tests app.passwordstore.ui.crypto.CryptoExtensionsTest`
+Expected: compilation FAIL, `Unresolved reference 'getParentPath'` (they are companion members today).
+
+- [ ] **Step 4: Move the file and drop the class**
+
+```bash
+git mv app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt \
+ app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt
+```
+
+In `CryptoExtensions.kt`, replace everything from the first import through the end of the class (the line `}` that closes `open class BasePGPActivity`) with:
+
+```kotlin
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.PersistableBundle
+import android.widget.Toast
+import androidx.annotation.StringRes
+import androidx.fragment.app.FragmentActivity
+import app.passwordstore.R
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.util.extensions.clipboard
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.extensions.snackbar
+import app.passwordstore.util.services.ClipboardService
+import app.passwordstore.util.settings.Constants
+import app.passwordstore.util.settings.PreferenceKeys
+import java.io.File
+
+/** Gets the relative path to the repository */
+private fun getRelativePath(fullPath: String, repositoryPath: String): String =
+ fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
+
+/** Gets the Parent path, relative to the repository */
+fun getParentPath(fullPath: String, repositoryPath: String): String {
+ val relativePath = getRelativePath(fullPath, repositoryPath)
+ val index = relativePath.lastIndexOf("/")
+ return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/"
+ .replace("/+".toRegex(), "/")
+}
+
+/** /path/to/store/social/facebook.gpg -> social/facebook */
+fun getLongName(fullPath: String, repositoryPath: String, basename: String): String {
+ var relativePath = getRelativePath(fullPath, repositoryPath)
+ return if (relativePath.isNotEmpty() && relativePath != "/") {
+ // remove preceding '/'
+ relativePath = relativePath.substring(1)
+ if (relativePath.endsWith('/')) {
+ relativePath + basename
+ } else {
+ "$relativePath/$basename"
+ }
+ } else {
+ basename
+ }
+}
+```
+
+(The package line and the upstream header above it stay. `copyTextToClipboard`, `copyPasswordToClipboard` and `findTillRoot` below stay unchanged.)
+
+Replace the whole `getPGPIdentifiers` function, KDoc included:
+
+```kotlin
+/**
+ * Get a list of available [PGPIdentifier]s for the current password repository. This method throws
+ * when no identifiers were able to be parsed. If this method returns null, it means that an invalid
+ * identifier was encountered and further execution must stop to let the user correct the problem.
+ */
+fun FragmentActivity.getPGPIdentifiers(subDir: String): List<PGPIdentifier>? {
+```
+
+through its closing `}` with:
+
+```kotlin
+/**
+ * Get a list of available [PGPIdentifier]s for the current password repository. This method throws
+ * when no identifiers were able to be parsed. If this method returns null, an invalid identifier
+ * was encountered and reported with a toast: further execution must stop to let the user correct
+ * the problem.
+ */
+fun Context.getPGPIdentifiers(subDir: String): List<PGPIdentifier>? {
+ val repoRoot = PasswordRepository.getRepositoryDirectory()
+ val gpgIdentifierFile =
+ File(repoRoot, subDir).findTillRoot(".gpg-id", repoRoot)
+ ?: File(repoRoot, ".gpg-id").apply { createNewFile() }
+ val gpgIdentifiers =
+ gpgIdentifierFile
+ .readLines()
+ .filter { it.isNotBlank() }
+ .map { line ->
+ PGPIdentifier.fromString(line)
+ ?: run {
+ // The line being empty means this is most likely an empty `.gpg-id`
+ // file we created. Skip the validation so we can make the user add a
+ // real ID.
+ if (line.isEmpty()) return@run
+ // Apparently `gpg-id` being the first line is also acceptable?
+ if (line == "gpg-id") return@run
+ val message =
+ if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) R.string.short_gpg_id
+ else R.string.invalid_gpg_id
+ // A toast on the application context, not a snackbar: the autofill decryption
+ // finishes its window right after, and the toast must outlive it.
+ Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
+ return null
+ }
+ }
+ .filterIsInstance<PGPIdentifier>()
+ if (gpgIdentifiers.isEmpty()) {
+ error("Failed to parse identifiers from .gpg-id: ${gpgIdentifierFile.readText()}")
+ }
+ return gpgIdentifiers
+}
+```
+
+(Every caller runs on the main thread: `EntryDestination.unlock`, `EditorDestination.save` and the decryption's `decrypt`, called from the screen lock prompt's callback or its `else` branch.)
+
+- [ ] **Step 5: `AutofillDecryptActivity.kt`**
+
+Replace `import app.passwordstore.ui.crypto.BasePGPActivity` with `import app.passwordstore.ui.crypto.getParentPath`, and add:
+
+```kotlin
+import android.content.SharedPreferences
+import android.view.WindowManager
+import androidx.appcompat.app.AppCompatActivity
+import app.passwordstore.data.crypto.CryptoRepository
+import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.util.coroutines.DispatcherProvider
+```
+
+Replace
+
+```kotlin
+@AndroidEntryPoint
+class AutofillDecryptActivity : BasePGPActivity() {
+
+ @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
+```
+
+with
+
+```kotlin
+@AndroidEntryPoint
+class AutofillDecryptActivity : AppCompatActivity() {
+
+ @SettingsPreferences @Inject lateinit var settings: SharedPreferences
+ @Inject lateinit var repository: CryptoRepository
+ @Inject lateinit var dispatcherProvider: DispatcherProvider
+ @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
+```
+
+and
+
+```kotlin
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // ponytail: a recreation
+```
+
+with
+
+```kotlin
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // Before anything is drawn: the passphrase dialog's parent stays out of screenshots and recents.
+ window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
+ // ponytail: a recreation
+```
+
+(`getParentPath(filePath, ...)` in `decrypt` now resolves to the top-level import; the companion's private `EXTRA_FILE_PATH` no longer shadows anything.)
+
+- [ ] **Step 6: `EXTRA_FILE_PATH` moves to `LaunchActivity`**
+
+In `LaunchActivity.kt` delete `import app.passwordstore.ui.crypto.BasePGPActivity`, replace both `BasePGPActivity.EXTRA_FILE_PATH` with `EXTRA_FILE_PATH`, and replace
+
+```kotlin
+ const val ACTION_DECRYPT_PASS = "DECRYPT_PASS"
+```
+
+with
+
+```kotlin
+ const val ACTION_DECRYPT_PASS = "DECRYPT_PASS"
+
+ /** The entry to open; home screen shortcuts carry this exact key, never change it. */
+ const val EXTRA_FILE_PATH = "FILE_PATH"
+```
+
+In `MainActivity.kt` delete `import app.passwordstore.ui.crypto.BasePGPActivity` and replace `intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH)` with `intent.getStringExtra(LaunchActivity.EXTRA_FILE_PATH)` (same package, no import).
+
+In `PasswordItem.kt` replace `import app.passwordstore.ui.crypto.BasePGPActivity` with `import app.passwordstore.ui.crypto.getLongName`, `BasePGPActivity.getLongName(` with `getLongName(`, and `intent.putExtra("FILE_PATH", file.absolutePath)` with `intent.putExtra(LaunchActivity.EXTRA_FILE_PATH, file.absolutePath)` (`LaunchActivity` is already imported).
+
+In `StoreActions.kt` replace `import app.passwordstore.ui.crypto.BasePGPActivity.Companion.getLongName` with `import app.passwordstore.ui.crypto.getLongName`.
+
+In `EntryDestination.kt` replace `import app.passwordstore.ui.crypto.BasePGPActivity.Companion.getParentPath` with `import app.passwordstore.ui.crypto.getParentPath`.
+
+- [ ] **Step 7: Run the test and the checks**
+
+```bash
+./gradlew spotlessApply
+./gradlew :app:testDebugUnitTest -PslimTests --tests app.passwordstore.ui.crypto.CryptoExtensionsTest
+CI=1 ./gradlew spotlessCheck :app:testDebugUnitTest -PslimTests :app:assembleDebug :app:lintDebug
+grep -rn 'BasePGPActivity' app/src AGENTS.md
+grep -n 'FLAG_SECURE' app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt
+```
+Expected: the new test passes, then `BUILD SUCCESSFUL`; the first grep prints nothing (AGENTS.md never named it); the second shows the `setFlags` line in `onCreate`.
+
+- [ ] **Step 8: Security review**
+
+Dispatch a reviewer (superpowers:code-reviewer) on the diff with this brief: the autofill decryption window sets `FLAG_SECURE` in `onCreate` right after `super.onCreate`, before `setContent` and before the early `finish()` paths, as `BasePGPActivity` did; the injected `settings`, `repository` and `dispatcherProvider` are the same bindings (`@SettingsPreferences` qualifier kept); `getPGPIdentifiers` parses `.gpg-id` exactly as before and still stops on an invalid or short ID (only the message channel changed, now a Toast on the application context); the `FILE_PATH` extra key is unchanged and `LaunchActivity` still forwards it only after `isFileInRepo`. Apply findings before committing.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add -A app/src/main/java/app/passwordstore/ui app/src/main/java/app/passwordstore/data/password/PasswordItem.kt \
+ app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt
+git status --short
+git commit -m "refactor(app): fold BasePGPActivity into the autofill decryption
+
+AutofillDecryptActivity was its last subclass: it now sets FLAG_SECURE
+and injects the settings, crypto repository and dispatchers itself. The
+path and clipboard helpers move to CryptoExtensions.kt as top-level
+functions (a test pins the path helpers), and the FILE_PATH extra key
+moves, unchanged, to LaunchActivity. An invalid or short .gpg-id ID is
+reported with a toast: the snackbar never showed in the autofill
+decryption, which closes its window at once.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+Expected: `git status --short` shows the rename (`R .../BasePGPActivity.kt -> .../CryptoExtensions.kt`), the new test and the six modified files.
+
+---
+
+### Task 3: View-era dependencies, view binding, dead styles and resources
+
+**Files:**
+- Modify: `app/build.gradle.kts`, `gradle/libs.versions.toml`
+- Modify: `build-logic/src/main/kotlin/app/passwordstore/gradle/ApplicationPlugin.kt`, `build-logic/src/main/kotlin/app/passwordstore/gradle/LintConfig.kt`
+- Modify: `app/lint.xml`, `app/src/main/res/values/themes_material3.xml`
+- Delete: `app/src/main/res/layout/map_preference_widget_switch.xml`, `app/src/main/res/values/attrs.xml`, `app/src/main/res/values/styles_material3.xml`
+
+- [ ] **Step 1: Confirm nothing uses them**
+
+```bash
+grep -rn --include=*.kt --include=*.xml --include=*.kts --include=*.pro -E 'androidx\.preference|androidx\.recyclerview|RecyclerView|SelectionTracker|zhanghai|fastscroll|maxr1998|modernandroidpreferences|Multiselected|state_multiselected|APSThemeM3\.Toolbar|APSThemeM3\.BottomSheetDialog|BottomSheetDialog\b|widget\.Toolbar|map_preference_widget' app/src ui build-logic
+./gradlew :app:dependencyInsight --configuration releaseRuntimeClasspath --dependency androidx.fragment:fragment | grep -m1 'androidx.fragment:fragment:'
+```
+Expected: the grep shows only `themes_material3.xml` (the two style references and the BottomSheetDialog style), `styles_material3.xml` and `attrs.xml`; the insight shows `androidx.fragment:fragment:1.8.4` (the baseline for Step 6). Anything else: stop and report.
+
+- [ ] **Step 2: `app/build.gradle.kts`**
+
+Delete these five lines from `dependencies`:
+
+```kotlin
+ implementation(libs.androidx.preference)
+ implementation(libs.androidx.recyclerview)
+ implementation(libs.androidx.recyclerviewSelection)
+```
+```kotlin
+ implementation(libs.thirdparty.fastscroll)
+```
+```kotlin
+ implementation(libs.thirdparty.modernAndroidPrefs)
+```
+
+and replace ` implementation(libs.androidx.fragment.ktx)` with
+
+```kotlin
+ // Pins androidx.fragment (FragmentActivity, BiometricPrompt): appcompat alone resolves 1.5.4.
+ implementation(libs.androidx.fragment.ktx)
+```
+
+(`libs.androidx.constraintlayout` goes in Task 4 with its last user.)
+
+- [ ] **Step 3: `gradle/libs.versions.toml`**
+
+Delete these lines under `[libraries]`:
+
+```toml
+androidx-preference = "androidx.preference:preference:1.2.1"
+androidx-recyclerview = "androidx.recyclerview:recyclerview:1.4.0"
+androidx-recyclerviewSelection = "androidx.recyclerview:recyclerview-selection:1.2.0"
+thirdparty-fastscroll = "me.zhanghai.android.fastscroll:library:1.3.0"
+thirdparty-modernAndroidPrefs = "de.maxr1998:modernandroidpreferences:2.4.0-beta2"
+```
+
+- [ ] **Step 4: View binding off, two lint disables gone**
+
+In `ApplicationPlugin.kt` replace
+
+```kotlin
+ buildFeatures {
+ viewBinding = true
+ buildConfig = true
+ }
+```
+
+with
+
+```kotlin
+ buildFeatures { buildConfig = true }
+```
+
+In `LintConfig.kt` delete
+
+```kotlin
+ // False-positives abound due to use of ViewBinding
+ disable += "UnusedIds"
+```
+
+and
+
+```kotlin
+ // Hilt only does field injection for fragments
+ disable += "FragmentFieldInjection"
+```
+
+- [ ] **Step 5: Resources**
+
+```bash
+git rm app/src/main/res/layout/map_preference_widget_switch.xml \
+ app/src/main/res/values/attrs.xml app/src/main/res/values/styles_material3.xml
+```
+
+In `themes_material3.xml` delete from `AppThemeM3`:
+
+```xml
+ <item name="toolbarStyle">@style/APSThemeM3.Toolbar</item>
+ <item name="bottomSheetDialogTheme">@style/APSThemeM3.BottomSheetDialog</item>
+```
+
+and the whole style:
+
+```xml
+ <style name="APSThemeM3.BottomSheetDialog" parent="ThemeOverlay.Material3.BottomSheetDialog">
+ <item name="android:windowIsFloating">false</item>
+ <item name="android:statusBarColor">@android:color/transparent</item>
+ <item name="android:navigationBarColor">@android:color/transparent</item>
+ </style>
+
+```
+
+In `app/lint.xml` delete ` <ignore path="src/main/res/layout/oreo_autofill_filter_row.xml" />` (deleted in step 7), ` <ignore path="src/main/res/layout/map_preference_widget_switch.xml" />` under `UnusedResources`, and the whole block:
+
+```xml
+ <issue id="PrivateResource">
+ <ignore path="src/main/res/layout/map_preference_widget_switch.xml" />
+ </issue>
+```
+
+- [ ] **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.<name>' 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 <noreply@anthropic.com>"
+```
+
+---
+
+### 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
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/git_auth_passphrase_layout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:endIconMode="password_toggle"
+ app:errorEnabled="true"
+ app:hintEnabled="true">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/git_auth_credential"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/ssh_keygen_passphrase"
+ android:inputType="textPassword" />
+
+ <requestFocus />
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.checkbox.MaterialCheckBox
+ android:id="@+id/git_auth_remember_credential"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/git_operation_remember_passphrase"
+ app:checkedState="checked" />
+</LinearLayout>
+```
+
+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 <noreply@anthropic.com>"
+```
+
+---
+
+### 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 '<string '
+```
+Expected: 22 lines: de 4, fr 3, gl 4, ko-rKR 1, pl-rPL 3, pt-rBR 4, ru 2, zh-rCN 1 (none in `values-it`, fixed in 0.1.4). Two German lines (`password_creation_file_write_fail_message`, `password_creation_file_delete_fail_message`) mean the store itself ("im Password Store", "aus dem Password Store"); English says "the store" there. The other 20 name the app.
+
+- [ ] **Step 2: Replace**
+
+```bash
+sed -i 's/im Password Store/im Repository/; s/aus dem Password Store/aus dem Repository/' \
+ app/src/main/res/values-de/strings.xml
+sed -i '/<string /s/Password Store/HardenedPass/g' app/src/main/res/values-*/strings.xml
+grep -n 'Password Store' app/src/main/res/values-*/strings.xml | grep '<string '
+git diff --stat
+git diff app/src/main/res/values-de/strings.xml
+```
+Expected: the third command prints nothing; `--stat` lists the eight locale files with 22 changed lines in all (20 "HardenedPass", 2 "Repository"); the German diff shows two "HardenedPass" lines (autofill description and instructions, the latter keeps its `\"...\"` quotes) and the two "Repository" lines. The copyright comments and the `<!-- Password Store -->` section comments are untouched (not `<string` lines).
+
+- [ ] **Step 3: Check**
+
+```bash
+CI=1 ./gradlew :app:lintDebug
+```
+Expected: `BUILD SUCCESSFUL`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add app/src/main/res/values-*/strings.xml
+git commit -m "fix(i18n): name HardenedPass in every locale
+
+0.1.4 renamed the app in the English and Italian autofill and
+biometric texts only; German, French, Galician, Korean, Polish,
+Brazilian Portuguese, Russian and Chinese still said Password Store in
+22 strings. 20 now name HardenedPass; the two German messages where it
+meant the store itself say Repository, as the rest of that translation
+does.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### 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 "<node [^>]*$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"; sleep 1; adb shell input keycombination 113 29; adb shell input keyevent 67; adb shell input text "$2"; }
+e2e_value() { e2e_ui | grep -o '<node [^>]*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 "<node [^>]*$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
+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
+adb uninstall $APP || true
+./gradlew :app:installDebug
+scripts/e2e-env.sh push-keys
+```
+(`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**
+
+```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 '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`). (FABs expose their label as `content-desc`, not `text`, in the dump.)
+
+- [ ] **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 '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"'
+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 <aps.test@example.org>" -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-<millis>` 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 '<node [^>]*text="Remember password"[^>]*>' | grep -o 'checked="[a-z]*"'
+e2e_ui | grep -o '<node [^>]*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 <noreply@anthropic.com>"
+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 <noreply@anthropic.com>"
+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<PGPIdentifier>?` (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.
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..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
@@ -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
@@ -22,7 +22,10 @@ the maintainer's phone stays on 0.1.4 until then.
- Breadcrumb chips under the search bar.
- 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.
+ 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.
- Pull to sync. Long-press starts multi-select (move, delete). FAB opens the create sheet
(entry or folder).
@@ -32,6 +35,9 @@ the maintainer's phone stays on 0.1.4 until then.
- First group: password (masked, reveal toggle, copy), username (copy), one-time code with a
countdown bar (copy).
- Second group: remaining extra fields, copy where it makes sense.
+- Overflow: copy name, share as plaintext. The password starts masked or shown per the existing
+ "Show the password" setting. Saving in the editor returns to the list. Password and one-time
+ code copies are cleared from the clipboard after the timeout.
### Editor
@@ -40,14 +46,66 @@ the maintainer's phone stays on 0.1.4 until then.
- Password with reveal and a generator button; the generator is a bottom sheet with random and
diceware tabs.
- Username field, free-text extra content (pass format kept as text, nothing hidden).
-- "OTP from QR" and "OTP manual" actions.
+- "OTP from QR" (camera or image) and "OTP manual" actions.
+- "Save and copy" in the overflow. Saving returns to the list; closing returns to where the
+ editor was opened. The folder picker is a dialog, shared with the list's move action. The
+ autofill save flow (`AutofillSaveActivity`) hosts the same editor.
+
+### Setup
+
+- Without a store the app opens on a welcome screen: clone a remote repository or create a
+ local store; Settings is reachable from it.
+- The git server screen serves the clone and later edits: URL, authentication mode (SSH key or
+ password; none or password for HTTP(S)), the SSH key's state with "Manage", "Clear saved host
+ key". A failed clone stays on the screen, so URL or key can be fixed and the clone retried.
+- The SSH key screen shows the key's state and, for a key generated on the device, the public
+ key (copy, share); it imports a key file (refusing files sshj cannot read, such as OpenPGP
+ keys) and opens key generation (RSA, ECDSA, Ed25519, optionally behind the screen lock).
+- One PGP keys screen manages keys (import, delete), picks the key of a new local store (the
+ repository is created only then, its `.gpg-id` naming the key by its uppercase long key ID, as
+ `pass init <key ID>` does), and imports the key a store needs: after a clone that no
+ imported key can decrypt, and when an entry is opened with no key imported.
+- Settings opens these within the graph. Autofill, the git operations' "No SSH key" dialog and
+ the entry view's key import open them through a `MainActivity` setup intent: the autofill
+ activities live outside the graph, and the other two wait for a result.
+
+### Settings
+
+- Settings lists its categories (general, autofill, passwords, repository, misc, PGP); each opens
+ a screen of rows (switches, single choices, text values, actions) over the existing preference
+ keys. Switches behind the screen lock store their value only after a successful prompt.
+- Rows that lead elsewhere navigate within `MainActivity`: git server, SSH key, key generation,
+ PGP keys, proxy, and the git configuration screen (author name and email, commit log, abort
+ rebase, hard reset, garbage collection).
+- Destructive actions ask first: deleting the repository (which never follows symlinks out of the
+ store and returns to the welcome screen), clearing a saved password or passphrase, the hard
+ reset.
+- The list's sort orders are folders first and recently used; "files first" and "type
+ independent" are gone, and stored values fall back to folders first.
+
+### Autofill
+
+- The autofill service's activities keep their windows (floating picker and warning, transparent
+ decryption) and draw them with the shared theme. The picker shows the site or app being filled,
+ a search prefilled with it, entry rows named by their origin identifier, and the
+ phishing-resistant search, match and clear-matches switches.
+- The publisher warning offers "Keep Autofill disabled" first; re-enabling stays behind "Advanced
+ information", which shows the package and certificate details above the "Re-enable Autofill"
+ button, so a double tap cannot re-enable autofill. Touches through another app's window are
+ ignored.
+- The decryption shows only the "no keys" and passphrase dialogs and starts once the window has
+ focus. It caches only a passphrase the user typed, and only after a successful screen lock
+ prompt, as the entry view does. An invalid `.gpg-id` closes it without filling. Saving and
+ generating host the editor.
+- Intents, extras, results and matching are unchanged.
## Architecture
### Activities
-- `MainActivity` (new, not exported, extends `FragmentActivity` for `BiometricPrompt`, uses no
- fragments) hosts a Compose `NavHost`. It replaces `PasswordStore`, `DecryptActivity`,
+- `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`. It replaces `PasswordStore`, `DecryptActivity`,
`PasswordCreationActivity`, `SelectFolderActivity`, `OnboardingActivity`, `SettingsActivity`,
`GitServerConfigActivity`, `GitConfigActivity`, `GitLogActivity`, `ProxySelectorActivity`,
`SshKeyGenActivity`, `SshKeyImportActivity`, `PGPKeyImportActivity` and `PGPKeyListActivity`.
@@ -75,19 +133,25 @@ the maintainer's phone stays on 0.1.4 until then.
Crypto, git, autofill parsing, preference keys, the Keystore-backed secret storage, clipboard
handling and the passphrase cache. Existing stores, keys and settings carry over without
-migration.
+migration, with two exceptions from step 6: a stored "files first" or "type independent" sort
+order (both removed) becomes folders first, and the unused OpenKeychain SSH key ID is dropped.
### 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
- `FLAG_SECURE` is set on the window while the entry view or editor destination is shown and
- cleared when leaving it, so secrets stay out of screenshots and recents.
+ cleared when leaving it, so secrets stay out of screenshots and recents. It is counted, so
+ moving between the two never clears it.
- `MainActivity` is not exported. Routes come only from `LaunchActivity` after its checks; an
entry route is still validated with `isFileInRepo`.
@@ -102,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
@@ -130,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.