diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-05 11:56:20 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-05 11:56:20 +0100 |
| commit | 73486a180ba3ead1890924920094e7e678a8ef76 (patch) | |
| tree | 370734f6029c4ca2b7a4eb799b40dd4b0d30be3e /docs/superpowers | |
| parent | 5f7ea1c314c156cce8ab68b795ff9602ed800917 (diff) | |
| download | HardenedPass-73486a180ba3ead1890924920094e7e678a8ef76.tar.gz HardenedPass-73486a180ba3ead1890924920094e7e678a8ef76.zip | |
docs: add the git dialogs to Compose plan
Record what reading the code changed in the design: values-v29 already hides the
battery saver option, and AppCompat goes before Material so themes change once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-11-05-git-dialogs-compose.md | 904 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-11-05-git-dialogs-compose-design.md | 37 |
2 files changed, 925 insertions, 16 deletions
diff --git a/docs/superpowers/plans/2026-11-05-git-dialogs-compose.md b/docs/superpowers/plans/2026-11-05-git-dialogs-compose.md new file mode 100644 index 00000000..e0bd176b --- /dev/null +++ b/docs/superpowers/plans/2026-11-05-git-dialogs-compose.md @@ -0,0 +1,904 @@ +# Git dialogs to Compose 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:** Remove Material Components and the app's own AppCompat use, moving the last View dialogs and the snackbar to Compose, at minSdk 31. + +**Architecture:** An activity-scoped `Prompts` ViewModel holds pending dialogs and the current snackbar as Compose state; `PromptHost()` draws them at the root of `MainActivity` and `AutofillSaveActivity`. Git code reaches it through `activity.prompts`. Spec: `docs/superpowers/specs/2026-11-05-git-dialogs-compose-design.md`. + +**Tech Stack:** Kotlin, Jetpack Compose Material 3, AndroidX Lifecycle, Gradle. No new dependencies. + +Work on `master`. Commits are GPG signed; never disable signing. Do not push until the user asks. Run commands from the repository root. Prefix Gradle with `CI=1` where shown: Kotlin warnings become errors, and lint has `warningsAsErrors`. + +## File structure + +- Create `app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt`: state (`Prompts`, `Prompt`, `Choice`, `Snack`) and drawing (`PromptHost`). +- Create `app/src/test/java/app/passwordstore/ui/prompts/PromptsTest.kt`. +- Delete `app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt`, `app/src/main/res/values-v29/`, `app/src/main/res/layout/git_credential_layout.xml`. +- Everything else is a modification listed per task. + +--- + +### Task 1: minSdk 31 and dead branches + +**Files:** +- Modify: `build-logic/src/main/kotlin/app/passwordstore/gradle/AndroidCommon.kt:23` +- Modify: `app/src/main/java/app/passwordstore/injection/AutofillResponseBuilderModule.kt` +- Modify: `app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt:54-71` +- Modify: `ui/compose/src/main/kotlin/app/passwordstore/ui/compose/theme/Theme.kt:87` +- Modify: `app/src/test/java/app/passwordstore/util/viewmodel/StrictDomainRegexTest.kt:19` +- Modify: every `SDK_INT` site listed in Step 4, `app/src/main/res/values/arrays.xml`, `app/src/main/res/values/prefs.xml`, `strings.xml` in `values` and 10 locales +- Delete: `Api26AutofillResponseBuilder.kt`, `app/src/main/res/values-v29/` + +- [ ] **Step 1: Raise minSdk** + +In `AndroidCommon.kt`: `defaultConfig.minSdk = 31`. + +- [ ] **Step 2: Drop the Api26 builder** + +`AutofillResponseBuilderModule.kt` body becomes: + +```kotlin + @Provides + @Reusable + fun provideAutofillResponseBuilder(): AutofillResponseBuilder.Factory = + Api30AutofillResponseBuilder.Factory +``` + +Remove the `Build` and `Api26AutofillResponseBuilder` imports, delete `Api26AutofillResponseBuilder.kt`, and remove `@RequiresApi(Build.VERSION_CODES.R)` from `Api30AutofillResponseBuilder.kt:34`. + +- [ ] **Step 3: Simplify the clipboard helper** + +In `CryptoExtensions.kt`: + +```kotlin +/** Copies provided [text] to the clipboard, flagged sensitive. */ +fun FragmentActivity.copyTextToClipboard(text: String?) { + 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) +} +``` + +Remove imports that become unused (`Build`, `StringRes`, `snackbar`) if nothing else in the file needs them. + +- [ ] **Step 4: Remove obsolete version checks** + +Rule: a check against `P`, `Q`, `R` or `S` is now always true for `>=` and always false for `<`. Keep the taken branch, delete the other, and delete the condition. Checks against `TIRAMISU` and `UPSIDE_DOWN_CAKE` stay. + +Sites: + +- `app/src/main/java/app/passwordstore/`: `util/services/ClipboardService.kt:124`, `Application.kt:105,109,113,117`, `util/crypto/KeystorePreferences.kt:70,80,94`, `util/autofill/AutofillResponseBuilder.kt:44` (if line numbers moved, grep for `SDK_INT`), `util/services/OreoAutofillService.kt:77`, `util/git/sshj/SshKey.kt:160,201,321`, `util/autofill/AutofillViewUtils.kt:50,59`, `util/auth/BiometricAuthenticator.kt:32`, `ui/autofill/AutofillPublisherChangedActivity.kt:57`, `ui/autofill/AutofillFilterView.kt:77`, `ui/autofill/AutofillDecryptActivity.kt:371`, `ui/settings/ProxyViewModel.kt:23`, `ui/editor/EditorViewModel.kt:171`. +- `autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/`: `PublicSuffixListCache.kt:56`, `AutofillHelper.kt:59,89,123`, `FeatureAndTrustDetection.kt:241`. +- `ui/compose/.../Theme.kt:87`: the case becomes `dynamicColor -> {`. + +Note `FeatureAndTrustDetection.kt:241`: `SDK_INT < P` is false, so the whole `||`/`&&` expression must be reduced by hand, not just deleted. At `AutofillHelper.kt:123` the `P` case of the `when` becomes `else`, and any older `else` goes. `KeystorePreferences.kt:80` still returns early on `!strongBox`. + +Then remove the obsolete annotations: + +```bash +grep -rl 'RequiresApi(Build.VERSION_CODES.O)' autofill-parser/src/main | xargs sed -i '/@RequiresApi(Build.VERSION_CODES.O)/d' +``` + +- [ ] **Step 5: Fold `values-v29` into `values`** + +In `app/src/main/res/values/arrays.xml`, the `app_theme_options` third item becomes `@string/pref_app_theme_value_follow_system` and the `app_theme_values` third item becomes `follow_system`. In `values/prefs.xml`, `app_theme_def` becomes `follow_system`. Then: + +```bash +command rm -r app/src/main/res/values-v29 +grep -rl pref_app_theme_value_battery_saver app/src/main/res | xargs sed -i '/pref_app_theme_value_battery_saver/d' +``` + +- [ ] **Step 6: Fix the test below minSdk** + +In `StrictDomainRegexTest.kt` delete `@Config(sdk = [23])` and its import. + +- [ ] **Step 7: Verify** + +Run: `./gradlew spotlessApply && CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug lint metalavaCheckCompatibilityRelease` + +Expected: BUILD SUCCESSFUL. Lint failures name leftovers: `ObsoleteSdkInt` means a missed check, `UnusedResources` means a string or resource only the deleted code used; delete it, including its translations. If metalava reports an API change in `autofill-parser`, run `./gradlew metalavaGenerateRelease` and include the signature file. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "build: raise minSdk to 31 + +The project targets GrapheneOS, which only runs current Android. Android 12 +gives the platform night mode API and removes the fingerprint dialog path that +needs AppCompat. Devices below it stay on 0.2.0. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 2: Prompts state + +**Files:** +- Create: `app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt` +- Test: `app/src/test/java/app/passwordstore/ui/prompts/PromptsTest.kt` + +- [ ] **Step 1: Write the failing test** + +```kotlin +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.prompts + +import androidx.compose.material3.SnackbarDuration +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking + +class PromptsTest { + + private fun Prompts.question() = current as Question + + @Test + fun answerResumesAsk() = runBlocking { + val prompts = Prompts() + val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") } + prompts.answer(prompts.question(), Choice.Other) + assertEquals(Choice.Other, asked.await()) + assertNull(prompts.current) + } + + @Test + fun dismissingAnswersCancel() = runBlocking { + val prompts = Prompts() + val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") } + prompts.cancel(prompts.question()) + assertEquals(Choice.Cancel, asked.await()) + } + + @Test + fun secondAnswerIsIgnored() = runBlocking { + val prompts = Prompts() + val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") } + val question = prompts.question() + prompts.answer(question, Choice.Confirm) + prompts.answer(question, Choice.Cancel) + assertEquals(Choice.Confirm, asked.await()) + } + + @Test + fun cancelledAskRemovesItsDialog() = runBlocking { + val prompts = Prompts() + val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") } + asked.cancel() + assertNull(prompts.current) + } + + @Test + fun promptsShowOneAtATimeInOrder() = runBlocking { + val prompts = Prompts() + val first = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("1", "m", "ok") } + val second = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("2", "m", "ok") } + assertEquals("1", prompts.question().title) + prompts.answer(prompts.question(), Choice.Confirm) + assertEquals("2", prompts.question().title) + prompts.answer(prompts.question(), Choice.Confirm) + first.await() + second.await() + } + + @Test + fun cancelAllAnswersAPendingCredentialWithNull() { + val prompts = Prompts() + val answers = mutableListOf<CredentialAnswer?>(CredentialAnswer("unset", false)) + prompts.askCredential("m", "h", "r", null) { answers[0] = it } + prompts.cancelAll() + assertNull(answers[0]) + assertNull(prompts.current) + } + + @Test + fun newSnackbarReplacesTheCurrentOne() { + val prompts = Prompts() + val first = prompts.snackbar("one", SnackbarDuration.Indefinite) + val second = prompts.snackbar("two", SnackbarDuration.Short) + assertSame(second, prompts.snack) + // The finished operation dismisses its own snackbar only. + prompts.dismiss(first) + assertSame(second, prompts.snack) + prompts.dismiss(second) + assertNull(prompts.snack) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest -PslimTests --tests 'app.passwordstore.ui.prompts.PromptsTest'` +Expected: FAIL, compilation error, unresolved reference `Prompts`. + +- [ ] **Step 3: Write the state** + +`app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt`: + +```kotlin +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.prompts + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine + +/** The button that answered a [Question]. Dismissing the dialog answers [Cancel]. */ +enum class Choice { + Confirm, + Other, + Cancel, +} + +/** What the user entered in a [CredentialRequest]. */ +class CredentialAnswer(val text: String, val remember: Boolean) + +/** A dialog waiting for its answer; [cancelled] is the answer to dismissing it. */ +sealed class Prompt<T>(private val cancelled: T, private val deliver: (T) -> Unit) { + internal fun answer(value: T) = deliver(value) + + internal fun cancel() = deliver(cancelled) +} + +class Question( + val title: String, + val message: String, + val confirm: String, + val cancel: String?, + val other: String?, + deliver: (Choice) -> Unit, +) : Prompt<Choice>(Choice.Cancel, deliver) + +class CredentialRequest( + val message: String, + val hint: String, + val remember: String, + val error: String?, + deliver: (CredentialAnswer?) -> Unit, +) : Prompt<CredentialAnswer?>(null, deliver) + +/** A snackbar; each one is a distinct object, so showing the same text again shows it again. */ +class Snack(val message: String, val length: SnackbarDuration) + +/** + * The dialogs and the snackbar of one activity, for code with no composable of its own (git + * operations and store actions), drawn by [PromptHost]. Reach it through [prompts]. + */ +class Prompts : ViewModel() { + + private val queue = mutableStateListOf<Prompt<*>>() + + /** The dialog on screen. The others wait for it to be answered. */ + val current: Prompt<*>? + get() = queue.firstOrNull() + + var snack by mutableStateOf<Snack?>(null) + private set + + /** Shows a dialog and suspends until it is answered. Cancelling the caller closes it. */ + suspend fun ask( + title: String, + message: String, + confirm: String, + cancel: String? = null, + other: String? = null, + ): Choice = suspendCancellableCoroutine { cont -> + val question = Question(title, message, confirm, cancel, other) { cont.resume(it) } + queue += question + cont.invokeOnCancellation { queue.remove(question) } + } + + /** + * Asks for a credential. Not suspending: the SSH thread waits on [deliver], and no cancellation + * reaches it, so this dialog outlives a recreated activity and is drawn again by the new one. + */ + fun askCredential( + message: String, + hint: String, + remember: String, + error: String?, + deliver: (CredentialAnswer?) -> Unit, + ) { + queue += CredentialRequest(message, hint, remember, error, deliver) + } + + /** Answers [prompt], once: a double tap must not resume its caller twice. */ + fun <T> answer(prompt: Prompt<T>, value: T) { + if (queue.remove(prompt)) prompt.answer(value) + } + + fun cancel(prompt: Prompt<*>) { + if (queue.remove(prompt)) prompt.cancel() + } + + fun cancelAll() { + queue.toList().forEach(::cancel) + } + + /** Shows [message] in place of any current snackbar, as the View snackbar did. */ + fun snackbar(message: String, length: SnackbarDuration = SnackbarDuration.Short): Snack = + Snack(message, length).also { snack = it } + + /** Hides [shown], unless another snackbar replaced it. */ + fun dismiss(shown: Snack) { + if (snack === shown) snack = null + } + + override fun onCleared() = cancelAll() +} + +/** The [Prompts] of this activity; it survives recreation. */ +val FragmentActivity.prompts: Prompts + get() = ViewModelProvider(this)[Prompts::class.java] +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:testDebugUnitTest -PslimTests --tests 'app.passwordstore.ui.prompts.PromptsTest'` +Expected: PASS, 7 tests. No commit yet: nothing uses the file, Task 3 commits it with its callers. + +--- + +### Task 3: PromptHost and all call sites + +**Files:** +- Modify: `app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt` +- Modify: `ui/main/MainActivity.kt`, `ui/autofill/AutofillSaveActivity.kt` +- Modify: `util/extensions/AndroidExtensions.kt`, `util/git/GitCommandExecutor.kt` +- Modify: `util/git/operation/CredentialFinder.kt`, `util/git/operation/BreakOutOfDetached.kt`, `util/git/operation/GitOperation.kt` +- Modify: `ui/git/base/BaseGitActivity.kt`, `ui/list/StoreActions.kt` +- Modify: `ui/setup/PgpKeysScreen.kt`, `ui/setup/GitServerScreen.kt`, `ui/settings/GitConfigScreen.kt`, `ui/editor/EditorDestination.kt` +- Delete: `app/src/main/res/layout/git_credential_layout.xml` + +Paths without a root are under `app/src/main/java/app/passwordstore/`. + +- [ ] **Step 1: Add `PromptHost` to `Prompts.kt`** + +```kotlin +/** Draws the [Prompts] of [activity]. Place it after the activity's content, inside `APSTheme`. */ +@Composable +fun PromptHost(activity: FragmentActivity) { + val prompts = remember(activity) { activity.prompts } + val snackbars = remember { SnackbarHostState() } + val snack = prompts.snack + // A new snack cancels this effect, which takes the old one off the screen. + LaunchedEffect(snack) { + if (snack != null) { + snackbars.showSnackbar(snack.message, duration = snack.length) + prompts.dismiss(snack) + } + } + Box(Modifier.fillMaxSize().navigationBarsPadding().imePadding(), Alignment.BottomCenter) { + SnackbarHost(snackbars) + } + val prompt = prompts.current + // Keyed: a retry is a new request and starts with an empty field. + key(prompt) { + when (prompt) { + is Question -> QuestionDialog(prompt, prompts) + is CredentialRequest -> CredentialDialog(prompt, prompts) + null -> {} + } + } +} + +@Composable +private fun QuestionDialog(question: Question, prompts: Prompts) { + AlertDialog( + onDismissRequest = { prompts.cancel(question) }, + title = { Text(question.title) }, + text = { Text(question.message) }, + confirmButton = { + TextButton(onClick = { prompts.answer(question, Choice.Confirm) }) { + Text(question.confirm) + } + }, + dismissButton = { + // No Row: the dialog's button area wraps its children when they do not fit. + question.cancel?.let { + TextButton(onClick = { prompts.answer(question, Choice.Cancel) }) { Text(it) } + } + question.other?.let { + TextButton(onClick = { prompts.answer(question, Choice.Other) }) { Text(it) } + } + }, + ) +} + +/** The credential lives only in `remember` and the dialog's window is FLAG_SECURE. */ +@Composable +private fun CredentialDialog(request: CredentialRequest, prompts: Prompts) { + // remember, never rememberSaveable: the credential must not reach saved state. + var text by remember { mutableStateOf("") } + var keep by remember { mutableStateOf(false) } + var revealed by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(request.error) } + val focus = remember { FocusRequester() } + fun submit() = prompts.answer(request, CredentialAnswer(text, keep)) + AlertDialog( + onDismissRequest = { prompts.cancel(request) }, + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + title = { Text(stringResource(R.string.passphrase_dialog_title)) }, + text = { + Column { + Text(request.message) + OutlinedTextField( + value = text, + onValueChange = { + text = it + error = null + }, + modifier = Modifier.focusRequester(focus), + label = { Text(request.hint) }, + supportingText = error?.let { { Text(it) } }, + isError = error != null, + 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, + ), + keyboardActions = KeyboardActions(onDone = { submit() }), + ) + Row( + Modifier.toggleable(value = keep, role = Role.Checkbox, onValueChange = { keep = it }), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = keep, onCheckedChange = null) + Text(request.remember) + } + } + LaunchedEffect(Unit) { focus.requestFocus() } + }, + confirmButton = { TextButton(onClick = ::submit) { Text(stringResource(R.string.dialog_ok)) } }, + dismissButton = { + TextButton(onClick = { prompts.cancel(request) }) { + Text(stringResource(R.string.dialog_cancel)) + } + }, + ) +} +``` + +Imports follow `ProxyScreen.kt` (including its `composeR` alias for the `ui:compose` R class) and `EntryDestination.kt`'s `PassphraseDialog`. + +- [ ] **Step 2: Host it** + +`MainActivity.kt`: directly after the `NavHost(...) { ... }` call, still inside `APSTheme { }`, add `PromptHost(this@MainActivity)`. + +`AutofillSaveActivity.kt`: after the `EditorDestination(...)` call inside `APSTheme { }`, add `PromptHost(this@AutofillSaveActivity)`, and change `activity = this` above it only if the compiler needs the label. + +- [ ] **Step 3: Replace the snackbar helper** + +In `AndroidExtensions.kt`: + +```kotlin +/** Shows a snackbar through this activity's [prompts]; it must host a `PromptHost`. */ +fun FragmentActivity.snackbar( + message: String, + length: SnackbarDuration = SnackbarDuration.Short, +): Snack = prompts.snackbar(message, length) +``` + +Remove the `Snackbar` and `View` imports if unused. At every caller replace `Snackbar.LENGTH_LONG` with `SnackbarDuration.Long` and the `com.google.android.material.snackbar.Snackbar` import with `androidx.compose.material3.SnackbarDuration`: `ui/setup/PgpKeysScreen.kt`, `ui/setup/GitServerScreen.kt`, `ui/settings/GitConfigScreen.kt`, `ui/editor/EditorDestination.kt`. + +In `GitCommandExecutor.kt`: + +```kotlin + val running = + activity.snackbar( + message = activity.resources.getString(R.string.git_operation_running), + length = SnackbarDuration.Indefinite, + ) +``` + +and the end of `execute()` becomes `.also { activity.prompts.dismiss(running) }`. + +- [ ] **Step 4: Credential dialog** + +In `CredentialFinder.kt`, replace the whole `if (storedCredential == null) { ... }` branch body, keeping the `else`: + +```kotlin + if (storedCredential == null) { + val resources = callingActivity.resources + callingActivity.prompts.askCredential( + message = resources.getString(messageRes), + hint = resources.getString(hintRes), + remember = resources.getString(rememberRes), + error = if (isRetry) resources.getString(errorRes) else null, + ) { answer -> + if (answer?.remember == true) { + gitOperationPrefs.edit { putString(credentialPref, answer.text) } + } + cont.resume(answer?.text) + } + } else { +``` + +Remove the imports of `SuppressLint`, `LayoutInflater`, `WindowManager`, `doOnTextChanged` and the four Material classes. Delete `app/src/main/res/layout/git_credential_layout.xml`. + +- [ ] **Step 5: Operations** + +`GitOperation.kt`: `open suspend fun preExecute() = true`, and: + +```kotlin + private suspend fun onMissingSshKeyFile() { + val resources = callingActivity.resources + val choice = + callingActivity.prompts.ask( + title = resources.getString(R.string.ssh_preferences_dialog_title), + message = resources.getString(R.string.ssh_preferences_dialog_text), + confirm = resources.getString(R.string.button_label_import), + cancel = resources.getString(R.string.dialog_cancel), + other = resources.getString(R.string.ssh_preferences_dialog_generate), + ) + when (choice) { + Choice.Confirm -> getSshKey(false) + Choice.Other -> getSshKey(true) + // Only closes the dialog: the calling activity is the app's own screen now. + Choice.Cancel -> {} + } + } +``` + +`BreakOutOfDetached.kt`: + +```kotlin + override suspend fun preExecute() = + if (!git.repository.repositoryState.isRebasing && !merging) { + val resources = callingActivity.resources + callingActivity.prompts.ask( + title = resources.getString(R.string.git_abort_and_push_title), + message = resources.getString(R.string.git_break_out_of_detached_unneeded), + confirm = resources.getString(R.string.dialog_ok), + ) + false + } else { + true + } +``` + +`AndroidExtensions.kt:73`: `override suspend fun preExecute(): Boolean {`. + +- [ ] **Step 6: Error dialog** + +`BaseGitActivity.kt`: + +```kotlin + suspend fun promptOnErrorHandler(err: Throwable) { + val error = rootCauseException(err) + if (!isExplicitlyUserInitiatedError(error)) { + gitPrefs.edit { remove(PreferenceKeys.HTTPS_PASSWORD) } + sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } + logcat { error.asLog() } + prompts.ask( + title = getString(R.string.jgit_error_dialog_title), + message = ErrorMessages[error], + confirm = getString(R.string.dialog_ok), + ) + } + } +``` + +No caller passes `onPromptDone`. Drop the `withContext` import if unused. + +- [ ] **Step 7: Store actions** + +`StoreActions.kt`, in `moveFile`, replacing the dialog and its `ponytail` comment: + +```kotlin + prompts.ask( + title = getString(R.string.password_move_error_title), + message = getString(R.string.password_move_error_message, source, destination), + confirm = getString(R.string.dialog_ok), + ) +``` + +and `confirm`: + +```kotlin +/** Shows [message] under [title] and suspends until answered; true when confirmed. */ +private suspend fun BaseGitActivity.confirm(title: String, message: String): Boolean = + prompts.ask( + title = title, + message = message, + confirm = getString(R.string.dialog_ok), + cancel = getString(R.string.dialog_cancel), + ) == Choice.Confirm +``` + +Remove `MaterialAlertDialogBuilder`, `resume` and `suspendCancellableCoroutine` imports. `Context` stays for `recordUse` and `moveHistory`. + +- [ ] **Step 8: Verify no Material dialog or snackbar is left** + +Run: `grep -rnE 'MaterialAlertDialog|material\.snackbar|material\.checkbox|material\.textfield' app/src/main` +Expected: no output. + +Run: `./gradlew spotlessApply && CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug lint` +Expected: BUILD SUCCESSFUL. Delete any string lint reports unused, with its translations. + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "feat(app): move the git dialogs and the snackbar to Compose + +An activity-scoped Prompts ViewModel holds them, since git operations have no +composable to draw in; PromptHost draws them. The credential dialog's window is +now FLAG_SECURE, and a recreated activity no longer loses it and strands the +SSH thread. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 4: AppCompat out of the code + +**Files:** +- Modify: `ui/git/base/BaseGitActivity.kt`, `ui/main/LaunchActivity.kt`, `ui/autofill/AutofillDecryptActivity.kt`, `ui/autofill/AutofillFilterView.kt`, `ui/autofill/AutofillPublisherChangedActivity.kt`, `ui/autofill/AutofillSaveActivity.kt` +- Modify: `util/git/operation/{Push,Pull,Sync,Clone,Gc,ResetToRemote,BreakOutOfDetached,GitOperation}*.kt`, `util/git/sshj/SshjSessionFactory.kt` +- Modify: `Application.kt` +- Modify: `ui/compose/src/main/kotlin/app/passwordstore/ui/compose/theme/Theme.kt` +- Modify: `app/src/main/res/values/themes_material3.xml`, `app/src/main/res/values-night/themes_material3.xml` +- Test: `app/src/test/java/app/passwordstore/ui/main/LaunchActivityTest.kt` must keep passing + +- [ ] **Step 1: Activities and operations** + +```bash +grep -rl 'androidx.appcompat.app.AppCompatActivity' app/src | xargs sed -i \ + -e 's/androidx\.appcompat\.app\.AppCompatActivity/androidx.fragment.app.FragmentActivity/' \ + -e 's/\bAppCompatActivity\b/FragmentActivity/g' +sed -i '/supportActionBar?.hide()/d' \ + app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt \ + app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt +``` + +Update the `MainActivity` class comment: "Extends [BaseGitActivity] (a FragmentActivity, which BiometricPrompt needs) because the git operations require one; it hosts no fragments." + +- [ ] **Step 2: Night mode** + +`Application.kt`, replacing the `AppCompatDelegate.*` import with `android.app.UiModeManager`, and removing the `DynamicColors` import and call: + +```kotlin + private fun setNightMode() { + getSystemService(UiModeManager::class.java) + .setApplicationNightMode( + when (prefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) { + "light" -> UiModeManager.MODE_NIGHT_NO + "dark" -> UiModeManager.MODE_NIGHT_YES + else -> UiModeManager.MODE_NIGHT_AUTO + } + ) + } +``` + +- [ ] **Step 3: Window themes** + +`values/themes_material3.xml`, whole `resources` body. Style names stay: renaming them is churn. + +```xml + <style name="AppThemeM3" parent="APSThemeM3"> + <item name="android:windowLightStatusBar">@bool/light_status_bar</item> + </style> + + <!-- Activities whose whole UI is Compose --> + <style name="NoActionBarThemeM3" parent="AppThemeM3" /> + + <style name="NoBackgroundThemeM3" parent="@style/AppThemeM3"> + <item name="android:background">@android:color/transparent</item> + <item name="android:backgroundDimEnabled">true</item> + <item name="android:navigationBarColor">@android:color/transparent</item> + <item name="android:statusBarColor">@android:color/transparent</item> + <item name="android:windowIsTranslucent">true</item> + <item name="android:windowContentOverlay">@null</item> + <item name="android:windowBackground">@android:color/transparent</item> + <item name="android:windowEnterAnimation">@android:anim/fade_in</item> + <item name="android:windowExitAnimation">@android:anim/fade_out</item> + </style> + + <style name="DialogLikeThemeM3" parent="AppThemeM3"> + <item name="android:windowIsFloating">true</item> + <item name="android:backgroundDimEnabled">true</item> + </style> + + <style name="APSThemeM3" parent="android:Theme.Material.Light.NoActionBar" /> +``` + +`values-night/themes_material3.xml`, whole `resources` body: + +```xml + <style name="APSThemeM3" parent="android:Theme.Material.NoActionBar" /> +``` + +The `android:windowLightStatusBar` item needs `@bool/light_status_bar`, which `values-night/bools.xml` already flips. + +- [ ] **Step 4: System bars** + +`Theme.kt`, the existing `SideEffect`: + +```kotlin + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + val bars = WindowCompat.getInsetsController(window, view) + bars.isAppearanceLightStatusBars = !darkTheme + bars.isAppearanceLightNavigationBars = !darkTheme + paintBars(window, colorScheme.background.toArgb()) + } + } +``` + +```kotlin +/** Colors the system bars, except in windows that keep them see-through (autofill decryption). */ +@Suppress("DEPRECATION") // Deprecated with edge-to-edge, which targetSdk 34 does not force. +private fun paintBars(window: Window, color: Int) { + if (window.statusBarColor == AndroidColor.TRANSPARENT) return + window.statusBarColor = color + window.navigationBarColor = color +} +``` + +Imports: `android.graphics.Color as AndroidColor`, `android.view.Window`, `androidx.compose.ui.graphics.toArgb`. + +- [ ] **Step 5: Verify** + +Run: `grep -rn 'androidx.appcompat' app/src/main ui autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt` +Expected: no output. (`FormField.kt` naming `androidx.appcompat.widget.AppCompatEditText` is a class name matched in other apps' forms and stays.) + +Run: `./gradlew spotlessApply && CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug lint` +Expected: BUILD SUCCESSFUL. `metalava` is unaffected: the theme module's public API does not change. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor(app): take the app's own code off AppCompat + +Activities extend FragmentActivity, window themes derive from the platform's +Material theme and night mode uses UiModeManager. APSTheme colors the system +bars, which DynamicColors did through the window theme. AppCompat stays in the +APK through zxing. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 5: Dependencies, resources, docs + +**Files:** +- Modify: `app/build.gradle.kts:60,65,71`, `gradle/libs.versions.toml:17,30` +- Modify: `app/src/main/res/values/colors_material3.xml`, `AGENTS.md`, `TODO.md` +- Possibly modify: `app/lint-baseline.xml` + +- [ ] **Step 1: Remove the dependencies** + +From `app/build.gradle.kts` remove `implementation(libs.androidx.appcompat)` and `implementation(libs.androidx.material)`, and change the fragment comment to `// FragmentActivity, which BiometricPrompt needs.` From `gradle/libs.versions.toml` remove `androidx-appcompat` and `androidx-material`. + +- [ ] **Step 2: Remove unused colors** + +`oreo_autofill_dataset.xml` uses `@color/md_theme_light_primary`, which stays because it is a `RemoteViews` layout. Delete every other color in `colors_material3.xml`. + +- [ ] **Step 3: Confirm the graph** + +Run: `./gradlew -q :app:dependencies --configuration releaseRuntimeClasspath | grep -E 'google.android.material|appcompat:appcompat' | sort -u` +Expected: no `com.google.android.material` line; `appcompat` only below `zxing-android-embedded` and `biometric`. + +- [ ] **Step 4: Docs** + +`AGENTS.md`: +- Replace the "Material Components (Views) stays after the Compose overhaul" bullet with: "Window themes derive from the platform `android:Theme.Material.*.NoActionBar`, activities from `FragmentActivity` (`BiometricPrompt` needs it). Git operations and store actions show dialogs and snackbars through the activity-scoped `Prompts` ViewModel (`activity.prompts`), drawn by `PromptHost`, which only `MainActivity` and `AutofillSaveActivity` host. AppCompat stays in the APK through zxing (`CaptureManager`, `zxing_CaptureTheme`) and `androidx.biometric`; `oreo_autofill_dataset.xml` stays a layout: fill suggestions are `RemoteViews` drawn by the system." +- In "What this is", add: "minSdk is 31: GrapheneOS runs current Android only, and Android 12 has `UiModeManager.setApplicationNightMode`." + +`TODO.md`: replace the git dialogs item with: + +```markdown +- QR scanner: `zxing-android-embedded` is the last thing keeping AppCompat in the APK + (`androidx.biometric` references it only on the fingerprint dialog path, unused at minSdk 31). + Replacing it would let AppCompat go. +``` + +- [ ] **Step 5: Full check** + +Run: `CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug :app:assembleRelease lint metalavaCheckCompatibilityRelease` +Expected: BUILD SUCCESSFUL. A lint error about a baselined jar path means the entry in `app/lint-baseline.xml` needs its path updated (see `AGENTS.md`). + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "build: remove Material Components and the AppCompat dependency + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 6: Emulator run + +No code unless a check fails. Use the setup in `AGENTS.md` ("End-to-end testing on an emulator"): `scripts/e2e-env.sh up`, AVD `aps-test`, `export ANDROID_SERIAL=emulator-5554`, animations off, `settings put secure autofill_service null`. `FLAG_SECURE` windows show black in `screencap`: read them from `uiautomator dump`. + +- [ ] **Step 1: Install and clone** + +Run `./gradlew :app:installDebug`, clone from the e2e server, import the PGP key, decrypt an entry. + +- [ ] **Step 2: Check each dialog** + +Each must behave as stated: + +| Check | Expected | +|---|---| +| SSH key with passphrase, wrong passphrase | Dialog returns with the error, cleared on typing, field empty | +| Same, remember ticked, right passphrase | Next sync asks nothing | +| Same, Cancel | Operation stops, no error dialog | +| Tap outside the credential dialog | Same as Cancel | +| Missing SSH key | Three buttons; outside tap only closes; Import and Generate open setup | +| Unreachable server | Git error dialog | +| Conflict, then "abort and push" | Recovery works; notice shows on a clean store | +| Move over an existing entry | Confirm dialog, both answers | +| "Operation running" snackbar | Shows, goes away at the end; "done" replaces it | + +- [ ] **Step 3: Check themes** + +Light, dark and system default in Settings: the app switches, status and navigation bars match the screen, icons readable. With "System default", `adb shell cmd uimode night yes` follows. + +- [ ] **Step 4: Check the rest** + +- Theme change with a credential dialog open: dialog comes back, answering works. +- QR scanner opens from the editor. +- Fingerprint unlock, per `AGENTS.md`. +- Autofill windows via `am start -n` after `adb root` (picker, publisher warning, save editor): no action bar, bars look right. + +- [ ] **Step 5: Fix what failed** + +Each failure gets its own `fix(app):` commit, with the full check from Task 5 Step 5 first. + +- [ ] **Step 6: Ask the user for the device run** + +Autofill save and fill on a real device with Firefox cannot run on the emulator. The website docs live in another repository: check them for a stated minimum Android version and tell the user. Release notes for the next version must state Android 12 as the minimum. + +- [ ] **Step 7: Record the run** + +Update "Last full emulator run" in `AGENTS.md` with the date and what passed; commit as `docs: record the emulator run`. diff --git a/docs/superpowers/specs/2026-11-05-git-dialogs-compose-design.md b/docs/superpowers/specs/2026-11-05-git-dialogs-compose-design.md index dba171ac..3ccf5fb1 100644 --- a/docs/superpowers/specs/2026-11-05-git-dialogs-compose-design.md +++ b/docs/superpowers/specs/2026-11-05-git-dialogs-compose-design.md @@ -31,9 +31,9 @@ below Android 12 stay on 0.2.0; the next release notes must say so. `Prompts` is a ViewModel scoped to the activity, reached with the extension `activity.prompts` (`ViewModelProvider(activity)`), so no interface is needed. It offers: -- `suspend fun ask(title, message, confirm, dismiss = null, neutral = null): Choice`, which - suspends until a button is pressed or the dialog is dismissed. Dismissing counts as the - cancelling answer. +- `suspend fun ask(title, message, confirm, cancel = null, other = null): Choice`, which suspends + until a button is pressed or the dialog is dismissed. Dismissing answers `Choice.Cancel`, so a + tap outside the missing SSH key dialog can never mean "generate". - `askCredential(...)` for the credential dialog, delivering the entered text and the remember choice, or null on cancel. - `snackbar(message, length)`, with `length` short, long or indefinite, returning a handle to @@ -43,14 +43,18 @@ below Android 12 stay on 0.2.0; the next release notes must say so. `AutofillSaveActivity`, the only two activities that show these. It draws the current dialog and a `SnackbarHost` at the bottom of the window, above the navigation bar and keyboard. -The state lives in the ViewModel, so a theme or locale recreation redraws a pending dialog instead -of leaving the operation waiting on a dialog that no longer exists. +The state lives in the ViewModel. Git operations run in the activity's `lifecycleScope`, so a theme +or locale recreation cancels them: `ask` removes its dialog when cancelled. The credential dialog +blocks an SSH thread that cancellation cannot reach, so it stays and is redrawn by the new activity +instead of leaving that thread waiting forever. When the activity finishes for good, `onCleared` +answers every pending prompt with its cancelling answer. ### Dialogs These all move to `ask`: -- the git error dialog in `BaseGitActivity.promptOnErrorHandler`, keeping `onPromptDone` +- the git error dialog in `BaseGitActivity.promptOnErrorHandler`, whose unused `onPromptDone` + parameter goes - the missing SSH key dialog in `GitOperation` (import, generate, cancel) - `BreakOutOfDetached`'s "not needed" notice - `moveFile`'s error and `confirm` in `StoreActions` @@ -73,7 +77,9 @@ rather than queueing behind it as `SnackbarHostState` does by default. `GitComma ### minSdk `AndroidCommon.kt` sets 31. Branches that become dead are deleted, including the below-Android-11 -snackbar in `copyTextToClipboard` and its parameters. +snackbar in `copyTextToClipboard` and its parameters, and `Api26AutofillResponseBuilder`. Lint runs +with `warningsAsErrors`, so `ObsoleteSdkInt` lists whatever is missed. `StrictDomainRegexTest`'s +`@Config(sdk = [23])` goes, being below minSdk. ### AppCompat out @@ -90,10 +96,10 @@ snackbar in `copyTextToClipboard` and its parameters. `Application.setNightMode` calls `UiModeManager.setApplicationNightMode`: `light` is `MODE_NIGHT_NO`, `dark` is `MODE_NIGHT_YES`, everything else is `MODE_NIGHT_AUTO`. -"Set by Battery Saver" leaves the settings list, and `app_theme_def` becomes `follow_system`. -Android already turns dark in battery saver, so follow-system covers it. A stored `battery_saver` -value falls into the "everything else" case; no migration is needed, but the settings row must -show "System default" for it rather than nothing. +`values-v29` already selects `follow_system` and hides "Set by Battery Saver" on Android 10 and +later, so nothing changes for users: its `arrays.xml` and `prefs.xml` replace the base ones, the +folder goes, and the battery saver string leaves every locale. A stored `battery_saver` value could +only come from a device that ran the app on Android 9 or older; it still maps to `MODE_NIGHT_AUTO`. ### System bars @@ -116,7 +122,7 @@ reports as unused after this (`md_theme_*`) are deleted. :app:assembleRelease lint metalavaCheckCompatibilityRelease`. - Emulator, on the e2e store: credential dialog (wrong passphrase retry, remember, cancel), missing SSH key dialog, git error dialog, conflict recovery, move error, delete and move confirmations, - every theme setting including a stored `battery_saver`, bar colors in light and dark, QR scanner + every theme setting, bar colors in light and dark, QR scanner opens, fingerprint unlock, a theme change while a dialog is open. - Real device with Firefox: autofill save, since its window now hosts `PromptHost`, and the autofill windows' transparent bars. @@ -133,8 +139,7 @@ reports as unused after this (`md_theme_*`) are deleted. 1. minSdk 31 and dead branches. 2. `Prompts`, `PromptHost` and all call sites. -3. Material out: themes, `DynamicColors`, system bars, dependency. -4. AppCompat out: activities, night mode, settings list, dependency, docs. +3. AppCompat out: activities, themes, night mode, `DynamicColors`, system bars. +4. Dependencies and unused resources out, docs. -After step 3 the window themes must still satisfy AppCompat, so that commit moves them to -`Theme.AppCompat.DayNight.NoActionBar`, and step 4 moves them to `android:Theme.Material.*`. +AppCompat goes before the Material dependency so the window themes change only once. |
