From c0c98afc8abbcbac28ace330a5c8659aba0cd572 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 23:38:48 +0100 Subject: fix(app): harden the entry passphrase prompt Ignore a submit while no prompt is showing, so OK plus IME Done cannot start two decrypts. Key the passphrase dialog on the retry count so each attempt gets a fresh field and shows the wrong password error. Note the process death gap around key import and the editor. Record the TotpModule, text selection and file IO decisions in the plan. Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/entry/EntryDestination.kt | 20 +++++++++++++------- .../app/passwordstore/ui/entry/EntryViewModel.kt | 6 +++++- docs/superpowers/plans/2026-11-02-compose-entry.md | 4 +++- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt index a54b517d..5c16b5a3 100644 --- a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect 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 @@ -104,6 +105,9 @@ fun EntryDestination( } } + // ponytail: after process death during key import or the editor, the unlock flow can start + // twice or a prompt can be left over the list; rare, no secret exposed. Fix by tracking + // whether the flow already started if it shows up. val keyImport = rememberLauncherForActivityResult(StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_OK) unlock() else onClose() @@ -173,13 +177,15 @@ fun EntryDestination( ) 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, - ) + key(viewModel.retries) { + PassphraseDialog( + wrong = viewModel.prompt == EntryPrompt.WrongPassphrase, + cacheEnabled = viewModel.cacheEnabled, + clearCache = viewModel.clearCache, + onSubmit = { passphrase, clear -> viewModel.submit(passphrase, clear, activity) }, + onCancel = viewModel::cancel, + ) + } } } } diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt index 38d3b9d9..5714f2a6 100644 --- a/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt @@ -86,7 +86,10 @@ constructor( private var started = false private var copyOnDecrypt = false - private var retries = 0 + /** Failed-or-asked passphrase prompts so far; keys the dialog so each attempt starts fresh. */ + var retries = 0 + private set + private var auth: BiometricResult = BiometricResult.CanceledByUser private var ids: List = emptyList() private var file: File? = null @@ -115,6 +118,7 @@ constructor( } fun submit(passphrase: String, clearCache: Boolean, context: Context) { + if (prompt == EntryPrompt.None) return prompt = EntryPrompt.None this.clearCache = clearCache val app = context.applicationContext diff --git a/docs/superpowers/plans/2026-11-02-compose-entry.md b/docs/superpowers/plans/2026-11-02-compose-entry.md index 684c7e8b..bd4314ca 100644 --- a/docs/superpowers/plans/2026-11-02-compose-entry.md +++ b/docs/superpowers/plans/2026-11-02-compose-entry.md @@ -56,6 +56,8 @@ Spec: `docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md`. Earlier | `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). @@ -65,7 +67,7 @@ Left for later steps because other legacy screens still use them: `PasswordDialo - 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 file IO never run on the main thread. +- Decryption and entry file IO never run on the main thread. **Conventions for every task** -- cgit v1.2.3