diff options
226 files changed, 28251 insertions, 9956 deletions
@@ -14,6 +14,12 @@ Modules: `app` (UI, git sync, autofill service), `autofill-parser`, `crypto:comm `crypto:pgpainless`, `format:common`, `passgen:diceware`, `passgen:random`, `ui:compose`, `coroutine-utils`. Build conventions live in `build-logic/`. +The UI is Jetpack Compose with Material 3. `MainActivity` hosts every screen in one `NavHost` +(list, entry, editor, setup, settings); `LaunchActivity` is the only exported activity. The +autofill service starts its four activities (`AutofillFilterView`, `AutofillDecryptActivity`, +`AutofillSaveActivity`, `AutofillPublisherChangedActivity`) directly, so they stay separate and +draw with the same theme. + `applicationId` is `xyz.danix.hardenedpass` (debug: `.debug` suffix, label "HardenedPass Debug"), so it installs next to upstream's `app.passwordstore` and never updates over it. The Kotlin package stays `app.passwordstore`; renaming it is churn with no user-visible effect. @@ -65,10 +71,23 @@ in `local.properties`: $ANDROID_HOME/emulator/emulator -avd aps-test -no-window -no-audio -no-boot-anim \ -no-snapshot-save -gpu swiftshader_indirect & - ./gradlew installDebug - -Drive it with `adb shell input`, `uiautomator dump` and `screencap`. Decrypt screens set -`FLAG_SECURE`, so screenshots of them are black; read them from the `uiautomator` dump. + ./gradlew :app:installDebug + +If a physical device is connected too, `export ANDROID_SERIAL=emulator-5554` first, so every +`adb` and Gradle install command targets the emulator. Agent shells keep no functions or +variables between calls: keep that export and any helpers in a sourced file, and prefix helper +names (`e2e_pass`, not `pass`, which is the real `pass` and reaches a real store). Drive it with +`adb shell input`, `uiautomator dump` and `screencap`; Compose nodes carry `text` and +`content-desc` in the dump, no resource ids, so match on those. FABs ("Import PGP key", "New +entry") carry only `content-desc`, and the list's search bar is itself an `EditText`. The entry +view, the editor and its generator sheet, the proxy screen, the passphrase dialogs and the +autofill decryption and save windows set `FLAG_SECURE`, so screenshots of them are black; read +them from the `uiautomator` dump. After boot, wait until the keyguard is dismissed and user +storage is unlocked before `adb uninstall` or `push-keys`: earlier, uninstall fails with +`DELETE_FAILED_INTERNAL_ERROR` and `/sdcard/Download` does not exist yet. In a Compose text +field, wait about a second after the tap before clearing it (Ctrl+A, DEL), or the clear misses. +`adb shell run-as <pkg> sh -c '...'` needs the whole command quoted as one `adb` argument +(`adb shell "run-as <pkg> sh -c '...'"`), or the device shell splits it. Fingerprint: `adb emu finger touch 1` after enrolling one in Settings (needs a lock PIN: `adb shell locksettings set-pin 1234` on the emulator only). Disable animations (`settings put global animator_duration_scale 0`, same for `transition_` and `window_`) @@ -81,8 +100,11 @@ worktree with `~/.android/debug.keystore`, so an in-place upgrade between them n re-signing. Autofill cannot be tested on the emulator image: Settings is denylisted in -`OreoAutofillService`, and the only browser (WebView Shell) is not a trusted browser. Use a -real device with a trusted browser (e.g. Firefox) instead: +`OreoAutofillService`, and the only browser (WebView Shell) is not a trusted browser. Its +activities are not exported; after `adb root` (the AOSP image allows it), `am start -n` opens the +picker, the publisher warning and the save editor with test extras, but not a real decryption +(it needs a client state `Bundle`). Use a real device with a trusted browser (e.g. Firefox) +instead: - `adb reverse tcp:2222 tcp:2222` lets the device clone `ssh://git@127.0.0.1:2222/...` from the e2e server over USB; serve a local login page the same way (`python3 -m http.server` on @@ -98,9 +120,10 @@ real device with a trusted browser (e.g. Firefox) instead: - Only read screens of the debug app, the test page and the autofill UI; the device holds the owner's real data. -Last full device run (2026-11-02): clone over SSH, PGP import, decrypt, TOTP, create and push, -conflict recovery, hard reset, fingerprint unlock (emulator) and Firefox autofill (tablet) all -passed. +Last full emulator run (2026-11-04, Compose UI): clone over SSH, PGP import, decrypt, TOTP, +create and push, conflict recovery (abort rebase, push a new branch), hard reset and +fingerprint unlock passed. Last Firefox autofill run on the tablet: 2026-11-04 (fill, passphrase +cache rule, no keys flow, picker, settings dialog), all passed. ## Verified facts and gotchas @@ -115,8 +138,16 @@ passed. (`<path>:private-key:<line>`). Never allowlist anything but generated test keys. - `app/lint-baseline.xml` entries for library jars are keyed on the jar's cache path, version and hash included. A dependency bump that moves a baselined jar needs the path updated. -- `LaunchActivity` is exported and forwards `FILE_PATH` to `DecryptActivity` (not exported) - only after `isFileInRepo` checks it canonically resolves inside the store. +- `LaunchActivity` is exported and forwards `FILE_PATH` to `MainActivity` (not exported) only + after `isFileInRepo` checks it canonically resolves inside the store; `MainActivity` opens it as + `EntryRoute`, whose destination checks it again before decrypting. +- Material Components (Views) stays after the Compose overhaul: the window themes derive from + `Theme.Material3.DayNight` (the AppCompat activities need it), and the git operations' View + dialogs (`BaseGitActivity`, `GitOperation`, `CredentialFinder` with + `git_credential_layout.xml`, `BreakOutOfDetached`, `StoreActions`), `DynamicColors` and the + `snackbar` helper use it. `oreo_autofill_dataset.xml` stays a layout: fill suggestions are + `RemoteViews` drawn by the system. `fragment-ktx` is declared only to pin `androidx.fragment` + (appcompat alone resolves an older one). - sshj gets Ed25519 keys from Bouncy Castle: `SshjConfig.kt` pins `KeyFactory.Ed25519` to it at provider position 1. Conscrypt from Android 14 mainline updates serves that factory with keys sshj maps to `KeyType.UNKNOWN` ("Don't know how to encode key"), breaking every Ed25519 host diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f83d413..f681cf52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ We bundle a [`ignore-revs-file`](https://git-scm.com/docs/git-blame#Documentatio ## Source code conventions - Unless you're absolutely sure what you're doing, always prefer the [`unsafeLazy`](https://github.com/android-password-store/Android-Password-Store/blob/f870dd49138d63d9803783458abf1356176851db/app/src/main/java/app/passwordstore/util/extensions/Extensions.kt#L68) method over Kotlin's inbuilt `lazy` for lazily evaluated properties. -- For inflating a Fragment or Activity view, always use the [`viewBinding`](https://github.com/android-password-store/Android-Password-Store/blob/3a16d77db24b6d79d9d27ce3207d72a7a879f38a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt) extension. +- The UI is Jetpack Compose (Material 3): screens are stateless composables driven by a ViewModel. Do not add XML layouts, Fragments or view binding. ## Building the project diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 68fc7bad..cad899f9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,6 +12,7 @@ plugins { alias(libs.plugins.hilt) alias(libs.plugins.ksp) alias(libs.plugins.kotlin.composeCompiler) + alias(libs.plugins.kotlin.serialization) } android { @@ -59,35 +60,32 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.autofill) implementation(libs.androidx.biometricKtx) - implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core.ktx) implementation(libs.androidx.documentfile) + // Pins androidx.fragment (FragmentActivity, BiometricPrompt): appcompat alone resolves 1.5.4. implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.hilt.navigation.compose) implementation(libs.bundles.androidxLifecycle) implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.material) - implementation(libs.androidx.preference) - implementation(libs.androidx.recyclerview) - implementation(libs.androidx.recyclerviewSelection) - implementation(libs.androidx.swiperefreshlayout) + implementation(libs.androidx.navigation.compose) implementation(libs.compose.ui.tooling) implementation(libs.dagger.hilt.android) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.core) implementation(libs.aps.sublimeFuzzy) implementation(libs.aps.zxingAndroidEmbedded) - implementation(libs.thirdparty.fastscroll) - implementation(libs.thirdparty.flowbinding.android) implementation(libs.thirdparty.jgit) { exclude(group = "org.apache.httpcomponents", module = "httpclient") } implementation(libs.thirdparty.kotlinResult) implementation(libs.thirdparty.logcat) - implementation(libs.thirdparty.modernAndroidPrefs) implementation(libs.thirdparty.leakcanary.plumber) implementation(libs.thirdparty.sshj) implementation(libs.thirdparty.bouncycastle.bcprov) diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index d9be2a91..f77d22dd 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -36,55 +36,11 @@ errorLine2=" ~~~~~~~~~~~~~~"> <location file="src/main/java/app/passwordstore/util/git/sshj/SshKey.kt" - line="238" + line="249" column="17"/> </issue> <issue - id="RawDispatchersUse" - message="Use SlackDispatchers." - errorLine1=" withContext(Dispatchers.IO) {" - errorLine2=" ~~~~~~~~~~~~~~"> - <location - file="src/main/java/app/passwordstore/util/git/sshj/SshKey.kt" - line="247" - column="17"/> - </issue> - - <issue - id="RawDispatchersUse" - message="Use SlackDispatchers." - errorLine1=" withContext(Dispatchers.IO) {" - errorLine2=" ~~~~~~~~~~~~~~"> - <location - file="src/main/java/app/passwordstore/util/git/sshj/SshKey.kt" - line="259" - column="17"/> - </issue> - - <issue - id="DenyListedApi" - message="Use Context#getDrawableCompat() instead" - errorLine1=" ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)" - errorLine2=" ~~~~~~~~~~~"> - <location - file="src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt" - line="69" - column="31"/> - </issue> - - <issue - id="DenyListedApi" - message="Use Context#getDrawableCompat() instead" - errorLine1=" ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)" - errorLine2=" ~~~~~~~~~~~"> - <location - file="src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt" - line="69" - column="31"/> - </issue> - - <issue id="MissingQuantity" message="For locale "it" (Italian) the following quantity should also be defined: `many`" errorLine1=" <plurals name="delete_title">" @@ -145,39 +101,6 @@ <issue id="SelectableText" message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`" - errorLine1=" <TextView" - errorLine2=" ~~~~~~~~"> - <location - file="src/main/res/layout/activity_oreo_autofill_filter.xml" - line="27" - column="4"/> - </issue> - - <issue - id="SelectableText" - message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`" - errorLine1=" <TextView" - errorLine2=" ~~~~~~~~"> - <location - file="src/main/res/layout/activity_oreo_autofill_publisher_changed.xml" - line="66" - column="4"/> - </issue> - - <issue - id="SelectableText" - message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`" - errorLine1=" <TextView" - errorLine2=" ~~~~~~~~"> - <location - file="src/main/res/layout/activity_oreo_autofill_publisher_changed.xml" - line="81" - column="4"/> - </issue> - - <issue - id="SelectableText" - message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`" errorLine1=" <TextView" errorLine2=" ~~~~~~~~"> <location @@ -197,48 +120,4 @@ column="6"/> </issue> - <issue - id="SelectableText" - message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`" - errorLine1=" <TextView" - errorLine2=" ~~~~~~~~"> - <location - file="src/main/res/layout/oreo_autofill_filter_row.xml" - line="29" - column="4"/> - </issue> - - <issue - id="SelectableText" - message="Consider making the text value selectable by specifying `android:textIsSelectable="true"`" - errorLine1=" <TextView" - errorLine2=" ~~~~~~~~"> - <location - file="src/main/res/layout/oreo_autofill_filter_row.xml" - line="43" - column="4"/> - </issue> - - <issue - id="SetTextI18n" - message="Number formatting does not take into account locale settings. Consider using `String.format` instead." - errorLine1=" binding.passwordLengthText.setText(prefs.getInt(DICEWARE_LENGTH, 5).toString())" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt" - line="46" - column="40"/> - </issue> - - <issue - id="SetTextI18n" - message="Number formatting does not take into account locale settings. Consider using `String.format` instead." - errorLine1=" binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString())" - errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - <location - file="src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt" - line="54" - column="34"/> - </issue> - </issues> diff --git a/app/lint.xml b/app/lint.xml index 6461b16f..44b84e59 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -5,7 +5,6 @@ <lint> <issue id="ContentDescription"> <ignore path="src/main/res/layout/oreo_autofill_dataset.xml" /> - <ignore path="src/main/res/layout/oreo_autofill_filter_row.xml" /> </issue> <issue id="InvalidPackage"> <ignore regexp="X509LDAPCertStoreSpi" /> @@ -13,12 +12,6 @@ <issue id="MissingQuantity"> <ignore path="src/main/res/values-fr/strings.xml" /> </issue> - <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> <issue id="TrustAllX509TrustManager"> <ignore regexp="bouncycastle" /> <ignore regexp="jgit" /> @@ -26,13 +19,6 @@ <issue id="UnusedResources"> <ignore path="src/main/res/values/bools.xml" /> <ignore path="src/main/res/values/colors_material3.xml" /> - <ignore path="src/main/res/layout/map_preference_widget_switch.xml" /> - </issue> - <issue id="PrivateResource"> - <ignore path="src/main/res/layout/map_preference_widget_switch.xml" /> - </issue> - <issue id="Deprecated"> - <ignore path="src/main/res/layout/item_field.xml" /> </issue> <issue id="VectorPath"> <ignore path="src/main/res/drawable/app_settings_alt_24px.xml" /> diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1cbe76a9..15d6447e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,36 +34,24 @@ tools:targetApi="s"> <activity - android:name=".ui.passwords.PasswordStore" - android:configChanges="orientation|screenSize" - android:exported="false" /> - - <activity - android:name=".ui.onboarding.activity.OnboardingActivity" - android:configChanges="orientation|screenSize" - android:exported="false" /> - - <activity - android:name=".ui.proxy.ProxySelectorActivity" - android:exported="false" - android:label="@string/activity_proxy_label" - android:windowSoftInputMode="adjustResize" /> - - <activity - android:name=".ui.crypto.DecryptActivity" - android:exported="false" /> - - <activity android:name=".ui.main.LaunchActivity" android:configChanges="orientation|screenSize" android:exported="true" - android:theme="@style/NoBackgroundThemeM3"> + android:theme="@style/NoActionBarThemeM3"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> + <!-- ponytail: rotation must not cancel store and git work in lifecycleScope; theme or locale changes still recreate. --> + <activity + android:name=".ui.main.MainActivity" + android:configChanges="orientation|screenSize" + android:exported="false" + android:theme="@style/NoActionBarThemeM3" + android:windowSoftInputMode="adjustResize" /> + <activity android:name="com.journeyapps.barcodescanner.CaptureActivity" android:clearTaskOnLaunch="true" @@ -73,35 +61,6 @@ android:windowSoftInputMode="stateAlwaysHidden" tools:node="replace" /> - <activity - android:name=".ui.git.config.GitServerConfigActivity" - android:exported="false" - android:label="@string/title_activity_git_clone" - android:windowSoftInputMode="adjustResize" /> - - <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.passwords.PasswordStore" /> - - <activity - android:name=".ui.crypto.PasswordCreationActivity" - android:exported="false" - android:label="@string/new_password_title" - android:windowSoftInputMode="adjustResize" /> - <service android:name=".util.services.ClipboardService" android:exported="false" @@ -124,21 +83,11 @@ android:resource="@xml/oreo_autofill_service" /> </service> - <activity - android:name=".ui.folderselect.SelectFolderActivity" - android:exported="false" /> - <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" /> + <!-- 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" /> <activity @@ -147,24 +96,19 @@ android:exported="false" android:theme="@style/DialogLikeThemeM3" android:windowSoftInputMode="adjustNothing" /> + <!-- 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/NoBackgroundThemeM3" /> + android:theme="@style/NoActionBarThemeM3" + android:windowSoftInputMode="adjustResize" /> <activity android:name=".ui.autofill.AutofillPublisherChangedActivity" android:configChanges="orientation|keyboardHidden" android:exported="false" android:theme="@style/DialogLikeThemeM3" android:windowSoftInputMode="adjustNothing" /> - <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" /> </application> <queries> diff --git a/app/src/main/java/app/passwordstore/data/password/FieldItem.kt b/app/src/main/java/app/passwordstore/data/password/FieldItem.kt deleted file mode 100644 index 56ee8e0e..00000000 --- a/app/src/main/java/app/passwordstore/data/password/FieldItem.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.data.password - -import app.passwordstore.data.passfile.Totp - -class FieldItem -private constructor( - val type: ItemType, - val label: String, - val value: String, - val action: ActionType, -) { - enum class ActionType { - COPY, - HIDE, - } - - enum class ItemType() { - USERNAME, - PASSWORD, - OTP, - FREEFORM, - } - - companion object { - fun createOtpField(label: String, totp: Totp): FieldItem { - return FieldItem( - ItemType.OTP, - label.format(totp.remainingTime.inWholeSeconds), - totp.value, - ActionType.COPY, - ) - } - - fun createPasswordField(label: String, password: String): FieldItem { - return FieldItem(ItemType.PASSWORD, label, password, ActionType.HIDE) - } - - fun createUsernameField(label: String, username: String): FieldItem { - return FieldItem(ItemType.USERNAME, label, username, ActionType.COPY) - } - - fun createFreeformField(label: String, content: String): FieldItem { - return FieldItem(ItemType.FREEFORM, label, content, ActionType.COPY) - } - } -} diff --git a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt index 82798a05..5d32108f 100644 --- a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt +++ b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt @@ -6,22 +6,16 @@ package app.passwordstore.data.password import android.content.Context import android.content.Intent -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.BasePGPActivity +import app.passwordstore.ui.crypto.getLongName import app.passwordstore.ui.main.LaunchActivity import java.io.File -data class PasswordItem( - val name: String, - val parent: PasswordItem? = null, - val type: Char, - val file: File, - val rootDir: File, -) : Comparable<PasswordItem> { +data class PasswordItem(val name: String, val type: Char, val file: File, val rootDir: File) : + Comparable<PasswordItem> { val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "") - val longName = BasePGPActivity.getLongName(fullPathToParent, rootDir.absolutePath, toString()) + val longName = getLongName(fullPathToParent, rootDir.absolutePath, toString()) override fun equals(other: Any?): Boolean { return (other is PasswordItem) && (other.file == file) @@ -42,9 +36,7 @@ data class PasswordItem( /** Creates an [Intent] to launch this [PasswordItem] through the authentication process. */ fun createAuthEnabledIntent(context: Context): Intent { val intent = Intent(context, LaunchActivity::class.java) - intent.putExtra("NAME", toString()) - intent.putExtra("FILE_PATH", file.absolutePath) - intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath) + intent.putExtra(LaunchActivity.EXTRA_FILE_PATH, file.absolutePath) intent.action = LaunchActivity.ACTION_DECRYPT_PASS return intent } @@ -54,24 +46,12 @@ data class PasswordItem( const val TYPE_CATEGORY = 'c' const val TYPE_PASSWORD = 'p' - @JvmStatic - fun newCategory(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem { - return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir) - } - - @JvmStatic fun newCategory(name: String, file: File, rootDir: File): PasswordItem { - return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir) - } - - @JvmStatic - fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem { - return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir) + return PasswordItem(name, TYPE_CATEGORY, file, rootDir) } - @JvmStatic fun newPassword(name: String, file: File, rootDir: File): PasswordItem { - return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir) + return PasswordItem(name, TYPE_PASSWORD, file, rootDir) } } } diff --git a/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt b/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt index 35c744a2..c9c4e928 100644 --- a/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt +++ b/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt @@ -13,14 +13,14 @@ import app.passwordstore.passgen.diceware.RandomIntGenerator import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.components.FragmentComponent +import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.android.qualifiers.ApplicationContext import java.io.InputStream import java.security.SecureRandom import javax.inject.Qualifier @Module -@InstallIn(FragmentComponent::class) +@InstallIn(ViewModelComponent::class) object DicewareModule { @Provides diff --git a/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt b/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt index aa85d002..b7d32b71 100644 --- a/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt +++ b/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt @@ -11,9 +11,10 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.components.ViewModelComponent @Module -@InstallIn(ActivityComponent::class) +@InstallIn(ActivityComponent::class, ViewModelComponent::class) interface TotpModule { @Binds fun bindTotpFinder(impl: UriTotpFinder): TotpFinder } diff --git a/app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt b/app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt deleted file mode 100644 index 57428ffb..00000000 --- a/app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.adapters - -import android.text.method.PasswordTransformationMethod -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat -import androidx.recyclerview.widget.RecyclerView -import app.passwordstore.R -import app.passwordstore.data.passfile.Totp -import app.passwordstore.data.password.FieldItem -import app.passwordstore.databinding.ItemFieldBinding -import app.passwordstore.ui.compose.R as composeR -import com.google.android.material.textfield.TextInputLayout - -class FieldItemAdapter( - private var fieldItemList: List<FieldItem>, - private val showPassword: Boolean, - private val copyTextToClipboard: (text: String?) -> Unit, -) : RecyclerView.Adapter<FieldItemAdapter.FieldItemViewHolder>() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder { - val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return FieldItemViewHolder(binding.root, binding) - } - - override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) { - holder.bind(fieldItemList[position], showPassword, copyTextToClipboard) - } - - override fun getItemCount(): Int { - return fieldItemList.size - } - - fun updateOTPCode(totp: Totp, labelFormat: String) { - var otpItemPosition = -1 - fieldItemList = - fieldItemList.mapIndexed { position, item -> - if (item.type == FieldItem.ItemType.OTP) { - otpItemPosition = position - return@mapIndexed FieldItem.createOtpField(labelFormat, totp) - } - - return@mapIndexed item - } - - notifyItemChanged(otpItemPosition) - } - - class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) : - RecyclerView.ViewHolder(itemView) { - - fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipboard: (String?) -> Unit) { - with(binding) { - itemText.hint = fieldItem.label - itemTextContainer.hint = fieldItem.label - itemText.setText(fieldItem.value) - - when (fieldItem.action) { - FieldItem.ActionType.COPY -> { - itemTextContainer.apply { - endIconDrawable = - ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy) - endIconMode = TextInputLayout.END_ICON_CUSTOM - setEndIconOnClickListener { copyTextToClipboard(itemText.text.toString()) } - } - itemText.transformationMethod = null - } - FieldItem.ActionType.HIDE -> { - itemTextContainer.apply { - endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE - setOnClickListener { copyTextToClipboard(itemText.text.toString()) } - } - itemText.apply { - transformationMethod = - if (!showPassword) { - PasswordTransformationMethod.getInstance() - } else { - null - } - if (fieldItem.type == FieldItem.ItemType.PASSWORD) { - typeface = - ResourcesCompat.getFont( - binding.root.context, - composeR.font.jetbrainsmono_nl_regular, - ) - } - setOnClickListener { copyTextToClipboard(itemText.text.toString()) } - } - } - } - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt b/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt deleted file mode 100644 index 54be64d6..00000000 --- a/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.adapters - -import android.text.SpannableString -import android.text.style.RelativeSizeSpan -import android.view.MotionEvent -import android.view.View -import androidx.appcompat.widget.AppCompatImageView -import androidx.appcompat.widget.AppCompatTextView -import androidx.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.selection.Selection -import androidx.recyclerview.widget.RecyclerView -import app.passwordstore.R -import app.passwordstore.data.password.PasswordItem -import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.viewmodel.SearchableRepositoryAdapter -import app.passwordstore.util.viewmodel.stableId -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.withContext - -open class PasswordItemRecyclerAdapter( - coroutineScope: CoroutineScope, - dispatcherProvider: DispatcherProvider, -) : - SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>( - R.layout.password_row_layout, - ::PasswordItemViewHolder, - coroutineScope, - dispatcherProvider, - PasswordItemViewHolder::bind, - ) { - - fun makeSelectable(recyclerView: RecyclerView) { - makeSelectable(recyclerView, ::PasswordItemDetailsLookup) - } - - override fun onItemClicked( - listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit - ): PasswordItemRecyclerAdapter { - return super.onItemClicked(listener) as PasswordItemRecyclerAdapter - } - - override fun onSelectionChanged( - listener: (selection: Selection<String>) -> Unit - ): PasswordItemRecyclerAdapter { - return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter - } - - class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) { - - private val name: AppCompatTextView = itemView.findViewById(R.id.label) - private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count) - private val folderIndicator: AppCompatImageView = itemView.findViewById(R.id.folder_indicator) - var itemDetails: ItemDetailsLookup.ItemDetails<String>? = null - - suspend fun bind(item: PasswordItem, dispatcherProvider: DispatcherProvider) { - val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "") - val source = - if (parentPath.isNotEmpty()) { - "$parentPath\n$item" - } else { - "$item" - } - val spannable = SpannableString(source) - spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0) - name.text = spannable - if (item.type == PasswordItem.TYPE_CATEGORY) { - folderIndicator.visibility = View.VISIBLE - val count = - withContext(dispatcherProvider.io()) { - item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size ?: 0 - } - childCount.visibility = if (count > 0) View.VISIBLE else View.GONE - childCount.text = "$count" - } else { - childCount.visibility = View.GONE - folderIndicator.visibility = View.GONE - } - itemDetails = - object : ItemDetailsLookup.ItemDetails<String>() { - override fun getPosition() = absoluteAdapterPosition - - override fun getSelectionKey() = item.stableId - } - } - } - - class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : - ItemDetailsLookup<String>() { - - override fun getItemDetails(event: MotionEvent): ItemDetails<String>? { - val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null - return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt index 70525083..3f0fb5ef 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt @@ -8,24 +8,42 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.IntentSender +import android.content.SharedPreferences import android.os.Build import android.os.Bundle +import android.view.WindowManager import android.view.autofill.AutofillManager +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.fragment.app.setFragmentResultListener import androidx.lifecycle.lifecycleScope import app.passwordstore.Application.Companion.screenWasOff import app.passwordstore.R import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.data.crypto.CryptoRepository import app.passwordstore.data.crypto.PGPPassphraseCache import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.BasePGPActivity -import app.passwordstore.ui.crypto.PasswordDialog +import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.crypto.getPGPIdentifiers +import app.passwordstore.ui.crypto.getParentPath +import app.passwordstore.ui.entry.PassphraseDialog +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.setup.SetupStart import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.autofill.AutofillResponseBuilder +import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.asLog import app.passwordstore.util.features.Feature.EnablePGPPassphraseCache import app.passwordstore.util.features.Features @@ -47,62 +65,157 @@ 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() { +class AutofillDecryptActivity : AppCompatActivity() { + @SettingsPreferences @Inject lateinit var settings: SharedPreferences + @Inject lateinit var repository: CryptoRepository + @Inject lateinit var dispatcherProvider: DispatcherProvider @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory @Inject lateinit var features: Features @Inject lateinit var passphraseCache: PGPPassphraseCache private lateinit var directoryStructure: DirectoryStructure + private lateinit var filePath: String + private lateinit var clientState: Bundle + private lateinit var action: AutofillAction private var clearCache = true - override fun onStart() { - super.onStart() - val filePath = + /** 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) + // Before anything is drawn: the passphrase dialog's parent stays out of screenshots and + // recents. + window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + // ponytail: a recreation (theme or locale change; rotation and resizing do not recreate, see + // the manifest) cancels the fill instead of resuming a half-done flow; the user taps the field + // again. Keep the flow's state in a ViewModel if this ever matters. + if (savedInstanceState != null) { + finish() + return + } + filePath = intent?.getStringExtra(EXTRA_FILE_PATH) ?: run { logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_FILE_PATH" } finish() return } - val clientState = + 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)!! - val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match + val isSearchAction = intent.getBooleanExtra(EXTRA_SEARCH_ACTION, true) + action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match directoryStructure = AutofillPreferences.directoryStructure(this) logcat { action.toString() } - requireKeysExist { - if ( - features.isEnabled(EnablePGPPassphraseCache) && BiometricAuthenticator.canAuthenticate(this) - ) { - BiometricAuthenticator.authenticate( - this, - R.string.biometric_prompt_title_gpg_passphrase_cache, - ) { authResult -> - decrypt(filePath, clientState, action, authResult) - } - } else { - decrypt(filePath, clientState, action, BiometricResult.CanceledByUser) + 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, typed = true) + } + }, + onCancel = { finish() }, + ) } } } - private fun decrypt( - filePath: String, - clientState: Bundle, - action: AutofillAction, - authResult: BiometricResult, - ) { + // 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 + ) + ?: run { + finish() + return + } lifecycleScope.launch(dispatcherProvider.main()) { when (authResult) { // Internally handled by the prompt dialog @@ -111,8 +224,7 @@ class AutofillDecryptActivity : BasePGPActivity() { is BiometricResult.CanceledBySystem, is BiometricResult.CanceledByUser, is BiometricResult.Failure, - is BiometricResult.HardwareUnavailableOrDisabled -> - askPassphrase(filePath, gpgIdentifiers, clientState, action) + 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 */ @@ -127,56 +239,30 @@ class AutofillDecryptActivity : BasePGPActivity() { gpgIdentifiers.first(), ) if (cachedPassphrase != null) { - decryptWithPassphrase( - File(filePath), - gpgIdentifiers, - clientState, - action, - cachedPassphrase, - ) + decryptWithPassphrase(gpgIdentifiers, cachedPassphrase) } else { - askPassphrase(filePath, gpgIdentifiers, clientState, action) + askPassphrase(gpgIdentifiers) } } } } } - private suspend fun askPassphrase( - filePath: String, - identifiers: List<PGPIdentifier>, - clientState: Bundle, - action: AutofillAction, - ) { + private suspend fun askPassphrase(identifiers: List<PGPIdentifier>) { if (!repository.isPasswordProtected(identifiers)) { - decryptWithPassphrase(File(filePath), identifiers, clientState, action, password = "") + decryptWithPassphrase(identifiers, password = "") return } - val dialog = - PasswordDialog.newInstance( - cacheEnabled = features.isEnabled(EnablePGPPassphraseCache), - clearCache = clearCache, - ) - dialog.show(supportFragmentManager, "PASSWORD_DIALOG") - dialog.setFragmentResultListener(PasswordDialog.PASSWORD_RESULT_KEY) { key, bundle -> - if (key == PasswordDialog.PASSWORD_RESULT_KEY) { - val value = bundle.getString(PasswordDialog.PASSWORD_PHRASE_KEY)!! - clearCache = bundle.getBoolean(PasswordDialog.PASSWORD_CLEAR_KEY) - lifecycleScope.launch(dispatcherProvider.main()) { - decryptWithPassphrase(File(filePath), identifiers, clientState, action, value) - } - } - } + passphraseFor = identifiers } + /** Caches [password] only when the user [typed] it, as the entry view does. */ private suspend fun decryptWithPassphrase( - filePath: File, identifiers: List<PGPIdentifier>, - clientState: Bundle, - action: AutofillAction, password: String, + typed: Boolean = false, ) { - val credentials = decryptCredential(filePath, password, identifiers) + val credentials = decryptCredential(File(filePath), password, identifiers, typed) if (credentials == null) { setResult(RESULT_CANCELED) } else { @@ -201,6 +287,7 @@ class AutofillDecryptActivity : BasePGPActivity() { file: File, password: String, identifiers: List<PGPIdentifier>, + typed: Boolean, ): Credentials? { runCatching { file.readBytes().inputStream() } .onFailure { e -> @@ -218,7 +305,9 @@ class AutofillDecryptActivity : BasePGPActivity() { .onSuccess { result -> return runCatching { runCatching { - if (features.isEnabled(EnablePGPPassphraseCache)) { + if ( + typed && cachesPassphrase(features.isEnabled(EnablePGPPassphraseCache), auth) + ) { passphraseCache.cachePassphrase(this, identifiers.first(), password) settings.edit { putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, clearCache) diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt new file mode 100644 index 00000000..acbacd40 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt @@ -0,0 +1,149 @@ +/* + * 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.lazy.rememberLazyListState +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.runtime.LaunchedEffect +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) + val listState = rememberLazyListState() + // As the old picker did: every new result starts at its best match. + LaunchedEffect(rows) { listState.scrollToItem(0) } + 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(state = listState) { + 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, + ) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt index b027657e..c607b81f 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt @@ -10,36 +10,36 @@ import android.content.Intent import android.content.IntentSender import android.os.Build import android.os.Bundle -import android.view.View import android.view.autofill.AutofillManager -import android.widget.TextView +import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.text.bold -import androidx.core.text.buildSpannedString -import androidx.core.text.underline -import androidx.core.widget.addTextChangedListener -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager +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.lifecycle.compose.collectAsStateWithLifecycle import app.passwordstore.R -import app.passwordstore.data.password.PasswordItem -import app.passwordstore.databinding.ActivityOreoAutofillFilterBinding +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.extensions.viewBinding -import app.passwordstore.util.settings.DirectoryStructure import app.passwordstore.util.viewmodel.FilterMode import app.passwordstore.util.viewmodel.ListMode import app.passwordstore.util.viewmodel.SearchMode -import app.passwordstore.util.viewmodel.SearchableRepositoryAdapter 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.coroutines.flow.collect -import kotlinx.coroutines.launch +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import logcat.LogPriority.ERROR import logcat.logcat @@ -85,11 +85,12 @@ class AutofillFilterView : AppCompatActivity() { } private lateinit var formOrigin: FormOrigin - private lateinit var directoryStructure: DirectoryStructure - private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate) 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) { @@ -100,7 +101,10 @@ class AutofillFilterView : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(binding.root) + // The picker decides which entry fills the requesting app: drop touches under an overlay, as + // the + // publisher warning does. + window.decorView.filterTouchesWhenObscured = true setFinishOnTouchOutside(true) val params = window.attributes @@ -129,111 +133,77 @@ class AutofillFilterView : AppCompatActivity() { return } } - directoryStructure = AutofillPreferences.directoryStructure(this) - supportActionBar?.hide() - bindUI() - updateSearch() setResult(RESULT_CANCELED) - } - private fun bindUI() { - with(binding) { - rvPassword.apply { - adapter = - SearchableRepositoryAdapter( - R.layout.oreo_autofill_filter_row, - ::PasswordViewHolder, - lifecycleScope, - dispatcherProvider, - ) { item, _ -> - val file = item.file.relativeTo(item.rootDir) - val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file) - val identifier = directoryStructure.getIdentifierFor(file) - val accountPart = directoryStructure.getAccountPartFor(file) - check(identifier != null || accountPart != null) { - "At least one of identifier and accountPart should always be non-null" - } - title.text = - if (identifier != null) { - buildSpannedString { - if (pathToIdentifier != null) append("$pathToIdentifier/") - bold { underline { append(identifier) } } - } - } else { - accountPart - } - subtitle.apply { - if (identifier != null && accountPart != null) { - text = accountPart - visibility = View.VISIBLE - } else { - visibility = View.GONE - } - } - } - .onItemClicked { _, item -> decryptAndFill(item) } - layoutManager = LinearLayoutManager(context) - } - search.apply { - val initialSearch = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) - setText(initialSearch, TextView.BufferType.EDITABLE) - addTextChangedListener { updateSearch() } - } - origin.text = buildSpannedString { - append(getString(R.string.oreo_autofill_select_and_fill_into)) - append("\n") - bold { append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) } - } - strictDomainSearch.apply { - visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE - isChecked = formOrigin is FormOrigin.Web - setOnCheckedChangeListener { _, _ -> updateSearch() } - } - shouldMatch.text = - getString( - R.string.oreo_autofill_match_with, - formOrigin.getPrettyIdentifier(applicationContext), + val isWeb = formOrigin is FormOrigin.Web + val initialQuery = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) + 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) } + // During the first composition, before rows is collected, so the ViewModel's default root + // listing (folders included) is never shown, and with the restored query and switch after + // recreation. The LaunchedEffect repeats it with equal arguments, which emits nothing. + remember { search(query, strict) } + 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) }, ) - lifecycleScope.launch { handleSearchResults() } - } - } - - private suspend fun handleSearchResults() { - model.searchResult.collect { result -> - val list = result.passwordItems - (binding.rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) { - binding.rvPassword.scrollToPosition(0) - } - // Switch RecyclerView out for a "no results" message if the new list is empty and - // the message is not yet shown (and vice versa). - if ( - (list.isEmpty() && binding.rvPasswordSwitcher.nextView.id == binding.rvPasswordEmpty.id) || - (list.isNotEmpty() && binding.rvPasswordSwitcher.nextView.id == binding.rvPassword.id) - ) { - binding.rvPasswordSwitcher.showNext() } } } - private fun updateSearch() { + /** The old picker's search: fuzzy or strict domain, recursive, entries only. */ + private fun search(query: String, strict: Boolean) { model.search( - binding.search.text.toString().trim(), - filterMode = - if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy, + query.trim(), + filterMode = if (strict) FilterMode.StrictDomain else FilterMode.Fuzzy, searchMode = SearchMode.RecursivelyInSubdirectories, listMode = ListMode.FilesOnly, ) } - private fun decryptAndFill(item: PasswordItem) { - if (binding.shouldClear.isChecked) - AutofillMatcher.clearMatchesFor(applicationContext, formOrigin) - if (binding.shouldMatch.isChecked) - AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file) + 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(item.file, intent!!.extras!!, this) + AutofillDecryptActivity.makeDecryptFileIntent(file, intent!!.extras!!, this) ) } } diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt new file mode 100644 index 00000000..9f436ea2 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt @@ -0,0 +1,47 @@ +/* + * 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.auth.BiometricAuthenticator.Result as BiometricResult +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 ?: file.nameWithoutExtension, subtitle, 0) + } + .toImmutableList() + +/** + * 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 diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt index 5b55143a..803896a5 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt @@ -13,21 +13,20 @@ import android.os.Build import android.os.Bundle import android.service.autofill.FillResponse import android.text.format.DateUtils -import android.view.View 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.databinding.ActivityOreoAutofillPublisherChangedBinding +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 app.passwordstore.util.extensions.viewBinding import com.github.androidpasswordstore.autofillparser.FormOrigin import com.github.androidpasswordstore.autofillparser.computeCertificatesHash -import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.getOrElse import com.github.michaelbull.result.runCatching import logcat.LogPriority.ERROR import logcat.logcat @@ -65,15 +64,15 @@ class AutofillPublisherChangedActivity : AppCompatActivity() { } } - private lateinit var appPackage: String - private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate) - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(binding.root) 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 - appPackage = + val appPackage = intent.getStringExtra(EXTRA_APP_PACKAGE) ?: run { logcat(ERROR) { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" } @@ -81,63 +80,57 @@ class AutofillPublisherChangedActivity : AppCompatActivity() { return } supportActionBar?.hide() - showPackageInfo() - with(binding) { - okButton.setOnClickListener { finish() } - advancedButton.setOnClickListener { - advancedButton.visibility = View.GONE - warningAppAdvancedInfo.visibility = View.VISIBLE - resetButton.visibility = View.VISIBLE - } - resetButton.setOnClickListener { - AutofillMatcher.clearMatchesFor( - this@AutofillPublisherChangedActivity, - FormOrigin.App(appPackage), - ) - val fillResponse = - IntentCompat.getParcelableExtra( - intent, - EXTRA_FILL_RESPONSE_AFTER_RESET, - FillResponse::class.java, + val (packageInfo, appInfo, hash) = + runCatching { + Triple( + packageManager.getPackageInfoCompat(appPackage, PackageManager.GET_META_DATA), + packageManager.getApplicationInfoCompat(appPackage, PackageManager.GET_META_DATA), + computeCertificatesHash(this, appPackage), ) - setResult( - RESULT_OK, - Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) }, + } + .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) }, ) - finish() } } } - private fun showPackageInfo() { - runCatching { - with(binding) { - val packageInfo = - packageManager.getPackageInfoCompat(appPackage, PackageManager.GET_META_DATA) - val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime) - warningAppInstallDate.text = - getString(R.string.oreo_autofill_warning_publisher_install_time, installTime) - val appInfo = - packageManager.getApplicationInfoCompat(appPackage, PackageManager.GET_META_DATA) - warningAppName.text = - getString( - R.string.oreo_autofill_warning_publisher_app_name, - packageManager.getApplicationLabel(appInfo), - ) - - val currentHash = - computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage) - warningAppAdvancedInfo.text = - getString( - R.string.oreo_autofill_warning_publisher_advanced_info_template, - appPackage, - currentHash, - ) - } - } - .onFailure { e -> - logcat(ERROR) { e.asLog("Failed to retrieve package info for $appPackage") } - finish() - } + /** 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() } } diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt index 9476b344..eb533411 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt @@ -9,12 +9,17 @@ 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.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.PasswordCreationActivity +import app.passwordstore.data.passfile.PasswordEntry +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.ui.editor.passContent import app.passwordstore.util.autofill.AutofillMatcher import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.autofill.AutofillResponseBuilder @@ -23,10 +28,15 @@ import com.github.androidpasswordstore.autofillparser.AutofillAction import com.github.androidpasswordstore.autofillparser.Credentials import com.github.androidpasswordstore.autofillparser.FormOrigin import dagger.hilt.android.AndroidEntryPoint -import java.io.File +import javax.inject.Inject 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() { @@ -102,60 +112,68 @@ class AutofillSaveActivity : AppCompatActivity() { } } + private val generate by unsafeLazy { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) } + + @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val repo = PasswordRepository.getRepositoryDirectory() - val saveIntent = - Intent(this, PasswordCreationActivity::class.java).apply { - putExtras( - bundleOf( - "REPO_PATH" to repo.absolutePath, - "FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath, - PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME), - PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD), - PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to - intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false), - ) + // 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, ) } - registerForActivityResult(StartActivityForResult()) { result -> - val data = result.data - if (result.resultCode == RESULT_OK && data != null) { - val createdPath = data.getStringExtra("CREATED_FILE")!! - formOrigin?.let { AutofillMatcher.addMatchFor(this, it, File(createdPath)) } - val password = data.getStringExtra("PASSWORD") - val resultIntent = - if (password != null) { - // Password was generated and should be filled into a form. - val username = data.getStringExtra("USERNAME") - val clientState = - intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) - ?: run { - logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } - finish() - return@registerForActivityResult - } - val credentials = Credentials(username, password, null) - val fillInDataset = - AutofillResponseBuilder.makeFillInDataset( - this, - credentials, - clientState, - AutofillAction.Generate, - ) - Intent().apply { - putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) - } - } else { - // Password was extracted from a form, there is nothing to fill. - Intent() + } + } + + /** 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 } - setResult(RESULT_OK, resultIntent) - } else { - setResult(RESULT_CANCELED) - } - finish() + // Read back from the saved content, as the old editor did, so a username in the extras + // counts too. + val entry = passwordEntryFactory.create(passContent(saved.form).encodeToByteArray()) + val username = + entry.username ?: AutofillPreferences.directoryStructure(this).getUsernameFor(saved.file) + val credentials = Credentials(username, entry.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() } - .launch(saveIntent) + setResult(RESULT_OK, resultIntent) + finish() } } diff --git a/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt b/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt deleted file mode 100644 index 24a28564..00000000 --- a/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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.view.View -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import app.passwordstore.R - -class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) { - - val title: TextView = itemView.findViewById(R.id.title) - val subtitle: TextView = itemView.findViewById(R.id.subtitle) -} diff --git a/app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt b/app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt new file mode 100644 index 00000000..6287408e --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt @@ -0,0 +1,125 @@ +/* + * 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.semantics.heading +import androidx.compose.ui.semantics.semantics +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, + ) + Text( + stringResource(R.string.oreo_autofill_warning_publisher_dataset_title), + modifier = center.semantics { heading() }, + 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, + ) + // 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_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 { + // The details come first, so Re-enable never sits where Advanced information was and a + // double tap cannot re-enable autofill. + SelectionContainer { + Text( + details, + modifier = center, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + ) + } + OutlinedButton(onClick = onReset, modifier = center) { + Text(stringResource(R.string.oreo_autofill_warning_publisher_reenable_button)) + } + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt deleted file mode 100644 index 2b3f4dab..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ /dev/null @@ -1,238 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import android.content.ClipData -import android.content.Intent -import android.content.SharedPreferences -import android.os.Build -import android.os.Bundle -import android.os.PersistableBundle -import android.view.WindowManager -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.annotation.CallSuper -import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity -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.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 com.google.android.material.snackbar.Snackbar -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) - } - - /** - * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing - * [showSnackbar] as false. - */ - fun 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)) - } - } - - /** - * 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() - } - } - } - - /** - * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to hide - * the default [Snackbar] and starts off an instance of [ClipboardService] to provide a way of - * clearing the clipboard. - */ - fun copyPasswordToClipboard(password: String?) { - copyTextToClipboard(password) - val clearAfter = - settings.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 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 - } - } - - 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 - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt new file mode 100644 index 00000000..fbaa534f --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt @@ -0,0 +1,149 @@ +/* + * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.crypto + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.PersistableBundle +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.fragment.app.FragmentActivity +import app.passwordstore.R +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.util.extensions.clipboard +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.extensions.snackbar +import app.passwordstore.util.services.ClipboardService +import app.passwordstore.util.settings.Constants +import app.passwordstore.util.settings.PreferenceKeys +import java.io.File + +/** Gets the relative path to the repository */ +private fun getRelativePath(fullPath: String, repositoryPath: String): String = + fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") + +/** Gets the Parent path, relative to the repository */ +fun getParentPath(fullPath: String, repositoryPath: String): String { + val relativePath = getRelativePath(fullPath, repositoryPath) + val index = relativePath.lastIndexOf("/") + return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/" + .replace("/+".toRegex(), "/") +} + +/** /path/to/store/social/facebook.gpg -> social/facebook */ +fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { + var relativePath = getRelativePath(fullPath, repositoryPath) + return if (relativePath.isNotEmpty() && relativePath != "/") { + // remove preceding '/' + relativePath = relativePath.substring(1) + if (relativePath.endsWith('/')) { + relativePath + basename + } else { + "$relativePath/$basename" + } + } else { + basename + } +} + +/** + * Copies provided [text] to the clipboard, flagged sensitive. Below Android 11 it also shows a + * snackbar, which can be disabled by passing [showSnackbar] as false. + */ +fun FragmentActivity.copyTextToClipboard( + text: String?, + showSnackbar: Boolean = true, + @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text, +) { + val clipboard = clipboard ?: return + val clip = ClipData.newPlainText("pgp_handler_result_pm", text) + clip.description.extras = + PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) } + clipboard.setPrimaryClip(clip) + if (showSnackbar && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + snackbar(message = resources.getString(snackbarTextRes)) + } +} + +/** + * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] and starts + * off an instance of [ClipboardService] to provide a way of clearing the clipboard. + */ +fun FragmentActivity.copyPasswordToClipboard(password: String?) { + copyTextToClipboard(password) + val clearAfter = + sharedPrefs.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() + ?: Constants.DEFAULT_DECRYPTION_TIMEOUT + + if (clearAfter != 0) { + val service = + Intent(this, ClipboardService::class.java).apply { + action = ClipboardService.ACTION_START + putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter) + } + startForegroundService(service) + } +} + +/** + * Get a list of available [PGPIdentifier]s for the current password repository. This method throws + * when no identifiers were able to be parsed. If this method returns null, an invalid identifier + * was encountered and reported with a toast: further execution must stop to let the user correct + * the problem. + */ +fun Context.getPGPIdentifiers(subDir: String): List<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 +} + +@Suppress("ReturnCount") +private fun File.findTillRoot(fileName: String, rootPath: File): File? { + val gpgFile = File(this, fileName) + if (gpgFile.exists()) return gpgFile + + if (this.absolutePath == rootPath.absolutePath) { + return null + } + val parent = parentFile + return if (parent != null && parent.exists()) { + parent.findTillRoot(fileName, rootPath) + } else { + null + } +} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt deleted file mode 100644 index 8b2109bf..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.core.content.edit -import androidx.fragment.app.setFragmentResultListener -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.password.FieldItem -import app.passwordstore.databinding.DecryptLayoutBinding -import app.passwordstore.ui.adapters.FieldItemAdapter -import app.passwordstore.util.auth.BiometricAuthenticator -import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.extensions.unsafeLazy -import app.passwordstore.util.extensions.viewBinding -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.AndroidEntryPoint -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.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -@AndroidEntryPoint -class DecryptActivity : BasePGPActivity() { - - @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory - @Inject lateinit var passphraseCache: PGPPassphraseCache - @Inject lateinit var features: Features - - private val binding by viewBinding(DecryptLayoutBinding::inflate) - private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) } - private var passwordEntry: PasswordEntry? = null - private var retries = 0 - private var clearCache = true - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - title = name - with(binding) { - setContentView(root) - passwordCategory.text = relativeParentPath - passwordFile.text = name - passwordFile.setOnLongClickListener { - copyTextToClipboard(name) - true - } - } - if ( - features.isEnabled(EnablePGPPassphraseCache) && - BiometricAuthenticator.canAuthenticate(this@DecryptActivity) - ) { - BiometricAuthenticator.authenticate( - this@DecryptActivity, - R.string.biometric_prompt_title_gpg_passphrase_cache, - ) { authResult -> - requireKeysExist { decrypt(isError = false, authResult) } - } - } else { - requireKeysExist { decrypt(isError = false, BiometricResult.CanceledByUser) } - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.pgp_handler, menu) - passwordEntry?.let { entry -> - menu.findItem(R.id.edit_password).isVisible = true - if (!entry.password.isNullOrBlank()) { - menu.findItem(R.id.share_password_as_plaintext).isVisible = true - menu.findItem(R.id.copy_password).isVisible = true - } - } - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> onBackPressedDispatcher.onBackPressed() - R.id.edit_password -> editPassword() - R.id.share_password_as_plaintext -> shareAsPlaintext() - R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password) - else -> return super.onOptionsItemSelected(item) - } - return true - } - - /** - * Automatically finishes the activity after [PreferenceKeys.GENERAL_SHOW_TIME] seconds decryption - * succeeded to prevent information leaks from stale activities. - */ - private fun startAutoDismissTimer() { - lifecycleScope.launch { - val timeout = - settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() - ?: Constants.DEFAULT_DECRYPTION_TIMEOUT - if (timeout != 0) { - delay(timeout.seconds) - finish() - } - } - } - - /** - * Edit the current password and hide all the fields populated by encrypted data so that when the - * result triggers they can be repopulated with new data. - */ - private fun editPassword() { - val intent = Intent(this, PasswordCreationActivity::class.java) - intent.action = Intent.ACTION_VIEW - intent.putExtra("FILE_PATH", relativeParentPath) - intent.putExtra("REPO_PATH", repoPath) - intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name) - intent.putExtra(PasswordCreationActivity.EXTRA_USERNAME, passwordEntry?.username) - intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password) - intent.putExtra( - PasswordCreationActivity.EXTRA_EXTRA_CONTENT, - passwordEntry?.extraContentWithoutUsername, - ) - intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true) - startActivity(intent) - finish() - } - - private fun shareAsPlaintext() { - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, passwordEntry?.password) - type = "text/plain" - } - // Always show a picker to give the user a chance to cancel - startActivity( - Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)) - ) - } - - private fun decrypt(isError: Boolean, authResult: BiometricResult) { - val gpgIdentifiers = getPGPIdentifiers(relativeParentPath) ?: 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.CanceledByUser, - is BiometricResult.CanceledBySystem, - is BiometricResult.Failure, - is BiometricResult.HardwareUnavailableOrDisabled -> - askPassphrase(isError, gpgIdentifiers, authResult) - 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@DecryptActivity) - screenWasOff = false - } - val cachedPassphrase = - passphraseCache.retrieveCachedPassphrase(this@DecryptActivity, gpgIdentifiers.first()) - if (cachedPassphrase != null) { - decryptWithPassphrase(cachedPassphrase, gpgIdentifiers, authResult) - } else { - askPassphrase(isError, gpgIdentifiers, authResult) - } - } - } - } - } - - private suspend fun askPassphrase( - isError: Boolean, - gpgIdentifiers: List<PGPIdentifier>, - authResult: BiometricResult, - ) { - if (retries < MAX_RETRIES) { - retries += 1 - } else { - finish() - } - if (!repository.isPasswordProtected(gpgIdentifiers)) { - decryptWithPassphrase(passphrase = "", gpgIdentifiers, authResult) - return - } - val dialog = - PasswordDialog.newInstance( - cacheEnabled = features.isEnabled(EnablePGPPassphraseCache), - clearCache = clearCache, - ) - if (isError) { - dialog.setError() - } - dialog.show(supportFragmentManager, "PASSWORD_DIALOG") - dialog.setFragmentResultListener(PasswordDialog.PASSWORD_RESULT_KEY) { key, bundle -> - if (key == PasswordDialog.PASSWORD_RESULT_KEY) { - val passphrase = bundle.getString(PasswordDialog.PASSWORD_PHRASE_KEY)!! - clearCache = bundle.getBoolean(PasswordDialog.PASSWORD_CLEAR_KEY) - lifecycleScope.launch(dispatcherProvider.main()) { - decryptWithPassphrase(passphrase, gpgIdentifiers, authResult) { - runCatching { - if (authResult is BiometricResult.Success) { - passphraseCache.cachePassphrase( - this@DecryptActivity, - gpgIdentifiers.first(), - passphrase, - ) - settings.edit { putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, clearCache) } - } - } - .onFailure { e -> logcat { e.asLog() } } - } - } - } - } - } - - private suspend fun decryptWithPassphrase( - passphrase: String, - identifiers: List<PGPIdentifier>, - authResult: BiometricResult, - onSuccess: suspend () -> Unit = {}, - ) { - val message = withContext(dispatcherProvider.io()) { File(fullPath).readBytes().inputStream() } - val outputStream = ByteArrayOutputStream() - val result = repository.decrypt(passphrase, identifiers, message, outputStream) - if (result.isOk) { - val entry = passwordEntryFactory.create(result.value.toByteArray()) - passwordEntry = entry - createPasswordUI(entry) - startAutoDismissTimer() - onSuccess() - } else { - logcat(ERROR) { result.error.stackTraceToString() } - decrypt(isError = true, authResult = authResult) - } - } - - private suspend fun createPasswordUI(entry: PasswordEntry) = - withContext(dispatcherProvider.main()) { - val labelFormat = resources.getString(R.string.otp_label_format) - val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) - invalidateOptionsMenu() - - val items = arrayListOf<FieldItem>() - if (!entry.password.isNullOrBlank()) { - items.add(FieldItem.createPasswordField(getString(R.string.password), entry.password!!)) - if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) { - copyPasswordToClipboard(entry.password) - } - } - - if (entry.hasTotp()) { - items.add(FieldItem.createOtpField(labelFormat, entry.totp.first())) - } - - if (!entry.username.isNullOrBlank()) { - items.add(FieldItem.createUsernameField(getString(R.string.username), entry.username!!)) - } - - entry.extraContent.forEach { (key, value) -> - items.add(FieldItem.createFreeformField(key, value)) - } - - val adapter = FieldItemAdapter(items, showPassword) { text -> copyTextToClipboard(text) } - binding.recyclerView.adapter = adapter - binding.recyclerView.itemAnimator = null - - if (entry.hasTotp()) { - lifecycleScope.launch { entry.totp.collect { adapter.updateOTPCode(it, labelFormat) } } - } - } - - private companion object { - private const val MAX_RETRIES = 3 - } -} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt b/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt deleted file mode 100644 index d8610307..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import 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.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import app.passwordstore.R -import app.passwordstore.data.passfile.PasswordEntry -import app.passwordstore.ui.APSAppBar -import app.passwordstore.ui.compose.PasswordField -import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.ui.compose.theme.SpacingLarge -import app.passwordstore.ui.compose.theme.SpacingMedium -import app.passwordstore.util.time.UserClock -import app.passwordstore.util.totp.UriTotpFinder - -/** Composable to show allow editing an existing [PasswordEntry]. */ -@Composable -fun EditPasswordScreen( - entryName: String, - entry: PasswordEntry, - onNavigateUp: () -> Unit, - @Suppress("UNUSED_PARAMETER") onSave: (PasswordEntry) -> Unit, - modifier: Modifier = Modifier, -) { - Scaffold( - topBar = { - APSAppBar( - title = entryName, - navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp), - onNavigationIconClick = onNavigateUp, - backgroundColor = MaterialTheme.colorScheme.surface, - ) - } - ) { paddingValues -> - Box(modifier = modifier.padding(paddingValues)) { - Column( - modifier = - Modifier.padding(vertical = SpacingMedium, horizontal = SpacingLarge).fillMaxSize() - ) { - if (entry.password != null) { - PasswordField( - value = entry.password!!, - label = stringResource(R.string.password), - initialVisibility = false, - modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(), - ) - } - ExtraContent(entry = entry) - } - } - } -} - -@Composable -private fun ExtraContent(entry: PasswordEntry, modifier: Modifier = Modifier) { - TextField( - value = entry.extraContentString, - onValueChange = {}, - label = { Text("Extra content") }, - modifier = modifier.fillMaxWidth(), - ) -} - -@Preview -@Composable -private fun EditPasswordScreenPreview() { - APSTheme { - EditPasswordScreen( - entryName = "Test Entry", - entry = createTestEntry(), - onNavigateUp = {}, - onSave = {}, - ) - } -} - -private fun createTestEntry() = - PasswordEntry( - UserClock(), - UriTotpFinder(), - """ - |My Password - |otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30 - |login: msfjarvis - |URL: example.com - """ - .trimMargin() - .encodeToByteArray(), - ) diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt deleted file mode 100644 index 7652c98e..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt +++ /dev/null @@ -1,510 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.graphics.ImageDecoder -import android.os.Build -import android.os.Bundle -import android.provider.MediaStore -import android.text.InputType -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.core.content.edit -import androidx.core.view.isVisible -import androidx.core.widget.doAfterTextChanged -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.passfile.PasswordEntry -import app.passwordstore.databinding.PasswordCreationActivityBinding -import app.passwordstore.ui.dialogs.DicewarePasswordGeneratorDialogFragment -import app.passwordstore.ui.dialogs.OtpImportDialogFragment -import app.passwordstore.ui.dialogs.PasswordGeneratorDialogFragment -import app.passwordstore.util.autofill.AutofillPreferences -import app.passwordstore.util.extensions.asLog -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.snackbar -import app.passwordstore.util.extensions.unsafeLazy -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.DirectoryStructure -import app.passwordstore.util.settings.PreferenceKeys -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 com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.zxing.BinaryBitmap -import com.google.zxing.LuminanceSource -import com.google.zxing.RGBLuminanceSource -import com.google.zxing.common.HybridBinarizer -import com.google.zxing.integration.android.IntentIntegrator -import com.google.zxing.integration.android.IntentIntegrator.QR_CODE -import com.google.zxing.qrcode.QRCodeReader -import dagger.hilt.android.AndroidEntryPoint -import java.io.ByteArrayOutputStream -import java.io.IOException -import java.nio.file.Paths -import javax.inject.Inject -import kotlin.io.path.absolutePathString -import kotlin.io.path.createDirectories -import kotlin.io.path.deleteIfExists -import kotlin.io.path.exists -import kotlin.io.path.isSameFileAs -import kotlin.io.path.nameWithoutExtension -import kotlin.io.path.pathString -import kotlin.io.path.relativeTo -import kotlin.io.path.writeBytes -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -@AndroidEntryPoint -class PasswordCreationActivity : BasePGPActivity() { - - private val binding by viewBinding(PasswordCreationActivityBinding::inflate) - @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory - - private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } - private val suggestedUsername by unsafeLazy { intent.getStringExtra(EXTRA_USERNAME) } - private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) } - private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } - private val shouldGeneratePassword by unsafeLazy { - intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) - } - private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) } - private var oldCategory: String? = null - private var copy: Boolean = false - - private val otpImportAction = - registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - binding.otpImportButton.isVisible = false - val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data) - val contents = "${intentResult.contents}\n" - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$contents") - else binding.extraContent.append(contents) - snackbar(message = getString(R.string.otp_import_success)) - } else { - snackbar(message = getString(R.string.otp_import_failure_generic)) - } - } - - private val imageImportAction = - registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri -> - if (imageUri == null) { - snackbar(message = getString(R.string.otp_import_failure_no_selection)) - return@registerForActivityResult - } - val bitmap = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, imageUri)) - .copy(Bitmap.Config.ARGB_8888, true) - } else { - @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri) - } - val intArray = IntArray(bitmap.width * bitmap.height) - // copy pixel data from the Bitmap into the 'intArray' array - bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) - val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray) - val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) - - val reader = QRCodeReader() - runCatching { - val result = reader.decode(binaryBitmap) - val text = result.text - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$text") - else binding.extraContent.append(text) - snackbar(message = getString(R.string.otp_import_success)) - binding.otpImportButton.isVisible = false - } - .onFailure { snackbar(message = getString(R.string.otp_import_failure_generic)) } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - title = - if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title) - with(binding) { - setContentView(root) - generatePassword.setOnClickListener { generatePassword() } - otpImportButton.setOnClickListener { - supportFragmentManager.setFragmentResultListener( - OTP_RESULT_REQUEST_KEY, - this@PasswordCreationActivity, - ) { requestKey, bundle -> - if (requestKey == OTP_RESULT_REQUEST_KEY) { - val contents = bundle.getString(RESULT) - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$contents") - else binding.extraContent.append(contents) - } - } - val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true - if (hasCamera) { - val items = - arrayOf( - getString(R.string.otp_import_qr_code), - getString(R.string.otp_import_from_file), - getString(R.string.otp_import_manual_entry), - ) - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setItems(items) { _, index -> - when (index) { - 0 -> - otpImportAction.launch( - IntentIntegrator(this@PasswordCreationActivity) - .setOrientationLocked(false) - .setBeepEnabled(false) - .setDesiredBarcodeFormats(QR_CODE) - .createScanIntent() - ) - 1 -> imageImportAction.launch("image/*") - 2 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") - } - } - .show() - } else { - OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") - } - } - - directoryInputLayout.apply { - if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) { - isEnabled = true - } else { - setBackgroundColor(getColor(android.R.color.transparent)) - } - val path = getRelativePath(fullPath, repoPath) - // Keep empty path field visible if it is editable. - if (path.isEmpty() && !isEnabled) visibility = View.GONE - else { - directory.setText(path) - oldCategory = path - } - } - if (suggestedName != null) { - filename.setText(suggestedName) - } else { - filename.requestFocus() - } - - if ( - AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == - DirectoryStructure.EncryptedUsername || suggestedUsername != null - ) { - usernameInputLayout.visibility = View.VISIBLE - if (suggestedUsername != null) username.setText(suggestedUsername) - else if (suggestedName != null) username.requestFocus() - } - - // Allow the user to quickly switch between storing the username as the filename or - // in the encrypted extras. This only makes sense if the directory structure is - // FileBased. - if ( - suggestedName == null && - AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == - DirectoryStructure.FileBased - ) { - encryptUsername.apply { - visibility = View.VISIBLE - setOnClickListener { - if (isChecked) { - // User wants to enable username encryption, so we use the filename - // as username and insert it into the username input field. - val login = filename.text.toString() - filename.text?.clear() - username.setText(login) - usernameInputLayout.apply { visibility = View.VISIBLE } - } else { - // User wants to disable username encryption, so we take the username - // from the username text field and insert it into the filename input field. - val login = username.text.toString() - username.text?.clear() - filename.setText(login) - usernameInputLayout.apply { visibility = View.GONE } - } - } - } - } - suggestedPass?.let { - password.setText(it) - password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - suggestedExtra?.let { extraContent.setText(it) } - if (shouldGeneratePassword) { - generatePassword() - password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - } - listOf(binding.filename, binding.username, binding.extraContent).forEach { - it.doAfterTextChanged { updateViewState() } - } - updateViewState() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.pgp_handler_new_password, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - setResult(RESULT_CANCELED) - onBackPressedDispatcher.onBackPressed() - } - R.id.save_password -> { - copy = false - requireKeysExist { encrypt() } - } - R.id.save_and_copy_password -> { - copy = true - requireKeysExist { encrypt() } - } - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun generatePassword() { - supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { - requestKey, - bundle -> - if (requestKey == PASSWORD_RESULT_REQUEST_KEY) { - binding.password.setText(bundle.getString(RESULT)) - } - } - when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_DICEWARE) { - KEY_PWGEN_TYPE_CLASSIC -> - PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator") - KEY_PWGEN_TYPE_DICEWARE -> - DicewarePasswordGeneratorDialogFragment().show(supportFragmentManager, "generator") - } - } - - private fun updateViewState() = - with(binding) { - encryptUsername.apply { - if (visibility != View.VISIBLE) return@apply - val hasUsernameInFileName = filename.text.toString().isNotBlank() - val usernameIsEncrypted = username.text.toString().isNotEmpty() - isEnabled = hasUsernameInFileName xor usernameIsEncrypted - isChecked = usernameIsEncrypted - } - // Use PasswordEntry to parse extras for OTP - val entry = - passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".encodeToByteArray()) - otpImportButton.isVisible = !entry.hasTotp() - } - - /** Encrypts the password and the extra content */ - private fun encrypt() { - with(binding) { - val oldName = suggestedName - val editName = filename.text.toString().trim() - var editUsername = username.text.toString() - val editPass = password.text.toString() - val editExtra = extraContent.text.toString() - - if (editName.isEmpty()) { - snackbar(message = resources.getString(R.string.file_toast_text)) - return@with - } else if (editName.contains('/')) { - snackbar(message = resources.getString(R.string.invalid_filename_text)) - return@with - } - - if (!editUsername.isEmpty()) { - editUsername = "\nusername: $editUsername" - } - - if (editPass.isEmpty() && editExtra.isEmpty()) { - snackbar(message = resources.getString(R.string.empty_toast_text)) - return@with - } - - if (copy) { - copyPasswordToClipboard(editPass) - } - - // pass enters the key ID into `.gpg-id`. - val gpgIdentifiers = getPGPIdentifiers(directory.text.toString()) ?: return@with - val content = "$editPass$editUsername\n$editExtra" - val path = - when { - // If we allowed the user to edit the relative path, we have to consider it here - // instead of fullPath. - directoryInputLayout.isEnabled -> { - val editRelativePath = directory.text.toString().trim() - if (editRelativePath.isEmpty()) { - snackbar(message = resources.getString(R.string.path_toast_text)) - return - } - val passwordDirectory = Paths.get(repoPath, editRelativePath.trim('/')) - passwordDirectory.createDirectories() - if (!passwordDirectory.exists()) { - snackbar( - message = - "Failed to create directory ${passwordDirectory.relativeTo(Paths.get(repoPath)).pathString}" - ) - return - } - - "${passwordDirectory.pathString}/$editName.gpg" - } - else -> "$fullPath/$editName.gpg" - } - - lifecycleScope.launch(dispatcherProvider.main()) { - runCatching { - val result = - withContext(dispatcherProvider.io()) { - val outputStream = ByteArrayOutputStream() - repository.encrypt(gpgIdentifiers, content.byteInputStream(), outputStream) - } - .getOrElse { e -> - logcat(ERROR) { e.asLog("Failed to encrypt password file") } - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setTitle(R.string.password_creation_file_fail_title) - .setMessage( - getString(R.string.password_creation_encrypt_fail_message, e.message) - ) - .setPositiveButton(android.R.string.ok, null) - .show() - return@runCatching - } - val passwordFile = Paths.get(path) - // If we're not editing, this file should not already exist! - // Additionally, if we were editing and the incoming and outgoing - // filenames differ, it means we renamed. Ensure that the target - // doesn't already exist to prevent an accidental overwrite. - if ( - (!editing || (editing && suggestedName != passwordFile.nameWithoutExtension)) && - passwordFile.exists() - ) { - snackbar(message = getString(R.string.password_creation_duplicate_error)) - return@runCatching - } - - if (!passwordFile.toFile().isInsideRepository()) { - snackbar(message = getString(R.string.message_error_destination_outside_repo)) - return@runCatching - } - - withContext(dispatcherProvider.io()) { passwordFile.writeBytes(result.toByteArray()) } - - // associate the new password name with the last name's timestamp in history - val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) - val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$suggestedName.gpg".base64() - val timestamp = preference.getString(oldFilePathHash) - if (timestamp != null) { - preference.edit { - remove(oldFilePathHash) - putString(passwordFile.absolutePathString().base64(), timestamp) - } - } - - val returnIntent = Intent() - returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path) - returnIntent.putExtra(RETURN_EXTRA_NAME, editName) - returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName)) - - if (shouldGeneratePassword) { - val directoryStructure = AutofillPreferences.directoryStructure(applicationContext) - val entry = passwordEntryFactory.create(content.encodeToByteArray()) - returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) - val username = - entry.username ?: directoryStructure.getUsernameFor(passwordFile.toFile()) - returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) - } - - if ( - directoryInputLayout.isVisible && - directoryInputLayout.isEnabled && - oldName != editName - ) { - val oldPath = Paths.get(repoPath, oldCategory?.trim('/') ?: "", "$oldName.gpg") - if (!oldPath.isSameFileAs(passwordFile) && !oldPath.deleteIfExists()) { - setResult(RESULT_CANCELED) - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setTitle(R.string.password_creation_file_fail_title) - .setMessage( - getString(R.string.password_creation_file_delete_fail_message, oldName) - ) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .show() - return@runCatching - } - } - - val commitMessageRes = - if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text - lifecycleScope.launch { - commitChange( - resources.getString(commitMessageRes, getLongName(fullPath, repoPath, editName)) - ) - .onSuccess { - setResult(RESULT_OK, returnIntent) - finish() - } - } - } - .onFailure { e -> - if (e is IOException) { - logcat(ERROR) { e.asLog("Failed to write password file") } - setResult(RESULT_CANCELED) - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setTitle(getString(R.string.password_creation_file_fail_title)) - .setMessage(getString(R.string.password_creation_file_write_fail_message)) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .show() - } else { - logcat(ERROR) { e.asLog() } - } - } - } - } - } - - companion object { - - private const val KEY_PWGEN_TYPE_CLASSIC = "classic" - private const val KEY_PWGEN_TYPE_DICEWARE = "diceware" - const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR" - const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT" - const val RESULT = "RESULT" - const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE" - const val RETURN_EXTRA_NAME = "NAME" - const val RETURN_EXTRA_LONG_NAME = "LONG_NAME" - const val RETURN_EXTRA_USERNAME = "USERNAME" - const val RETURN_EXTRA_PASSWORD = "PASSWORD" - const val EXTRA_FILE_NAME = "FILENAME" - const val EXTRA_USERNAME = "USERNAME" - const val EXTRA_PASSWORD = "PASSWORD" - const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT" - const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD" - const val EXTRA_EDITING = "EDITING" - } -} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt deleted file mode 100644 index d845d689..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import android.view.KeyEvent -import android.view.WindowManager -import androidx.core.os.bundleOf -import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import app.passwordstore.R -import app.passwordstore.databinding.DialogPasswordEntryBinding -import app.passwordstore.util.extensions.finish -import app.passwordstore.util.extensions.unsafeLazy -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -/** [DialogFragment] to request a password from the user and forward it along. */ -class PasswordDialog : DialogFragment() { - - private val binding by unsafeLazy { DialogPasswordEntryBinding.inflate(layoutInflater) } - private var isError: Boolean = false - private var clearCacheChecked: Boolean = true - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - builder.setView(binding.root) - builder.setTitle(R.string.password) - - val cacheEnabled = requireArguments().getBoolean(CACHE_ENABLED_EXTRA, false) - binding.autoClearCache.isVisible = cacheEnabled - - if (cacheEnabled) { - clearCacheChecked = requireArguments().getBoolean(AUTO_CLEAR_CACHE_EXTRA) - binding.autoClearCache.isChecked = clearCacheChecked - binding.autoClearCache.setOnCheckedChangeListener { _, isChecked -> - clearCacheChecked = isChecked - } - } - - builder.setPositiveButton(android.R.string.ok) { _, _ -> setPasswordAndDismiss() } - val dialog = builder.create() - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - dialog.setOnShowListener { - if (isError) { - binding.passwordField.error = getString(R.string.git_operation_wrong_password) - } - binding.passwordEditText.doOnTextChanged { _, _, _, _ -> binding.passwordField.error = null } - binding.passwordEditText.setOnKeyListener { _, keyCode, _ -> - if (keyCode == KeyEvent.KEYCODE_ENTER) { - setPasswordAndDismiss() - return@setOnKeyListener true - } - false - } - } - dialog.window?.setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE, - ) - return dialog - } - - fun setError() { - isError = true - } - - override fun onCancel(dialog: DialogInterface) { - super.onCancel(dialog) - finish() - } - - private fun setPasswordAndDismiss() { - val password = binding.passwordEditText.text.toString() - setFragmentResult( - PASSWORD_RESULT_KEY, - bundleOf(PASSWORD_PHRASE_KEY to password, PASSWORD_CLEAR_KEY to clearCacheChecked), - ) - dismissAllowingStateLoss() - } - - companion object { - - private const val CACHE_ENABLED_EXTRA = "CACHE_ENABLED" - private const val AUTO_CLEAR_CACHE_EXTRA = "AUTO_CLEAR_CACHE" - - const val PASSWORD_RESULT_KEY = "password_result" - const val PASSWORD_PHRASE_KEY = "password_phrase" - const val PASSWORD_CLEAR_KEY = "password_clear" - - fun newInstance(cacheEnabled: Boolean, clearCache: Boolean): PasswordDialog { - val extras = - bundleOf(CACHE_ENABLED_EXTRA to cacheEnabled, AUTO_CLEAR_CACHE_EXTRA to clearCache) - val fragment = PasswordDialog() - fragment.arguments = extras - return fragment - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt b/app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt deleted file mode 100644 index f2a67706..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import 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.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.capitalize -import androidx.compose.ui.text.intl.Locale -import androidx.compose.ui.tooling.preview.Preview -import app.passwordstore.R -import app.passwordstore.data.passfile.PasswordEntry -import app.passwordstore.ui.APSAppBar -import app.passwordstore.ui.compose.CopyButton -import app.passwordstore.ui.compose.PasswordField -import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.ui.compose.theme.SpacingLarge -import app.passwordstore.ui.compose.theme.SpacingMedium -import app.passwordstore.util.time.UserClock -import app.passwordstore.util.totp.UriTotpFinder -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking - -/** Composable to show a decrypted [PasswordEntry]. */ -@Composable -fun ViewPasswordScreen( - entryName: String, - entry: PasswordEntry, - onNavigateUp: () -> Unit, - modifier: Modifier = Modifier, -) { - Scaffold( - topBar = { - APSAppBar( - title = entryName, - navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp), - onNavigationIconClick = onNavigateUp, - backgroundColor = MaterialTheme.colorScheme.surface, - ) - } - ) { paddingValues -> - Box(modifier = modifier.padding(paddingValues)) { - Column( - modifier = - Modifier.padding(vertical = SpacingMedium, horizontal = SpacingLarge).fillMaxSize() - ) { - if (entry.password != null) { - PasswordField( - value = entry.password!!, - label = stringResource(R.string.password), - initialVisibility = false, - readOnly = true, - modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(), - ) - } - if (entry.hasTotp()) { - val totp by entry.totp.collectAsState(runBlocking { entry.totp.first() }) - TextField( - value = totp.value, - onValueChange = {}, - readOnly = true, - label = { Text("OTP (expires in ${totp.remainingTime.inWholeSeconds}s)") }, - trailingIcon = { CopyButton(totp.value, R.string.copy_label) }, - modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(), - ) - } - if (entry.username != null) { - TextField( - value = entry.username!!, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.username)) }, - trailingIcon = { CopyButton(entry.username!!, R.string.copy_label) }, - modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(), - ) - } - ExtraContent(entry = entry) - } - } - } -} - -@Composable -private fun ExtraContent(entry: PasswordEntry, modifier: Modifier = Modifier) { - entry.extraContent.forEach { (label, value) -> - TextField( - value = value, - onValueChange = {}, - readOnly = true, - label = { Text(label.capitalize(Locale.current)) }, - trailingIcon = { CopyButton(value, R.string.copy_label) }, - modifier = modifier.padding(bottom = SpacingMedium).fillMaxWidth(), - ) - } -} - -@Preview -@Composable -private fun ViewPasswordScreenPreview() { - APSTheme { - ViewPasswordScreen(entryName = "Test Entry", entry = createTestEntry(), onNavigateUp = {}) - } -} - -private fun createTestEntry() = - PasswordEntry( - UserClock(), - UriTotpFinder(), - """ - |My Password - |otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30 - |login: msfjarvis - |URL: example.com - """ - .trimMargin() - .encodeToByteArray(), - ) diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt b/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt deleted file mode 100644 index e689f3bd..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.dialogs - -import android.content.Context -import android.content.DialogInterface.OnDismissListener -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewTreeObserver -import android.widget.FrameLayout -import androidx.annotation.StringRes -import androidx.core.view.isVisible -import app.passwordstore.R -import app.passwordstore.databinding.BasicBottomSheetBinding -import app.passwordstore.util.extensions.viewBinding -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment - -/** - * [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like API - * through [BasicBottomSheet.Builder] to create a similar UI, just at the bottom of the screen. - */ -class BasicBottomSheet -private constructor( - val title: String?, - val message: String, - val positiveButtonLabel: String?, - val negativeButtonLabel: String?, - val positiveButtonClickListener: View.OnClickListener?, - val negativeButtonClickListener: View.OnClickListener?, - val onDismissListener: OnDismissListener?, -) : BottomSheetDialogFragment() { - - private val binding by viewBinding(BasicBottomSheetBinding::bind) - - private var behavior: BottomSheetBehavior<FrameLayout>? = null - private val bottomSheetCallback = - object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, slideOffset: Float) {} - - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_COLLAPSED) { - dismiss() - } - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - if (savedInstanceState != null) dismiss() - return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - view.viewTreeObserver.addOnGlobalLayoutListener( - object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - view.viewTreeObserver.removeOnGlobalLayoutListener(this) - val dialog = dialog as BottomSheetDialog? ?: return - behavior = dialog.behavior - behavior?.apply { - state = BottomSheetBehavior.STATE_EXPANDED - peekHeight = 0 - addBottomSheetCallback(bottomSheetCallback) - } - if (!title.isNullOrEmpty()) { - binding.bottomSheetTitle.isVisible = true - binding.bottomSheetTitle.text = title - } - binding.bottomSheetMessage.text = message - if (positiveButtonClickListener != null) { - positiveButtonLabel?.let { buttonLbl -> binding.bottomSheetOkButton.text = buttonLbl } - binding.bottomSheetOkButton.isVisible = true - binding.bottomSheetOkButton.setOnClickListener { - positiveButtonClickListener.onClick(it) - dismiss() - } - } - if (negativeButtonClickListener != null) { - binding.bottomSheetCancelButton.isVisible = true - negativeButtonLabel?.let { buttonLbl -> - binding.bottomSheetCancelButton.text = buttonLbl - } - binding.bottomSheetCancelButton.setOnClickListener { - negativeButtonClickListener.onClick(it) - dismiss() - } - } - if (onDismissListener != null) { - dialog.setOnDismissListener(onDismissListener) - } - } - } - ) - } - - override fun dismiss() { - super.dismiss() - behavior?.removeBottomSheetCallback(bottomSheetCallback) - } - - class Builder(val context: Context) { - - private var title: String? = null - private var message: String? = null - private var positiveButtonLabel: String? = null - private var negativeButtonLabel: String? = null - private var positiveButtonClickListener: View.OnClickListener? = null - private var negativeButtonClickListener: View.OnClickListener? = null - private var onDismissListener: OnDismissListener? = null - - fun setTitleRes(@StringRes titleRes: Int): Builder { - this.title = context.resources.getString(titleRes) - return this - } - - fun setTitle(title: String): Builder { - this.title = title - return this - } - - fun setMessageRes(@StringRes messageRes: Int): Builder { - this.message = context.resources.getString(messageRes) - return this - } - - fun setMessage(message: String): Builder { - this.message = message - return this - } - - fun setPositiveButtonClickListener( - buttonLabel: String? = null, - listener: View.OnClickListener, - ): Builder { - this.positiveButtonClickListener = listener - this.positiveButtonLabel = buttonLabel - return this - } - - fun setNegativeButtonClickListener( - buttonLabel: String? = null, - listener: View.OnClickListener, - ): Builder { - this.negativeButtonClickListener = listener - this.negativeButtonLabel = buttonLabel - return this - } - - fun setOnDismissListener(onDismissListener: OnDismissListener): Builder { - this.onDismissListener = onDismissListener - return this - } - - fun build(): BasicBottomSheet { - require(message != null) { "Message needs to be set" } - return BasicBottomSheet( - title, - message!!, - positiveButtonLabel, - negativeButtonLabel, - positiveButtonClickListener, - negativeButtonClickListener, - onDismissListener, - ) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt deleted file mode 100644 index 2605e0ef..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.dialogs - -import android.app.AlertDialog -import android.app.Dialog -import android.content.SharedPreferences -import android.graphics.Typeface -import android.os.Bundle -import androidx.core.content.edit -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.databinding.FragmentPwgenDicewareBinding -import app.passwordstore.injection.prefs.PasswordGeneratorPreferences -import app.passwordstore.passgen.diceware.DicewarePassphraseGenerator -import app.passwordstore.ui.crypto.PasswordCreationActivity -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.settings.PreferenceKeys.DICEWARE_LENGTH -import app.passwordstore.util.settings.PreferenceKeys.DICEWARE_SEPARATOR -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.launch -import reactivecircus.flowbinding.android.widget.afterTextChanges - -@AndroidEntryPoint -class DicewarePasswordGeneratorDialogFragment : DialogFragment() { - - @Inject lateinit var dicewareGenerator: DicewarePassphraseGenerator - @Inject @PasswordGeneratorPreferences lateinit var prefs: SharedPreferences - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - - val binding = FragmentPwgenDicewareBinding.inflate(layoutInflater) - builder.setView(binding.root) - - binding.passwordSeparatorText.setText(prefs.getString(DICEWARE_SEPARATOR) ?: "-") - binding.passwordLengthText.setText(prefs.getInt(DICEWARE_LENGTH, 5).toString()) - binding.passwordText.typeface = Typeface.MONOSPACE - - lifecycleScope.launch { - merge( - binding.passwordLengthText.afterTextChanges(), - binding.passwordSeparatorText.afterTextChanges(), - ) - .collect { _ -> generatePassword(binding) } - } - return builder - .run { - setTitle(R.string.pwgen_title) - setPositiveButton(R.string.dialog_ok) { _, _ -> - setFragmentResult( - PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY, - bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}"), - ) - } - setNeutralButton(R.string.dialog_cancel) { _, _ -> } - setNegativeButton(R.string.pwgen_generate, null) - create() - } - .apply { - setOnShowListener { - generatePassword(binding) - getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { generatePassword(binding) } - } - } - } - - private fun generatePassword(binding: FragmentPwgenDicewareBinding) { - val length = binding.passwordLengthText.text?.toString()?.toIntOrNull() ?: 5 - val separator = binding.passwordSeparatorText.text?.toString()?.getOrNull(0) ?: '-' - setPreferences(length, separator) - binding.passwordText.text = dicewareGenerator.generatePassphrase(length, separator) - } - - private fun setPreferences(length: Int, separator: Char) { - prefs.edit { - putInt(DICEWARE_LENGTH, length) - putString(DICEWARE_SEPARATOR, separator.toString()) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt deleted file mode 100644 index 669e126e..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.dialogs - -import android.app.Dialog -import android.os.Bundle -import android.view.WindowManager -import androidx.appcompat.app.AlertDialog -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import app.passwordstore.R -import app.passwordstore.ui.passwords.PasswordStore -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout -import java.io.File - -class FolderCreationDialogFragment : DialogFragment() { - - private lateinit var newFolder: File - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext()) - alertDialogBuilder.setTitle(R.string.title_create_folder) - alertDialogBuilder.setView(R.layout.folder_dialog_fragment) - alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null) - alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> dismiss() } - val dialog = alertDialogBuilder.create() - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!) - } - } - return dialog - } - - private fun createDirectory(currentDir: String) { - val dialog = requireDialog() - val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text) - val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container) - newFolder = File("$currentDir/${folderNameView.text}") - folderNameViewContainer.error = - when { - newFolder.isFile -> getString(R.string.folder_creation_err_file_exists) - newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists) - else -> null - } - if (folderNameViewContainer.error != null) return - newFolder.mkdirs() - (requireActivity() as PasswordStore).refreshPasswordList(newFolder) - // TODO(msfjarvis): Restore this functionality - /* - if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) { - keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) - return - } else { - dismiss() - } - */ - dismiss() - } - - companion object { - - private const val CURRENT_DIR_EXTRA = "CURRENT_DIRECTORY" - - fun newInstance(startingDirectory: String): FolderCreationDialogFragment { - val extras = bundleOf(CURRENT_DIR_EXTRA to startingDirectory) - val fragment = FolderCreationDialogFragment() - fragment.arguments = extras - return fragment - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt b/app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt deleted file mode 100644 index 0304b16c..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.dialogs - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewTreeObserver -import android.widget.FrameLayout -import androidx.core.os.bundleOf -import androidx.fragment.app.setFragmentResult -import app.passwordstore.R -import app.passwordstore.ui.passwords.PasswordFragment.Companion.ACTION_FOLDER -import app.passwordstore.ui.passwords.PasswordFragment.Companion.ACTION_KEY -import app.passwordstore.ui.passwords.PasswordFragment.Companion.ACTION_PASSWORD -import app.passwordstore.ui.passwords.PasswordFragment.Companion.ITEM_CREATION_REQUEST_KEY -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment - -class ItemCreationBottomSheet : BottomSheetDialogFragment() { - - private var behavior: BottomSheetBehavior<FrameLayout>? = null - private val bottomSheetCallback = - object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, slideOffset: Float) {} - - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_COLLAPSED) { - dismiss() - } - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - if (savedInstanceState != null) dismiss() - return inflater.inflate(R.layout.item_create_sheet, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - view.viewTreeObserver.addOnGlobalLayoutListener( - object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - view.viewTreeObserver.removeOnGlobalLayoutListener(this) - val dialog = dialog as BottomSheetDialog? ?: return - behavior = dialog.behavior - behavior?.apply { - state = BottomSheetBehavior.STATE_EXPANDED - peekHeight = 0 - addBottomSheetCallback(bottomSheetCallback) - } - dialog.findViewById<View>(R.id.create_folder)?.setOnClickListener { - setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER)) - dismiss() - } - dialog.findViewById<View>(R.id.create_password)?.setOnClickListener { - setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_PASSWORD)) - dismiss() - } - } - } - ) - } - - override fun dismiss() { - super.dismiss() - behavior?.removeBottomSheetCallback(bottomSheetCallback) - } -} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt deleted file mode 100644 index 1a75175b..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.dialogs - -import android.app.Dialog -import android.net.Uri -import android.os.Bundle -import android.view.WindowManager -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import app.passwordstore.databinding.FragmentManualOtpEntryBinding -import app.passwordstore.ui.crypto.PasswordCreationActivity -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class OtpImportDialogFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater) - builder.setView(binding.root) - builder.setPositiveButton(android.R.string.ok) { _, _ -> - setFragmentResult( - PasswordCreationActivity.OTP_RESULT_REQUEST_KEY, - bundleOf(PasswordCreationActivity.RESULT to getTOTPUri(binding)), - ) - } - val dialog = builder.create() - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - return dialog - } - - private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String { - val secret = binding.secret.text.toString() - val account = binding.account.text.toString() - if (secret.isBlank()) return "" - val builder = Uri.Builder() - builder.scheme("otpauth") - builder.authority("totp") - builder.appendQueryParameter("secret", secret) - if (account.isNotBlank()) builder.appendQueryParameter("issuer", account) - return builder.build().toString() - } -} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt deleted file mode 100644 index d4a2372b..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.dialogs - -import android.app.AlertDialog -import android.app.Dialog -import android.content.Context -import android.graphics.Typeface -import android.os.Bundle -import android.widget.CheckBox -import android.widget.EditText -import android.widget.Toast -import androidx.annotation.IdRes -import androidx.appcompat.widget.AppCompatTextView -import androidx.core.content.edit -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.databinding.FragmentPwgenBinding -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 app.passwordstore.ui.crypto.PasswordCreationActivity -import app.passwordstore.util.settings.PreferenceKeys -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.launch -import reactivecircus.flowbinding.android.widget.afterTextChanges -import reactivecircus.flowbinding.android.widget.checkedChanges - -class PasswordGeneratorDialogFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val prefs = requireContext().getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) - val builder = MaterialAlertDialogBuilder(requireContext()) - - val binding = FragmentPwgenBinding.inflate(layoutInflater) - builder.setView(binding.root) - - binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false) - binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false) - binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false) - binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false) - binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false) - binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true) - binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString()) - binding.passwordText.typeface = Typeface.MONOSPACE - - lifecycleScope.launch { - merge( - binding.numerals.checkedChanges().skipInitialValue(), - binding.symbols.checkedChanges().skipInitialValue(), - binding.uppercase.checkedChanges().skipInitialValue(), - binding.lowercase.checkedChanges().skipInitialValue(), - binding.ambiguous.checkedChanges().skipInitialValue(), - binding.pronounceable.checkedChanges().skipInitialValue(), - binding.lengthNumber.afterTextChanges().skipInitialValue(), - ) - .collect { generate(binding.passwordText) } - } - - return builder - .run { - setTitle(R.string.pwgen_title) - setPositiveButton(R.string.dialog_ok) { _, _ -> - setFragmentResult( - PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY, - bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}"), - ) - } - setNeutralButton(R.string.dialog_cancel) { _, _ -> } - setNegativeButton(R.string.pwgen_generate, null) - create() - } - .apply { - setOnShowListener { - generate(binding.passwordText) - getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { - generate(binding.passwordText) - } - } - } - } - - private fun generate(passwordField: AppCompatTextView) { - val passwordOptions = getSelectedOptions() - val passwordLength = getLength() - setPrefs(requireContext(), passwordOptions, passwordLength) - passwordField.text = - runCatching { PasswordGenerator.generate(passwordOptions, passwordLength) } - .getOrElse { exception -> - val errorText = - when (exception) { - is MaxIterationsExceededException -> - requireContext().getString(R.string.pwgen_max_iterations_exceeded) - is NoCharactersIncludedException -> - requireContext().getString(R.string.pwgen_no_chars_error) - is PasswordLengthTooShortException -> - requireContext().getString(R.string.pwgen_length_too_short_error) - else -> requireContext().getString(R.string.pwgen_some_error_occurred) - } - Toast.makeText(requireActivity(), errorText, Toast.LENGTH_SHORT).show() - "" - } - } - - private fun isChecked(@IdRes id: Int): Boolean { - return requireDialog().findViewById<CheckBox>(id).isChecked - } - - private fun getSelectedOptions(): List<PasswordOption> { - return listOfNotNull( - PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) }, - PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) }, - PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) }, - PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) }, - PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) }, - PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) }, - ) - } - - private fun getLength(): Int { - val lengthText = requireDialog().findViewById<EditText>(R.id.lengthNumber).text.toString() - return lengthText.toIntOrNull()?.takeIf { it >= 0 } ?: PasswordGenerator.DEFAULT_LENGTH - } - - /** - * Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for generated - * passwords. - */ - private fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean { - ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit { - for (possibleOption in PasswordOption.entries) { - putBoolean(possibleOption.key, possibleOption in options) - } - putInt("length", targetLength) - } - return true - } -} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt b/app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt deleted file mode 100644 index 58c39968..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.dialogs - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import android.view.WindowManager -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import app.passwordstore.databinding.DialogTextInputBinding -import app.passwordstore.util.extensions.finish -import app.passwordstore.util.extensions.unsafeLazy -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -/** - * General purpose [DialogFragment] that can be used to accept a single text input. It provides a - * configurable title and text input hint through the instantiation helper at - * [TextInputDialog.newInstance]. Typical usage would look something like this: - * ```kotlin - * val dialog = TextInputDialog.newInstance(getString(R.string.dialog_title), getString(R.string.dialog_hint)) - * dialog.show(supportFragmentManager, "text_input_dialog") - * dialog.setFragmentResultListener(TextInputDialog.REQUEST_KEY) { _, bundle -> - * doSomething(bundle.getString(TextInputDialog.BUNDLE_KEY_TEXT)) - * } - * ``` - */ -class TextInputDialog : DialogFragment() { - private val binding by unsafeLazy { DialogTextInputBinding.inflate(layoutInflater) } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - builder.setView(binding.root) - builder.setTitle(arguments?.getString(BUNDLE_KEY_TITLE)) - arguments?.getString(BUNDLE_KEY_HINT)?.let { hint -> - binding.textInputLayout.isHintEnabled = true - binding.textInputLayout.isHintAnimationEnabled = true - binding.textInputLayout.hint = hint - } - builder.setPositiveButton(android.R.string.ok) { dialogInterface, _ -> - setFragmentResult(REQUEST_KEY, bundleOf(BUNDLE_KEY_TEXT to binding.editText.text.toString())) - dialogInterface.dismiss() - } - val dialog = builder.create() - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - return dialog - } - - override fun onCancel(dialog: DialogInterface) { - super.onCancel(dialog) - finish() - } - - companion object { - const val REQUEST_KEY = "text_input_dialog" - const val BUNDLE_KEY_TEXT = "text" - private const val BUNDLE_KEY_TITLE = "title" - private const val BUNDLE_KEY_HINT = "hint" - - fun newInstance(title: String, hint: String? = null): TextInputDialog { - val args = bundleOf(BUNDLE_KEY_TITLE to title, BUNDLE_KEY_HINT to hint) - val dialog = TextInputDialog() - dialog.arguments = args - return dialog - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt new file mode 100644 index 00000000..6d9f9c9f --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt @@ -0,0 +1,268 @@ +/* + * 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(target.parentFile?.relativeTo(root)?.path.orEmpty()) ?: return + activity.lifecycleScope.launch { + val result = viewModel.save(ids, target, original, activity, copy) + if (result is SaveResult.Failed) failure = activity.getString(result.message, result.arg) + } + } + + // Finishes a save: run again by an editor recreated in the middle (a theme or locale change + // cancels this effect), so the commit and onSaved always happen; the copy and notice only once. + val saved = viewModel.saved + LaunchedEffect(saved) { + if (saved == null) return@LaunchedEffect + if (viewModel.announce()) { + if (viewModel.copyOnSave) activity.copyPasswordToClipboard(saved.form.password) + saved.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 commit cut + // short by recreation runs again; one that already went through finds nothing to commit. + activity.commitChange( + activity.getString( + if (original != null) R.string.git_commit_edit_text else R.string.git_commit_add_text, + saved.file.relativeTo(root).path.removeSuffix(".gpg"), + ) + ) + keyboard?.hide() + focusManager.clearFocus() + viewModel.left() + onSaved(saved) + } + + 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)) } + }, + ) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt new file mode 100644 index 00000000..d7437df3 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt @@ -0,0 +1,150 @@ +/* + * 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.FileOutputStream +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").toPath().normalize().toFile() + +/** 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 { + FileOutputStream(temp).use { + it.write(bytes) + it.fd.sync() + } + 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 = '-') diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt new file mode 100644 index 00000000..bb8af01a --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt @@ -0,0 +1,314 @@ +/* + * 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)) } + }, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt new file mode 100644 index 00000000..6d2fbaec --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt @@ -0,0 +1,273 @@ +/* + * 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.NonCancellable +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 + + /** + * The successful save, kept until the editor has committed it and left ([left]), so an editor + * recreated in the middle finishes the job. Memory only: it holds the plaintext form. + */ + var saved by mutableStateOf<SaveResult.Saved?>(null) + private set + + /** Whether [saved] is to be copied to the clipboard ("Save and copy"). */ + var copyOnSave = false + private set + + private var announced = false + + /** 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. A success lands in [saved], with [copy] in [copyOnSave]. Returns null when a save + * already ran or is running. + */ + suspend fun save( + ids: List<PGPIdentifier>, + target: File, + original: File?, + context: Context, + copy: Boolean, + ): SaveResult? { + if (saving) return null + saving = true + copyOnSave = copy + try { + return write(ids, target, original, context.applicationContext) + } finally { + // Stays set after a success (the editor is leaving); cleared on failure or cancellation. + if (saved == null) saving = false + } + } + + /** True the first time only: the copy and notices of [saved] run once, across recreation. */ + fun announce(): Boolean = !announced.also { announced = true } + + /** The editor committed [saved] and left: nothing is left to finish. */ + fun left() { + saved = null + } + + override fun onCleared() { + form = EditorForm() + saved = null + } + + 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(), + ) + } + // NonCancellable: a recreated activity must not stop between writing the target and deleting + // the original, which would leave a rename that cannot be retried ("duplicate"). + return withContext(NonCancellable + 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 + } + } + // Set here, inside NonCancellable: a written entry is never forgotten by a cancelled caller. + SaveResult.Saved(target, form, stale).also { saved = it } + } + } + + 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" + } +} diff --git a/app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt b/app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt new file mode 100644 index 00000000..67a4963a --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt @@ -0,0 +1,197 @@ +/* + * 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)) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt new file mode 100644 index 00000000..02b112f4 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt @@ -0,0 +1,246 @@ +/* + * 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 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.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 +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.Totp +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.crypto.copyPasswordToClipboard +import app.passwordstore.ui.crypto.copyTextToClipboard +import app.passwordstore.ui.crypto.getPGPIdentifiers +import app.passwordstore.ui.crypto.getParentPath +import app.passwordstore.ui.main.LaunchActivity +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.setup.SetupStart +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 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, + modifier: Modifier = Modifier, + viewModel: EntryViewModel = hiltViewModel(), +) { + val root = PasswordRepository.getRepositoryDirectory() + + // 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) + } + } + + // ponytail: after process death during key import, 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() + } + + // 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 = { if (entry != null) onEdit() }, + 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(MainActivity.setupIntent(activity, SetupStart.PgpImport)) + } + ) { + Text(stringResource(R.string.button_label_import)) + } + }, + ) + EntryPrompt.Passphrase, + EntryPrompt.WrongPassphrase -> + 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, + ) + } + } + } +} + +/** + * 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( + 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)) + } + }, + ) +} + +/** 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), + ) diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt new file mode 100644 index 00000000..8dbc9eb9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt @@ -0,0 +1,35 @@ +/* + * 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() diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt new file mode 100644 index 00000000..d4860819 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt @@ -0,0 +1,183 @@ +/* + * 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) }) } + } + } + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt new file mode 100644 index 00000000..6296a9b6 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt @@ -0,0 +1,220 @@ +/* + * 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.ui.autofill.cachesPassphrase +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 + /** 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<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) { + if (prompt == EntryPrompt.None) return + prompt = EntryPrompt.None + this.clearCache = clearCache + val app = context.applicationContext + viewModelScope.launch { + if (decrypt(passphrase, app) && cachesPassphrase(cacheEnabled, auth)) { + 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 + } +} diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt deleted file mode 100644 index cfeb6206..00000000 --- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.folderselect - -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.commit -import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.passwords.PASSWORD_FRAGMENT_TAG -import app.passwordstore.ui.passwords.PasswordStore -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) { - - private lateinit var passwordList: SelectFolderFragment - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - passwordList = SelectFolderFragment() - val args = Bundle() - args.putString( - PasswordStore.REQUEST_ARG_PATH, - PasswordRepository.getRepositoryDirectory().absolutePath, - ) - - passwordList.arguments = args - - supportActionBar?.show() - - supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - - supportFragmentManager.commit { - replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG) - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.pgp_handler_select_folder, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - setResult(RESULT_CANCELED) - onBackPressedDispatcher.onBackPressed() - } - R.id.crypto_select -> selectFolder() - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun selectFolder() { - intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath) - setResult(RESULT_OK, intent) - finish() - } -} diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt deleted file mode 100644 index 3cf12fdd..00000000 --- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.folderselect - -import android.content.Context -import android.os.Bundle -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import app.passwordstore.R -import app.passwordstore.data.password.PasswordItem -import app.passwordstore.databinding.PasswordRecyclerViewBinding -import app.passwordstore.ui.adapters.PasswordItemRecyclerAdapter -import app.passwordstore.ui.passwords.PasswordStore -import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.viewmodel.ListMode -import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import dagger.hilt.android.AndroidEntryPoint -import java.io.File -import javax.inject.Inject -import kotlinx.coroutines.launch -import me.zhanghai.android.fastscroll.FastScrollerBuilder - -@AndroidEntryPoint -class SelectFolderFragment : Fragment(R.layout.password_recycler_view) { - - @Inject lateinit var dispatcherProvider: DispatcherProvider - private val binding by viewBinding(PasswordRecyclerViewBinding::bind) - private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter - private lateinit var listener: OnFragmentInteractionListener - - private val model: SearchableRepositoryViewModel by activityViewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.fab.hide() - recyclerAdapter = - PasswordItemRecyclerAdapter(lifecycleScope, dispatcherProvider).onItemClicked { _, item -> - listener.onFragmentInteraction(item) - } - binding.passRecycler.apply { - layoutManager = LinearLayoutManager(requireContext()) - itemAnimator = null - adapter = recyclerAdapter - } - - FastScrollerBuilder(binding.passRecycler).build() - registerForContextMenu(binding.passRecycler) - - 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) - lifecycleScope.launch { - model.searchResult.flowWithLifecycle(lifecycle).collect { result -> - recyclerAdapter.submitList(result.passwordItems) - } - } - } - - override fun onAttach(context: Context) { - super.onAttach(context) - runCatching { - listener = - object : OnFragmentInteractionListener { - override fun onFragmentInteraction(item: PasswordItem) { - if (item.type == PasswordItem.TYPE_CATEGORY) { - model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly) - (requireActivity() as AppCompatActivity) - .supportActionBar - ?.setDisplayHomeAsUpEnabled(true) - } - } - } - } - .onFailure { - throw ClassCastException("$context must implement OnFragmentInteractionListener") - } - } - - val currentDir: File - get() = model.currentDir.value - - interface OnFragmentInteractionListener { - - fun onFragmentInteraction(item: PasswordItem) - } -} diff --git a/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt b/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt deleted file mode 100644 index 695029d4..00000000 --- a/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.git.config - -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Patterns -import android.view.MenuItem -import androidx.core.os.postDelayed -import androidx.fragment.app.setFragmentResultListener -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.databinding.ActivityGitConfigBinding -import app.passwordstore.ui.dialogs.TextInputDialog -import app.passwordstore.ui.git.base.BaseGitActivity -import app.passwordstore.ui.git.log.GitLogActivity -import app.passwordstore.util.extensions.asLog -import app.passwordstore.util.extensions.launchActivity -import app.passwordstore.util.extensions.viewBinding -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.launch -import logcat.LogPriority.ERROR -import logcat.logcat -import org.eclipse.jgit.lib.Constants -import org.eclipse.jgit.lib.Repository -import org.eclipse.jgit.lib.RepositoryState - -class GitConfigActivity : BaseGitActivity() { - - private val binding by viewBinding(ActivityGitConfigBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - if (gitSettings.authorName.isEmpty()) binding.gitUserName.requestFocus() - else binding.gitUserName.setText(gitSettings.authorName) - binding.gitUserEmail.setText(gitSettings.authorEmail) - setupTools() - binding.saveButton.setOnClickListener { - val email = binding.gitUserEmail.text.toString().trim() - val name = binding.gitUserName.text.toString().trim() - if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) { - MaterialAlertDialogBuilder(this) - .setMessage(getString(R.string.invalid_email_dialog_text)) - .setPositiveButton(getString(R.string.dialog_ok), null) - .show() - } else { - gitSettings.authorEmail = email - gitSettings.authorName = name - Snackbar.make( - binding.root, - getString(R.string.git_server_config_save_success), - Snackbar.LENGTH_SHORT, - ) - .show() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - /** Sets up the UI components of the tools section. */ - private fun setupTools() { - val repo = PasswordRepository.repository - if (repo != null) { - binding.gitHeadStatus.text = headStatusMsg(repo) - // enable the abort button only if we're rebasing or merging - val needsAbort = - repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING - binding.gitAbortRebase.isEnabled = needsAbort - binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f - } - binding.gitLog.setOnClickListener { - runCatching { launchActivity(GitLogActivity::class.java) } - .onFailure { ex -> logcat(ERROR) { ex.asLog("Failed to start GitLogActivity") } } - } - binding.gitAbortRebase.setOnClickListener { abortRebase() } - binding.gitResetToRemote.setOnClickListener { resetToRemote() } - binding.gitGc.setOnClickListener { - lifecycleScope.launch { - launchGitOperation(GitOp.GC) - .fold( - success = ::finishOnSuccessHandler, - failure = { err -> promptOnErrorHandler(err) { finish() } }, - ) - } - } - } - - private fun resetToRemote() { - val dialog = - TextInputDialog.newInstance(getString(R.string.git_utils_reset_remote_branch_title)) - dialog.show(supportFragmentManager, "BRANCH_INPUT_DIALOG") - dialog.setFragmentResultListener(TextInputDialog.REQUEST_KEY) { _, bundle -> - val result = bundle.getString(TextInputDialog.BUNDLE_KEY_TEXT) - if (!result.isNullOrEmpty()) { - remoteBranch = result - lifecycleScope.launch { - launchGitOperation(GitOp.RESET) - .fold( - success = ::finishOnSuccessHandler, - failure = { err -> promptOnErrorHandler(err) { finish() } }, - ) - } - } - } - } - - private fun abortRebase() { - lifecycleScope.launch { - launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED) - .fold( - success = { - val branch = PasswordRepository.getCurrentBranch() - MaterialAlertDialogBuilder(this@GitConfigActivity).run { - setTitle(resources.getString(R.string.git_abort_and_push_title)) - setMessage( - resources.getString( - R.string.git_break_out_of_detached_success, - branch, - "conflicting-$branch-...", - ) - ) - setOnDismissListener { finish() } - setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } - show() - } - }, - failure = { err -> promptOnErrorHandler(err) { finish() } }, - ) - } - } - - /** - * Returns a user-friendly message about the current state of HEAD. - * - * The state is recognized to be either pointing to a branch or detached. - */ - private fun headStatusMsg(repo: Repository): String { - return runCatching { - val headRef = repo.findRef(Constants.HEAD) - if (headRef.isSymbolic) { - val branchName = headRef.target.name - val shortBranchName = Repository.shortenRefName(branchName) - getString(R.string.git_head_on_branch, shortBranchName) - } else { - val commitHash = headRef.objectId.abbreviate(8).name() - getString(R.string.git_head_detached, commitHash) - } - } - .getOrElse { ex -> - logcat(ERROR) { "Error getting HEAD reference\n${ex}" } - getString(R.string.git_head_missing) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt b/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt deleted file mode 100644 index 2a1d3ebd..00000000 --- a/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt +++ /dev/null @@ -1,304 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.git.config - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.view.MenuItem -import android.view.View -import androidx.core.os.postDelayed -import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.databinding.ActivityGitCloneBinding -import app.passwordstore.ui.dialogs.BasicBottomSheet -import app.passwordstore.ui.git.base.BaseGitActivity -import app.passwordstore.util.extensions.snackbar -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.AuthMode -import app.passwordstore.util.settings.GitSettings -import app.passwordstore.util.settings.Protocol -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -/** - * Activity that encompasses both the initial clone as well as editing the server config for future - * changes. - */ -class GitServerConfigActivity : BaseGitActivity() { - - private val binding by viewBinding(ActivityGitCloneBinding::inflate) - - private lateinit var newAuthMode: AuthMode - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val isClone = intent?.extras?.getBoolean("cloning") ?: false - if (isClone) { - binding.saveButton.text = getString(R.string.clone_button) - } - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - newAuthMode = gitSettings.authMode - - binding.authModeGroup.apply { - when (newAuthMode) { - AuthMode.SshKey -> check(binding.authModeSshKey.id) - AuthMode.Password -> check(binding.authModePassword.id) - AuthMode.None -> clearChecked() - } - addOnButtonCheckedListener { _, _, _ -> - if (checkedButtonIds.isEmpty()) { - newAuthMode = AuthMode.None - } else { - when (checkedButtonId) { - binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey - binding.authModePassword.id -> newAuthMode = AuthMode.Password - View.NO_ID -> newAuthMode = AuthMode.None - } - } - } - } - - binding.serverUrl.setText( - gitSettings.url.also { - if (it.isNullOrEmpty()) return@also - setAuthModes(it.startsWith("http://") || it.startsWith("https://")) - } - ) - - binding.serverUrl.doOnTextChanged { text, _, _, _ -> - if (text.isNullOrEmpty()) return@doOnTextChanged - setAuthModes(text.startsWith("http://") || text.startsWith("https://")) - } - - binding.clearHostKeyButton.isVisible = gitSettings.hasSavedHostKey() - binding.clearHostKeyButton.setOnClickListener { - gitSettings.clearSavedHostKey() - Snackbar.make( - binding.root, - getString(R.string.clear_saved_host_key_success), - Snackbar.LENGTH_LONG, - ) - .show() - it.isVisible = false - } - binding.saveButton.setOnClickListener { - val newUrl = binding.serverUrl.text.toString().trim() - // If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://` - // in the beginning will cause the port to be seen as part of the path. Let users know - // about it and offer a quickfix. - if (newUrl.contains(PORT_REGEX)) { - if (newUrl.startsWith("https://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.https_scheme_with_port_title) - .setMessageRes(R.string.https_scheme_with_port_message) - .setPositiveButtonClickListener { - binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/")) - } - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } else if (!newUrl.startsWith("ssh://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.ssh_scheme_needed_message) - .setPositiveButtonClickListener { - @Suppress("SetTextI18n") binding.serverUrl.setText("ssh://$newUrl") - } - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } - } - if (newUrl.startsWith("git://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.git_scheme_disallowed_title) - .setMessageRes(R.string.git_scheme_disallowed_message) - .setPositiveButtonClickListener {} - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } - when ( - val updateResult = - gitSettings.updateConnectionSettingsIfValid( - newAuthMode = newAuthMode, - newUrl = binding.serverUrl.text.toString().trim(), - ) - ) { - GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> { - Snackbar.make( - binding.root, - getString(R.string.git_server_config_save_error), - Snackbar.LENGTH_LONG, - ) - .show() - } - is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> { - when (updateResult.newProtocol) { - Protocol.Https -> - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.git_server_config_save_missing_username_https) - .setPositiveButtonClickListener {} - .build() - .show(supportFragmentManager, "HTTPS_MISSING_USERNAME") - Protocol.Ssh -> - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.git_server_config_save_missing_username_ssh) - .setPositiveButtonClickListener {} - .build() - .show(supportFragmentManager, "SSH_MISSING_USERNAME") - } - } - GitSettings.UpdateConnectionSettingsResult.Valid -> { - if (isClone && PasswordRepository.repository == null) PasswordRepository.initialize() - if (!isClone) { - Snackbar.make( - binding.root, - getString(R.string.git_server_config_save_success), - Snackbar.LENGTH_SHORT, - ) - .show() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } else { - cloneRepository() - } - } - is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> { - val message = - getString( - R.string.git_server_config_save_auth_mode_mismatch, - updateResult.newProtocol, - updateResult.validModes.joinToString(", "), - ) - Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() - } - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private fun setAuthModes(isHttps: Boolean) = - with(binding) { - if (isHttps) { - authModeSshKey.isVisible = false - authModePassword.isVisible = true - if (authModeGroup.checkedButtonId != authModePassword.id) authModeGroup.clearChecked() - } else { - authModeSshKey.isVisible = true - authModePassword.isVisible = true - if (authModeGroup.checkedButtonId == View.NO_ID) authModeGroup.check(authModeSshKey.id) - } - } - - /** Clones the repository, the directory exists, deletes it */ - private fun cloneRepository() { - val localDir = - requireNotNull(PasswordRepository.getRepositoryDirectory()) { - "Repository directory must be set before cloning" - } - val localDirFiles = localDir.listFiles() ?: emptyArray() - // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder - if ( - localDir.exists() && - localDirFiles.isNotEmpty() && - !(localDirFiles.size == 1 && localDirFiles[0].name == ".git") - ) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_delete_title) - .setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString())) - .setCancelable(false) - .setPositiveButton(R.string.dialog_delete) { dialog, _ -> - runCatching { - lifecycleScope.launch { - val snackbar = - snackbar( - message = getString(R.string.delete_directory_progress_text), - length = Snackbar.LENGTH_INDEFINITE, - ) - withContext(dispatcherProvider.io()) { - localDir.deleteRecursively() - localDir.mkdirs() - } - snackbar.dismiss() - launchGitOperation(GitOp.CLONE) - .fold( - success = { - setResult(RESULT_OK) - finish() - }, - failure = { err -> promptOnErrorHandler(err) { finish() } }, - ) - } - } - .onFailure { e -> - e.printStackTrace() - MaterialAlertDialogBuilder(this).setMessage(e.message).show() - } - dialog.cancel() - } - .setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> dialog.cancel() } - .show() - } else { - runCatching { - // Silently delete & replace the lone .git folder if it exists - if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") { - localDir.deleteRecursively() - } - } - .onFailure { e -> - logcat(ERROR) { e.asLog() } - MaterialAlertDialogBuilder(this).setMessage(e.message).show() - } - lifecycleScope.launch { - launchGitOperation(GitOp.CLONE) - .fold( - success = { - setResult(RESULT_OK) - finish() - }, - failure = { promptOnErrorHandler(it) }, - ) - } - } - } - - companion object { - - private val PORT_REGEX = ":[0-9]{1,5}/".toRegex() - - fun createCloneIntent(context: Context): Intent { - return Intent(context, GitServerConfigActivity::class.java).apply { - putExtra("cloning", true) - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt b/app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt deleted file mode 100644 index a72881d1..00000000 --- a/app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.git.log - -import android.os.Bundle -import android.view.MenuItem -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import app.passwordstore.databinding.ActivityGitLogBinding -import app.passwordstore.ui.git.base.BaseGitActivity -import app.passwordstore.util.extensions.viewBinding - -/** - * Displays the repository's git commits in git-log fashion. - * - * It provides basic information about each commit by way of a non-interactive RecyclerView. - */ -class GitLogActivity : BaseGitActivity() { - - private val binding by viewBinding(ActivityGitLogBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - createRecyclerView() - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - finish() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private fun createRecyclerView() { - binding.gitLogRecyclerView.apply { - setHasFixedSize(true) - addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL)) - adapter = GitLogAdapter() - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt b/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt deleted file mode 100644 index 95186188..00000000 --- a/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.git.log - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import app.passwordstore.databinding.GitLogRowLayoutBinding -import app.passwordstore.util.git.GitCommit -import app.passwordstore.util.git.GitLogModel -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.util.Locale -import logcat.LogPriority.ERROR -import logcat.logcat - -private fun shortHash(hash: String): String { - return hash.substring(0 until 8) -} - -private fun stringFrom(date: Instant): String { - return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) - .withLocale(Locale.getDefault()) - .withZone(ZoneId.systemDefault()) - .format(date) -} - -/** @see GitLogActivity */ -class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() { - - private val model = GitLogModel() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val inflater = LayoutInflater.from(parent.context) - val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false) - return ViewHolder(binding) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val commit = model.get(position) - if (commit == null) { - logcat(ERROR) { "There is no git commit for view holder at position $position." } - return - } - viewHolder.bind(commit) - } - - override fun getItemCount() = model.size - - class ViewHolder(private val binding: GitLogRowLayoutBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(commit: GitCommit) = - with(binding) { - gitLogRowMessage.text = commit.shortMessage - gitLogRowHash.text = shortHash(commit.hash) - gitLogRowTime.text = stringFrom(commit.time) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt new file mode 100644 index 00000000..bd5b66f3 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import android.widget.Toast +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) + // ponytail: a composable has no injected DispatcherProvider; plain IO for a directory read. + @Suppress("RawDispatchersUse") + 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 + naming = false + if (folder.mkdirs() || folder.isDirectory) { + path = folder.relativeTo(root.canonicalFile).path + } else { + Toast.makeText(context, R.string.folder_creation_err_failed, Toast.LENGTH_LONG).show() + } + }, + 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)) } + } + }, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt new file mode 100644 index 00000000..a9228484 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt @@ -0,0 +1,368 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import android.widget.Toast +import androidx.activity.compose.BackHandler +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.git.base.BaseGitActivity.GitOp +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.util.extensions.isInsideRepository +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, + Move, + Clone, + NotInitialized, +} + +/** + * 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 relative to the root, + * [onOpenEntry] an entry file; [onNewEntry] opens the editor in this folder, [onClone] the git + * server screen for a clone, [onSettings] the settings; [onNoStore] runs when the store is gone. + */ +@Composable +fun ListDestination( + activity: MainActivity, + path: String, + onOpenFolder: (relativePath: String) -> Unit, + onOpenEntry: (File) -> Unit, + onNewEntry: () -> Unit, + onClone: () -> Unit, + onSettings: () -> Unit, + onNoStore: () -> 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) } + + // 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) + 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) } + + fun refresh() { + if (path.isNotEmpty() && !dir.isDirectory) onOpenFolder("") else viewModel.forceRefresh() + } + + // Every return to this folder (back from a child, or from another activity) re-reads it. + LifecycleResumeEffect(Unit) { + if (activity.checkStore()) refresh() else onNoStore() + resumes++ + onPauseOrDispose {} + } + BackHandler(enabled = selected.isNotEmpty()) { selected = emptyList() } + BackHandler(enabled = selected.isEmpty() && query.isNotEmpty()) { query = "" } + + 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 + refresh() + } + } + + 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)) + onOpenEntry(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 = onSettings, + onNew = { dialog = ListDialog.Create }, + onMove = { dialog = ListDialog.Move }, + onDelete = { + val files = selected.map(::File) + val count = countFiles(files) + if (count == 0) { + files.forEach { it.deleteWithoutFollowingLinks() } + selected = emptyList() + refresh() + } else { + deleteCount = count + dialog = ListDialog.Delete + } + }, + onRename = { dialog = ListDialog.Rename }, + onPin = { + activity.pinEntry(File(selected.single())) + selected = emptyList() + }, + focusSearch = path.isEmpty() && prefs.getBoolean(PreferenceKeys.SEARCH_ON_START, false), + ) + + when (dialog) { + ListDialog.None -> Unit + ListDialog.Create -> + CreateSheet( + onDismiss = { dialog = ListDialog.None }, + onNewEntry = { + dialog = ListDialog.None + ifInitialized(onNewEntry) + }, + 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)) + refresh() + } + }, + 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) + refresh() + } + }, + onDismiss = { dialog = ListDialog.None }, + dismiss = stringResource(R.string.dialog_no), + ) + ListDialog.Move -> + FolderPicker( + root = root, + start = path, + onPick = { target -> + // One move per pick: a double tap on Select must not move twice. + val destination = File(root, target) + val files = selected.map(::File) + val canonical = destination.canonicalFile + if ( + !destination.isInsideRepository() || + files.any { it.isDirectory && canonical.startsWith(it.canonicalFile) } + ) { + Toast.makeText( + activity, + R.string.message_error_destination_outside_repo, + Toast.LENGTH_LONG, + ) + .show() + } else if (dialog == ListDialog.Move) { + dialog = ListDialog.None + selected = emptyList() + activity.lifecycleScope.launch { + activity.moveItems(files, destination) + refresh() + } + } + }, + onDismiss = { dialog = ListDialog.None }, + ) + ListDialog.Clone -> + Prompt( + text = stringResource(R.string.clone_git_repo), + confirm = stringResource(R.string.clone_button), + onConfirm = { + dialog = ListDialog.None + onClone() + }, + 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. Shared with the + * folder picker's "New folder". + */ +@Composable +internal 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, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/list/ListModel.kt b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt new file mode 100644 index 00000000..96b7ab4b --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt @@ -0,0 +1,71 @@ +/* + * 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 + +/** + * 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() } + +/** + * 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 } +} diff --git a/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt new file mode 100644 index 00000000..720cfc0a --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt @@ -0,0 +1,340 @@ +/* + * 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.saveable.rememberSaveable +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.platform.LocalWindowInfo +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 androidx.lifecycle.compose.LifecycleResumeEffect +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, + focusSearch: Boolean = false, +) { + val selecting = selected.isNotEmpty() + val listState = rememberLazyListState() + val pullState = rememberPullToRefreshState() + val searchFocus = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + // Saveable, so it survives this destination leaving composition while a folder is open. + var launched by rememberSaveable { mutableStateOf(false) } + var returned by remember { mutableStateOf(false) } + LifecycleResumeEffect(Unit) { + returned = true + onPauseOrDispose {} + } + // Out of touch mode (keys, D-pad) the view system hands its default focus to the first + // focusable, the search field, which keeps it in touch mode too and brings the keyboard back + // when the list returns from a screen that had it up. So every return drops focus, except the + // first, where search on start asks for it. Done once the window has focus: before that the + // view still holds the touch mode it last saw, and out of it clearing focus refocuses the field. + val windowFocused = LocalWindowInfo.current.isWindowFocused + LaunchedEffect(returned, windowFocused) { + if (returned && windowFocused) { + returned = false + if (focusSearch && !launched) searchFocus.requestFocus() else focusManager.clearFocus() + launched = true + } + } + // 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) + // M3 clears the label's semantics; the FAB takes its description from the icon. + ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.list_new_entry)) }, + icon = { Icon(Icons.Filled.Add, stringResource(R.string.list_new_entry)) }, + 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 + } diff --git a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt new file mode 100644 index 00000000..3bdede73 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt @@ -0,0 +1,179 @@ +/* + * 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.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 java.nio.file.Files +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 this file or folder, never following symlinks; true when everything is gone. */ +internal fun File.deleteWithoutFollowingLinks(): Boolean { + if (isDirectory && !Files.isSymbolicLink(toPath())) { + // Evaluate every child, no short-circuit, so one failure leaves the rest deleted. + val children = listFiles().orEmpty().map { it.deleteWithoutFollowingLinks() } + if (!children.all { it }) return false + } + return delete() +} + +/** Deletes [files] (entries or whole folders), drops their autofill matches and commits. */ +suspend fun BaseGitActivity.deleteItems(files: List<File>) { + val root = PasswordRepository.getRepositoryDirectory() + withContext(dispatcherProvider.io()) { + val deleted = files.flatMap { if (it.isDirectory) it.listFilesRecursively() else listOf(it) } + files.forEach { it.deleteWithoutFollowingLinks() } + 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) { + 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) + } +} + +private suspend fun BaseGitActivity.moveFile(source: File, destination: File): Boolean { + 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() + } + return moved +} + +/** 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 -> + val dialog = + 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() + cont.invokeOnCancellation { dialog.dismiss() } + } diff --git a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt index 1a5e8a89..c0af5742 100644 --- a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt @@ -12,9 +12,6 @@ import android.os.Looper import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.BasePGPActivity -import app.passwordstore.ui.crypto.DecryptActivity -import app.passwordstore.ui.passwords.PasswordStore import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result import app.passwordstore.util.extensions.sharedPrefs @@ -24,10 +21,21 @@ import java.io.File @SuppressLint("CustomSplashScreen") class LaunchActivity : AppCompatActivity() { + private var prompted = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (!sharedPrefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, false)) startTargetActivity(true) + } + + // The prompt waits for window focus: Android 14's SystemUI dismisses a prompt shown while its + // activity is not yet on top (shown from onCreate), with ERROR_CANCELED, closing the app at once. + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (!hasFocus || prompted || isFinishing) return val prefs = sharedPrefs if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, false)) { + prompted = true BiometricAuthenticator.authenticate(this) { result -> when (result) { is Result.Success -> { @@ -45,27 +53,20 @@ class LaunchActivity : AppCompatActivity() { is Result.Retry -> {} } } - } else { - startTargetActivity(true) } } - private fun getDecryptIntent(): Intent { - return Intent(this, DecryptActivity::class.java) - } - private fun startTargetActivity(noAuth: Boolean) { - // This activity is exported, so the file path is untrusted input: only forward it to - // DecryptActivity when it points at a file inside the password store. + // This activity is exported, so the file path is untrusted input: only forward it when it + // points at a file inside the password store. MainActivity is not exported and trusts it. val repoDir = PasswordRepository.getRepositoryDirectory() - val filePath = intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH) - val intentToStart = - if (intent.action == ACTION_DECRYPT_PASS && isFileInRepo(filePath, repoDir)) - getDecryptIntent().apply { - putExtra(BasePGPActivity.EXTRA_FILE_PATH, filePath) - putExtra(BasePGPActivity.EXTRA_REPO_PATH, repoDir.absolutePath) - } - else Intent(this, PasswordStore::class.java).setAction(Intent.ACTION_VIEW) + val filePath = intent.getStringExtra(EXTRA_FILE_PATH) + val intentToStart = Intent(this, MainActivity::class.java) + if (intent.action == ACTION_DECRYPT_PASS && isFileInRepo(filePath, repoDir)) + intentToStart.putExtra(EXTRA_FILE_PATH, filePath) + // A plain start replaces the task, so an instance already open (one whose store vanished, say) + // is not left below the new one. An entry opens on top, back returns to where the user was. + else intentToStart.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) startActivity(intentToStart) Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L) @@ -75,6 +76,9 @@ class LaunchActivity : AppCompatActivity() { const val ACTION_DECRYPT_PASS = "DECRYPT_PASS" + /** The entry to open; home screen shortcuts carry this exact key, never change it. */ + const val EXTRA_FILE_PATH = "FILE_PATH" + /** True when [path] resolves, after following `..` and symlinks, to a file under [repoDir]. */ fun isFileInRepo(path: String?, repoDir: File): Boolean { if (path.isNullOrEmpty()) return false diff --git a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt new file mode 100644 index 00000000..ce5fb1df --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt @@ -0,0 +1,283 @@ +/* + * 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.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.SettingsRoute +import app.passwordstore.ui.settings.settingsGraph +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.sharedPrefs +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.PreferenceKeys +import app.passwordstore.util.shortcuts.ShortcutHandler +import com.github.michaelbull.result.Result +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(LaunchActivity.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)) + }, + onSettings = { + if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(SettingsRoute) + }, + 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) + settingsGraph(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) } + } + + /** 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) + } + + /** 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)) + } + + 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 (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. 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. + 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() } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt b/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt deleted file mode 100644 index 709c2302..00000000 --- a/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.onboarding.activity - -import android.os.Bundle -import androidx.activity.addCallback -import androidx.appcompat.app.AppCompatActivity -import app.passwordstore.R -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.hide() - val callback = onBackPressedDispatcher.addCallback(enabled = false) { finishAffinity() } - supportFragmentManager.addOnBackStackChangedListener { - if (supportFragmentManager.backStackEntryCount == 0) { - callback.isEnabled = true - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt deleted file mode 100644 index a9d933b6..00000000 --- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.onboarding.fragments - -import android.os.Bundle -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.fragment.app.Fragment -import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.databinding.FragmentCloneBinding -import app.passwordstore.ui.git.config.GitServerConfigActivity -import app.passwordstore.util.extensions.finish -import app.passwordstore.util.extensions.performTransactionWithBackStack -import app.passwordstore.util.extensions.sharedPrefs -import app.passwordstore.util.extensions.unsafeLazy -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.PreferenceKeys -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -class CloneFragment : Fragment(R.layout.fragment_clone) { - - private val binding by viewBinding(FragmentCloneBinding::bind) - - private val settings by unsafeLazy { requireActivity().applicationContext.sharedPrefs } - - private val cloneAction = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } - finish() - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.cloneRemote.setOnClickListener { cloneToHiddenDir() } - binding.createLocal.setOnClickListener { createRepository() } - } - - /** Clones a remote Git repository to the app's private directory */ - private fun cloneToHiddenDir() { - cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext())) - } - - private fun createRepository() { - val localDir = PasswordRepository.getRepositoryDirectory() - runCatching { - check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" } - PasswordRepository.createRepository(localDir) - if (!PasswordRepository.isInitialized) { - PasswordRepository.initialize() - } - parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance()) - } - .onFailure { e -> - logcat(ERROR) { e.asLog() } - if (!localDir.delete()) { - logcat { "Failed to delete local repository: $localDir" } - } - finish() - } - } - - companion object { - - fun newInstance(): CloneFragment = CloneFragment() - } -} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt deleted file mode 100644 index 64c2e124..00000000 --- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.onboarding.fragments - -import android.content.SharedPreferences -import android.os.Bundle -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.databinding.FragmentKeySelectionBinding -import app.passwordstore.injection.prefs.SettingsPreferences -import app.passwordstore.ui.pgp.PGPKeyListActivity -import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.commitChange -import app.passwordstore.util.extensions.finish -import app.passwordstore.util.extensions.snackbar -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.PreferenceKeys -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import java.io.File -import javax.inject.Inject -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@AndroidEntryPoint -class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) { - - @Inject @SettingsPreferences lateinit var settings: SharedPreferences - @Inject lateinit var dispatcherProvider: DispatcherProvider - private val binding by viewBinding(FragmentKeySelectionBinding::bind) - private val gpgKeySelectAction = - registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - val data = result.data ?: return@registerForActivityResult - val selectedKey = - data.getStringExtra(PGPKeyListActivity.EXTRA_SELECTED_KEY) - ?: return@registerForActivityResult - lifecycleScope.launch { - withContext(dispatcherProvider.io()) { - val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") - gpgIdentifierFile.writeText(selectedKey) - } - settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } - requireActivity() - .commitChange(getString(R.string.git_commit_gpg_id, getString(R.string.app_name))) - finish() - } - } else { - requireActivity() - .snackbar( - message = getString(R.string.gpg_key_select_mandatory), - length = Snackbar.LENGTH_LONG, - ) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.selectKey.setOnClickListener { - gpgKeySelectAction.launch(PGPKeyListActivity.newSelectionActivity(requireContext())) - } - } - - companion object { - - fun newInstance() = KeySelectionFragment() - } -} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt deleted file mode 100644 index 44881c64..00000000 --- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.onboarding.fragments - -import android.os.Bundle -import android.view.View -import androidx.annotation.Keep -import androidx.fragment.app.Fragment -import app.passwordstore.R -import app.passwordstore.databinding.FragmentWelcomeBinding -import app.passwordstore.ui.settings.SettingsActivity -import app.passwordstore.util.extensions.launchActivity -import app.passwordstore.util.extensions.performTransactionWithBackStack -import app.passwordstore.util.extensions.viewBinding - -@Keep -class WelcomeFragment : Fragment(R.layout.fragment_welcome) { - - private val binding by viewBinding(FragmentWelcomeBinding::bind) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.letsGo.setOnClickListener { - parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance()) - } - binding.settingsButton.setOnClickListener { - requireActivity().launchActivity(SettingsActivity::class.java) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt deleted file mode 100644 index 917bb91c..00000000 --- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt +++ /dev/null @@ -1,388 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.passwords - -import android.content.Context -import android.content.SharedPreferences -import android.os.Bundle -import android.os.Parcelable -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.animation.Animation -import android.view.animation.AnimationUtils -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.appcompat.view.ActionMode -import androidx.core.content.edit -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import app.passwordstore.R -import app.passwordstore.data.password.PasswordItem -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.databinding.PasswordRecyclerViewBinding -import app.passwordstore.injection.prefs.SettingsPreferences -import app.passwordstore.ui.adapters.PasswordItemRecyclerAdapter -import app.passwordstore.ui.dialogs.BasicBottomSheet -import app.passwordstore.ui.dialogs.ItemCreationBottomSheet -import app.passwordstore.ui.git.base.BaseGitActivity -import app.passwordstore.ui.git.config.GitServerConfigActivity -import app.passwordstore.ui.util.OnOffItemAnimator -import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.base64 -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.extensions.sharedPrefs -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.AuthMode -import app.passwordstore.util.settings.GitSettings -import app.passwordstore.util.settings.PasswordSortOrder -import app.passwordstore.util.settings.PreferenceKeys -import app.passwordstore.util.shortcuts.ShortcutHandler -import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import dagger.hilt.android.AndroidEntryPoint -import java.io.File -import javax.inject.Inject -import kotlinx.coroutines.launch -import me.zhanghai.android.fastscroll.FastScrollerBuilder - -@AndroidEntryPoint -class PasswordFragment : Fragment(R.layout.password_recycler_view) { - - @Inject lateinit var gitSettings: GitSettings - @Inject lateinit var shortcutHandler: ShortcutHandler - @Inject lateinit var dispatcherProvider: DispatcherProvider - @Inject @SettingsPreferences lateinit var prefs: SharedPreferences - private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter - private lateinit var listener: OnFragmentInteractionListener - private lateinit var settings: SharedPreferences - - private var recyclerViewStateToRestore: Parcelable? = null - private var actionMode: ActionMode? = null - private var scrollTarget: File? = null - - private val model: SearchableRepositoryViewModel by activityViewModels() - private val binding by viewBinding(PasswordRecyclerViewBinding::bind) - private val swipeResult = - registerForActivityResult(StartActivityForResult()) { - binding.swipeRefresher.isRefreshing = false - requireStore().refreshPasswordList() - } - - val currentDir: File - get() = model.currentDir.value - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - settings = requireContext().sharedPrefs - initializePasswordList() - binding.fab.setOnClickListener { - ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET") - } - childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { - _, - bundle -> - when (bundle.getString(ACTION_KEY)) { - ACTION_FOLDER -> requireStore().createFolder() - ACTION_PASSWORD -> requireStore().createPassword() - } - } - } - - private fun initializePasswordList() { - val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git") - val hasGitDir = - gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true) - binding.swipeRefresher.setOnRefreshListener { - if (!hasGitDir) { - requireStore().refreshPasswordList() - binding.swipeRefresher.isRefreshing = false - } else if (!PasswordRepository.isGitRepo()) { - BasicBottomSheet.Builder(requireContext()) - .setMessageRes(R.string.clone_git_repo) - .setPositiveButtonClickListener(getString(R.string.clone_button)) { - swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext())) - } - .build() - .show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO") - binding.swipeRefresher.isRefreshing = false - } else { - // When authentication is set to AuthMode.None then the only git operation we can - // run is a pull, so automatically fallback to that. - val operationId = - when (gitSettings.authMode) { - AuthMode.None -> BaseGitActivity.GitOp.PULL - else -> BaseGitActivity.GitOp.SYNC - } - requireStore().apply { - lifecycleScope.launch { - launchGitOperation(operationId) - .fold( - success = { - binding.swipeRefresher.isRefreshing = false - refreshPasswordList() - }, - failure = { err -> - promptOnErrorHandler(err) { binding.swipeRefresher.isRefreshing = false } - }, - ) - } - } - } - } - - recyclerAdapter = - PasswordItemRecyclerAdapter(lifecycleScope, dispatcherProvider) - .onItemClicked { _, item -> listener.onFragmentInteraction(item) } - .onSelectionChanged { selection -> - // In order to not interfere with drag selection, we disable the - // SwipeRefreshLayout - // once an item is selected. - binding.swipeRefresher.isEnabled = selection.isEmpty - - if (actionMode == null) - actionMode = - requireStore().startSupportActionMode(actionModeCallback) ?: return@onSelectionChanged - - if (!selection.isEmpty) { - actionMode!!.title = - resources.getQuantityString( - R.plurals.delete_title, - selection.size(), - selection.size(), - ) - actionMode!!.invalidate() - } else { - actionMode!!.finish() - } - } - val recyclerView = binding.passRecycler - recyclerView.apply { - addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)) - layoutManager = LinearLayoutManager(requireContext()) - itemAnimator = OnOffItemAnimator() - adapter = recyclerAdapter - } - - FastScrollerBuilder(recyclerView).build() - recyclerAdapter.makeSelectable(recyclerView) - registerForContextMenu(recyclerView) - - val path = - requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) { - "Cannot navigate if ${PasswordStore.REQUEST_ARG_PATH} is not provided" - } - model.navigateTo(File(path), pushPreviousLocation = false) - lifecycleScope.launch { - model.searchResult.flowWithLifecycle(lifecycle).collect { result -> - // Only run animations when the new list is filtered, i.e., the user submitted a search, - // and not on folder navigation since the latter leads to too many removal animations. - (recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered - recyclerAdapter.submitList(result.passwordItems) { - when { - result.isFiltered -> { - // When the result is filtered, we always scroll to the top since that is - // where the best fuzzy match appears. - recyclerView.scrollToPosition(0) - } - scrollTarget != null -> { - scrollTarget?.let { - recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it)) - } - scrollTarget = null - } - else -> { - // When the result is not filtered and there is a saved scroll position for - // it, we try to restore it. - recyclerViewStateToRestore?.let { - recyclerView.layoutManager!!.onRestoreInstanceState(it) - } - recyclerViewStateToRestore = null - } - } - } - } - } - } - - private val actionModeCallback = - object : ActionMode.Callback { - // Called when the action mode is created; startActionMode() was called - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - // Inflate a menu resource providing context menu items - mode.menuInflater.inflate(R.menu.context_pass, menu) - // hide the fab - animateFab(false) - return true - } - - // Called each time the action mode is shown. Always called after onCreateActionMode, - // but may be called multiple times if the mode is invalidated. - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - val selectedItems = recyclerAdapter.getSelectedItems() - menu.findItem(R.id.menu_edit_password).isVisible = - selectedItems.all { it.type == PasswordItem.TYPE_CATEGORY } - menu.findItem(R.id.menu_pin_password).isVisible = - selectedItems.size == 1 && selectedItems[0].type == PasswordItem.TYPE_PASSWORD - return true - } - - // Called when the user selects a contextual menu item - override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - return when (item.itemId) { - R.id.menu_delete_password -> { - requireStore().deletePasswords(recyclerAdapter.getSelectedItems()) - // Action picked, so close the CAB - mode.finish() - true - } - R.id.menu_move_password -> { - requireStore().movePasswords(recyclerAdapter.getSelectedItems()) - false - } - R.id.menu_edit_password -> { - requireStore().renameCategory(recyclerAdapter.getSelectedItems()) - mode.finish() - false - } - R.id.menu_pin_password -> { - val passwordItem = recyclerAdapter.getSelectedItems()[0] - shortcutHandler.addPinnedShortcut( - passwordItem, - passwordItem.createAuthEnabledIntent(requireContext()), - ) - false - } - else -> false - } - } - - // Called when the user exits the action mode - override fun onDestroyActionMode(mode: ActionMode) { - recyclerAdapter.requireSelectionTracker().clearSelection() - actionMode = null - // show the fab - animateFab(true) - } - - private fun animateFab(show: Boolean) = - with(binding.fab) { - val animation = - AnimationUtils.loadAnimation(context, if (show) R.anim.scale_up else R.anim.scale_down) - animation.setAnimationListener( - object : Animation.AnimationListener { - override fun onAnimationRepeat(animation: Animation?) {} - - override fun onAnimationEnd(animation: Animation?) { - if (!show) visibility = View.GONE - } - - override fun onAnimationStart(animation: Animation?) { - if (show) visibility = View.VISIBLE - } - } - ) - animate() - .rotationBy(if (show) -90f else 90f) - .setStartDelay(if (show) 100 else 0) - .setDuration(100) - .start() - startAnimation(animation) - } - } - - override fun onResume() { - super.onResume() - binding.swipeRefresher.isEnabled = !prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false) - } - - override fun onAttach(context: Context) { - super.onAttach(context) - runCatching { - listener = - object : OnFragmentInteractionListener { - override fun onFragmentInteraction(item: PasswordItem) { - if ( - settings.getString(PreferenceKeys.SORT_ORDER) == - PasswordSortOrder.RECENTLY_USED.name - ) { - // save the time when password was used - val preferences = - context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) - preferences.edit { - putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString()) - } - } - - if (item.type == PasswordItem.TYPE_CATEGORY) { - navigateTo(item.file) - } else { - if (requireArguments().getBoolean("matchWith", false)) { - requireStore().matchPasswordWithApp(item) - } else { - requireStore().decryptPassword(item) - } - } - } - } - } - .onFailure { - throw ClassCastException("$context must implement OnFragmentInteractionListener") - } - } - - private fun requireStore() = requireActivity() as PasswordStore - - /** Returns true if the back press was handled by the [Fragment]. */ - fun onBackPressedInActivity(): Boolean { - if (!model.canNavigateBack) return false - // The RecyclerView state is restored when the asynchronous update operation on the - // adapter is completed. - recyclerViewStateToRestore = model.navigateBack() - if (!model.canNavigateBack) requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false) - return true - } - - fun dismissActionMode() { - actionMode?.finish() - } - - companion object { - - const val ITEM_CREATION_REQUEST_KEY = "creation_key" - const val ACTION_KEY = "action" - const val ACTION_FOLDER = "folder" - const val ACTION_PASSWORD = "password" - - fun newInstance(args: Bundle): PasswordFragment { - val fragment = PasswordFragment() - fragment.arguments = args - return fragment - } - } - - fun navigateTo(file: File) { - requireStore().clearSearch() - model.navigateTo( - file, - recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState(), - ) - requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true) - } - - fun scrollToOnNextRefresh(file: File) { - scrollTarget = file - } - - interface OnFragmentInteractionListener { - - fun onFragmentInteraction(item: PasswordItem) - } -} diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt deleted file mode 100644 index 059b398f..00000000 --- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt +++ /dev/null @@ -1,621 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.passwords - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.KeyEvent -import android.view.Menu -import android.view.MenuItem -import android.view.MenuItem.OnActionExpandListener -import android.view.WindowManager -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.activity.viewModels -import androidx.appcompat.widget.SearchView -import androidx.appcompat.widget.SearchView.OnQueryTextListener -import androidx.core.content.edit -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.commit -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.password.PasswordItem -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.BasePGPActivity -import app.passwordstore.ui.crypto.BasePGPActivity.Companion.getLongName -import app.passwordstore.ui.crypto.DecryptActivity -import app.passwordstore.ui.crypto.PasswordCreationActivity -import app.passwordstore.ui.dialogs.FolderCreationDialogFragment -import app.passwordstore.ui.folderselect.SelectFolderActivity -import app.passwordstore.ui.git.base.BaseGitActivity -import app.passwordstore.ui.onboarding.activity.OnboardingActivity -import app.passwordstore.ui.settings.SettingsActivity -import app.passwordstore.util.autofill.AutofillMatcher -import app.passwordstore.util.extensions.base64 -import app.passwordstore.util.extensions.commitChange -import app.passwordstore.util.extensions.contains -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.extensions.isInsideRepository -import app.passwordstore.util.extensions.launchActivity -import app.passwordstore.util.extensions.listFilesRecursively -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 app.passwordstore.util.viewmodel.SearchableRepositoryViewModel -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import dagger.hilt.android.AndroidEntryPoint -import java.io.File -import java.lang.Character.UnicodeBlock -import javax.inject.Inject -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.LogPriority.INFO -import logcat.logcat - -const val PASSWORD_FRAGMENT_TAG = "PasswordsList" - -@AndroidEntryPoint -class PasswordStore : BaseGitActivity() { - - @Inject lateinit var shortcutHandler: ShortcutHandler - private lateinit var searchItem: MenuItem - private val settings by lazy { sharedPrefs } - - private val model: SearchableRepositoryViewModel by viewModels() - - private val listRefreshAction = - registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - refreshPasswordList() - } - } - - private val passwordMoveAction = - registerForActivityResult(StartActivityForResult()) { result -> - val intentData = result.data ?: return@registerForActivityResult - val filesToMove = - requireNotNull(intentData.getStringArrayExtra("Files")) { - "'Files' intent extra must be set" - } - val target = - File( - requireNotNull(intentData.getStringExtra("SELECTED_FOLDER_PATH")) { - "'SELECTED_FOLDER_PATH' intent extra must be set" - } - ) - val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath - if (!target.isDirectory) { - logcat(ERROR) { "Tried moving passwords to a non-existing folder." } - return@registerForActivityResult - } - - logcat { "Moving passwords to ${intentData.getStringExtra("SELECTED_FOLDER_PATH")}" } - logcat { filesToMove.joinToString(", ") } - - lifecycleScope.launch(dispatcherProvider.io()) { - for (file in filesToMove) { - val source = File(file) - if (!source.exists()) { - logcat(ERROR) { "Tried moving something that appears non-existent." } - continue - } - val destinationFile = File(target.absolutePath + "/" + source.name) - val basename = source.nameWithoutExtension - val sourceLongName = - getLongName( - requireNotNull(source.parent) { "$file has no parent" }, - repositoryPath, - basename, - ) - val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename) - if (destinationFile.exists()) { - logcat(ERROR) { "Trying to move a file that already exists." } - withContext(dispatcherProvider.main()) { - MaterialAlertDialogBuilder(this@PasswordStore) - .setTitle(resources.getString(R.string.password_exists_title)) - .setMessage( - resources.getString( - R.string.password_exists_message, - destinationLongName, - sourceLongName, - ) - ) - .setPositiveButton(R.string.dialog_ok) { _, _ -> - launch(dispatcherProvider.io()) { moveFile(source, destinationFile) } - } - .setNegativeButton(R.string.dialog_cancel, null) - .show() - } - } else { - launch(dispatcherProvider.io()) { moveFile(source, destinationFile) } - } - } - when (filesToMove.size) { - 1 -> { - val source = File(filesToMove[0]) - val basename = source.nameWithoutExtension - val sourceLongName = - getLongName( - requireNotNull(source.parent) { "$basename has no parent" }, - repositoryPath, - basename, - ) - val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename) - withContext(dispatcherProvider.main()) { - commitChange( - resources.getString( - R.string.git_commit_move_text, - sourceLongName, - destinationLongName, - ) - ) - } - } - else -> { - val repoDir = PasswordRepository.getRepositoryDirectory().absolutePath - val relativePath = getRelativePath("${target.absolutePath}/", repoDir) - withContext(dispatcherProvider.main()) { - commitChange( - resources.getString(R.string.git_commit_move_multiple_text, relativePath) - ) - } - } - } - } - refreshPasswordList() - getPasswordFragment()?.dismissActionMode() - } - - override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { - // open search view on search key, or Ctr+F - if ( - (keyCode == KeyEvent.KEYCODE_SEARCH || - keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) && !searchItem.isActionViewExpanded - ) { - searchItem.expandActionView() - return true - } - - // open search view on any printable character and query for it - val c = event.unicodeChar.toChar() - val printable = isPrintable(c) - if (printable && !searchItem.isActionViewExpanded) { - searchItem.expandActionView() - (searchItem.actionView as SearchView).setQuery(c.toString(), true) - return true - } - return super.onKeyDown(keyCode, event) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_pwdstore) - - lifecycleScope.launch { - model.currentDir.flowWithLifecycle(lifecycle).collect { dir -> - val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile - supportActionBar?.apply { - if (dir != basePath) title = dir.name else setTitle(R.string.app_name) - } - } - } - } - - override fun onStart() { - super.onStart() - refreshPasswordList() - } - - override fun onResume() { - super.onResume() - checkLocalRepository() - if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) { - if (!searchItem.isActionViewExpanded) { - searchItem.expandActionView() - } - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - val menuRes = - when { - gitSettings.authMode == AuthMode.None -> R.menu.main_menu_no_auth - PasswordRepository.isGitRepo() -> R.menu.main_menu_git - else -> R.menu.main_menu_non_git - } - menuInflater.inflate(menuRes, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - // Invalidation forces onCreateOptionsMenu to be called again. This is cheap and quick so - // we can get by without any noticeable difference in performance. - invalidateOptionsMenu() - searchItem = menu.findItem(R.id.action_search) - val searchView = searchItem.actionView as SearchView - searchView.setOnQueryTextListener( - object : OnQueryTextListener { - override fun onQueryTextSubmit(s: String): Boolean { - searchView.clearFocus() - return true - } - - override fun onQueryTextChange(s: String): Boolean { - val filter = s.trim() - // List the contents of the current directory if the user enters a blank - // search term. - if (filter.isEmpty()) - model.navigateTo(newDirectory = model.currentDir.value, pushPreviousLocation = false) - else model.search(filter) - return true - } - } - ) - - // When using the support library, the setOnActionExpandListener() method is - // static and accepts the MenuItem object as an argument - searchItem.setOnActionExpandListener( - object : OnActionExpandListener { - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - refreshPasswordList() - return true - } - - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - return true - } - } - ) - if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) { - searchItem.expandActionView() - } - return super.onPrepareOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - val initBefore = - MaterialAlertDialogBuilder(this) - .setMessage(resources.getString(R.string.creation_dialog_text)) - .setPositiveButton(resources.getString(R.string.dialog_ok), null) - when (id) { - R.id.user_pref -> { - runCatching { launchActivity(SettingsActivity::class.java) } - .onFailure { e -> e.printStackTrace() } - } - R.id.git_push -> { - if (!PasswordRepository.isInitialized) { - initBefore.show() - } else { - runGitOperation(GitOp.PUSH) - } - } - R.id.git_pull -> { - if (!PasswordRepository.isInitialized) { - initBefore.show() - } else { - runGitOperation(GitOp.PULL) - } - } - R.id.git_sync -> { - if (!PasswordRepository.isInitialized) { - initBefore.show() - } else { - runGitOperation(GitOp.SYNC) - } - } - R.id.refresh -> refreshPasswordList() - android.R.id.home -> { - @Suppress("DEPRECATION") onBackPressed() - } - else -> return super.onOptionsItemSelected(item) - } - return true - } - - @Deprecated("Deprecated in Java") - @Suppress("DEPRECATION") - override fun onBackPressed() { - if (getPasswordFragment()?.onBackPressedInActivity() != true) super.onBackPressed() - } - - private fun getPasswordFragment(): PasswordFragment? { - return supportFragmentManager.findFragmentByTag(PASSWORD_FRAGMENT_TAG) as? PasswordFragment - } - - fun clearSearch() { - if (searchItem.isActionViewExpanded) searchItem.collapseActionView() - } - - private fun runGitOperation(operation: GitOp) = - lifecycleScope.launch { - launchGitOperation(operation) - .fold(success = { refreshPasswordList() }, failure = { promptOnErrorHandler(it) }) - } - - private fun checkLocalRepository() { - PasswordRepository.initialize() - checkLocalRepository(PasswordRepository.getRepositoryDirectory()) - } - - private fun checkLocalRepository(localDir: File?) { - if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) { - // do not push the fragment if we already have it - if ( - getPasswordFragment() == null || settings.getBoolean(PreferenceKeys.REPO_CHANGED, false) - ) { - settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) } - val args = Bundle() - args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath) - - // if the activity was started from the autofill settings, the - // intent is to match a clicked pwd with app. pass this to fragment - if (intent.getBooleanExtra("matchWith", false)) { - args.putBoolean("matchWith", true) - } - supportActionBar?.apply { - show() - setDisplayHomeAsUpEnabled(false) - } - supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - supportFragmentManager.commit { - replace(R.id.main_layout, PasswordFragment.newInstance(args), PASSWORD_FRAGMENT_TAG) - } - } - } else { - launchActivity(OnboardingActivity::class.java) - } - } - - private fun getRelativePath(fullPath: String, repositoryPath: String): String { - return fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") - } - - fun decryptPassword(item: PasswordItem) { - val authDecryptIntent = item.createAuthEnabledIntent(this) - val decryptIntent = - Intent(authDecryptIntent).setComponent(ComponentName(this, DecryptActivity::class.java)) - - startActivity(decryptIntent) - - // Adds shortcut - shortcutHandler.addDynamicShortcut(item, authDecryptIntent) - } - - private fun validateState(): Boolean { - if (!PasswordRepository.isInitialized) { - MaterialAlertDialogBuilder(this) - .setMessage(resources.getString(R.string.creation_dialog_text)) - .setPositiveButton(resources.getString(R.string.dialog_ok), null) - .show() - return false - } - return true - } - - fun createPassword() { - if (!validateState()) return - val currentDir = currentDir - logcat(INFO) { "Adding file to : ${currentDir.absolutePath}" } - val intent = Intent(this, PasswordCreationActivity::class.java) - intent.putExtra(BasePGPActivity.EXTRA_FILE_PATH, currentDir.absolutePath) - intent.putExtra( - BasePGPActivity.EXTRA_REPO_PATH, - PasswordRepository.getRepositoryDirectory().absolutePath, - ) - listRefreshAction.launch(intent) - } - - fun createFolder() { - if (!validateState()) return - FolderCreationDialogFragment.newInstance(currentDir.path).show(supportFragmentManager, null) - } - - fun deletePasswords(selectedItems: List<PasswordItem>) { - var size = 0 - selectedItems.forEach { - if (it.file.isFile) size++ else size += it.file.listFilesRecursively().size - } - if (size == 0) { - selectedItems.map { item -> item.file.deleteRecursively() } - refreshPasswordList() - return - } - MaterialAlertDialogBuilder(this) - .setMessage(resources.getQuantityString(R.plurals.delete_dialog_text, size, size)) - .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ -> - val filesToDelete = arrayListOf<File>() - selectedItems.forEach { item -> - if (item.file.isDirectory) filesToDelete.addAll(item.file.listFilesRecursively()) - else filesToDelete.add(item.file) - } - selectedItems.map { item -> item.file.deleteRecursively() } - refreshPasswordList() - AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete) - val fmt = - selectedItems.joinToString(separator = ", ") { item -> - item.file.toRelativeString(PasswordRepository.getRepositoryDirectory()) - } - lifecycleScope.launch { - commitChange(resources.getString(R.string.git_commit_remove_text, fmt)) - } - } - .setNegativeButton(resources.getString(R.string.dialog_no), null) - .show() - } - - fun movePasswords(values: List<PasswordItem>) { - val intent = Intent(this, SelectFolderActivity::class.java) - val fileLocations = values.map { it.file.absolutePath }.toTypedArray() - intent.putExtra("Files", fileLocations) - passwordMoveAction.launch(intent) - } - - enum class CategoryRenameError(val resource: Int) { - None(0), - EmptyField(R.string.message_category_error_empty_field), - CategoryExists(R.string.message_category_error_category_exists), - DestinationOutsideRepo(R.string.message_error_destination_outside_repo), - } - - /** - * Prompt the user with a new category name to assign, if the new category forms/leads a path - * (i.e. contains "/"), intermediate directories will be created and new category will be placed - * inside. - * - * @param oldCategory The category to change its name - * @param error Determines whether to show an error to the user in the alert dialog, this error - * may be due to the new category the user entered already exists or the field was empty or the - * destination path is outside the repository - * @see [CategoryRenameError] - * @see [isInsideRepository] - */ - private fun renameCategory( - oldCategory: PasswordItem, - error: CategoryRenameError = CategoryRenameError.None, - ) { - val view = layoutInflater.inflate(R.layout.folder_dialog_fragment, null) - val newCategoryEditText = view.findViewById<TextInputEditText>(R.id.folder_name_text) - - if (error != CategoryRenameError.None) { - newCategoryEditText.error = getString(error.resource) - } - - val dialog = - MaterialAlertDialogBuilder(this) - .setTitle(R.string.title_rename_folder) - .setView(view) - .setMessage(getString(R.string.message_rename_folder, oldCategory.name)) - .setPositiveButton(R.string.dialog_ok) { _, _ -> - val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}") - when { - newCategoryEditText.text.isNullOrBlank() -> - renameCategory(oldCategory, CategoryRenameError.EmptyField) - newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists) - !newCategory.isInsideRepository() -> - renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo) - else -> - lifecycleScope.launch(dispatcherProvider.io()) { - moveFile(oldCategory.file, newCategory) - - // associate the new category with the last category's timestamp in - // history - val preference = - getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) - val timestamp = preference.getString(oldCategory.file.absolutePath.base64()) - if (timestamp != null) { - preference.edit { - remove(oldCategory.file.absolutePath.base64()) - putString(newCategory.absolutePath.base64(), timestamp) - } - } - - withContext(dispatcherProvider.main()) { - commitChange( - resources.getString( - R.string.git_commit_move_text, - oldCategory.name, - newCategory.name, - ) - ) - } - } - } - } - .setNegativeButton(R.string.dialog_skip, null) - .create() - - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - dialog.show() - } - - fun renameCategory(categories: List<PasswordItem>) { - for (oldCategory in categories) { - renameCategory(oldCategory) - } - } - - /** - * Refreshes the password list by re-executing the last navigation or search action, preserving - * the navigation stack and scroll position. If the current directory no longer exists, navigation - * is reset to the repository root. If the optional [target] argument is provided, it will be - * entered if it is a directory or scrolled into view if it is a file (both inside the current - * directory). - */ - fun refreshPasswordList(target: File? = null) { - val plist = getPasswordFragment() - if (target?.isDirectory == true && model.currentDir.value.contains(target)) { - plist?.navigateTo(target) - } else if (target?.isFile == true && model.currentDir.value.contains(target)) { - // Creating new passwords is handled by an activity, so we will refresh in onStart. - plist?.scrollToOnNextRefresh(target) - } else if (model.currentDir.value.isDirectory) { - model.forceRefresh() - } else { - model.reset() - supportActionBar?.setDisplayHomeAsUpEnabled(false) - } - } - - private val currentDir: File - get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory() - - private suspend fun moveFile(source: File, destinationFile: File) { - val sourceDestinationMap = - if (source.isDirectory) { - destinationFile.mkdirs() - // Recursively list all files (not directories) below `source`, then - // obtain the corresponding target file by resolving the relative path - // starting at the destination folder. - source.listFilesRecursively().associateWith { - destinationFile.resolve(it.relativeTo(source)) - } - } else { - mapOf(source to destinationFile) - } - if (!source.renameTo(destinationFile)) { - logcat(ERROR) { "Something went wrong while moving $source to $destinationFile." } - withContext(dispatcherProvider.main()) { - MaterialAlertDialogBuilder(this@PasswordStore) - .setTitle(R.string.password_move_error_title) - .setMessage(getString(R.string.password_move_error_message, source, destinationFile)) - .setCancelable(true) - .setPositiveButton(android.R.string.ok, null) - .show() - } - } else { - AutofillMatcher.updateMatches(this, sourceDestinationMap) - } - } - - fun matchPasswordWithApp(item: PasswordItem) { - val path = - item.file.absolutePath - .replace(PasswordRepository.getRepositoryDirectory().toString() + "/", "") - .replace(".gpg", "") - val data = Intent() - data.putExtra("path", path) - setResult(RESULT_OK, data) - finish() - } - - companion object { - - const val REQUEST_ARG_PATH = "PATH" - - private fun isPrintable(c: Char): Boolean { - val block = UnicodeBlock.of(c) - return (!Character.isISOControl(c) && block != null && block !== UnicodeBlock.SPECIALS) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt deleted file mode 100644 index d276c552..00000000 --- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -@file:Suppress("BlockingMethodInNonBlockingContext") - -package app.passwordstore.ui.pgp - -import android.os.Bundle -import androidx.activity.result.contract.ActivityResultContracts.OpenDocument -import androidx.appcompat.app.AppCompatActivity -import app.passwordstore.R -import app.passwordstore.crypto.KeyUtils.tryGetId -import app.passwordstore.crypto.PGPKey -import app.passwordstore.crypto.PGPKeyManager -import app.passwordstore.crypto.errors.KeyAlreadyExistsException -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import kotlinx.coroutines.runBlocking - -@AndroidEntryPoint -class PGPKeyImportActivity : AppCompatActivity() { - - /** - * A [ByteArray] containing the contents of the previously selected file. This is necessary for - * the replacement case where we do not want users to have to pick the file again. - */ - private var lastBytes: ByteArray? = null - @Inject lateinit var keyManager: PGPKeyManager - - private val pgpKeyImportAction = - registerForActivityResult(OpenDocument()) { uri -> - runCatching { - if (uri == null) { - return@runCatching null - } - val keyInputStream = - contentResolver.openInputStream(uri) - ?: throw IllegalStateException("Failed to open selected file") - val bytes = keyInputStream.use { `is` -> `is`.readBytes() } - importKey(bytes, false) - } - .run(::handleImportResult) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - pgpKeyImportAction.launch(arrayOf("*/*")) - } - - override fun onDestroy() { - lastBytes = null - super.onDestroy() - } - - private fun importKey(bytes: ByteArray, replace: Boolean): PGPKey? { - lastBytes = bytes - val (key, error) = runBlocking { keyManager.addKey(PGPKey(bytes), replace = replace) } - if (replace) { - lastBytes = null - } - if (error != null) throw error - return key - } - - private fun handleImportResult(result: Result<PGPKey?, Throwable>) { - if (result.isOk) { - val key = result.value - if (key == null) { - setResult(RESULT_CANCELED) - finish() - // This return convinces Kotlin that the control flow for `key == null` definitely - // terminates here and allows for a smart cast below. - return - } - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.pgp_key_import_succeeded)) - .setMessage(getString(R.string.pgp_key_import_succeeded_message, tryGetId(key))) - .setPositiveButton(android.R.string.ok) { _, _ -> - setResult(RESULT_OK) - finish() - } - .setCancelable(false) - .show() - } else { - if (result.error is KeyAlreadyExistsException && lastBytes != null) { - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.pgp_key_import_failed)) - .setMessage(getString(R.string.pgp_key_import_failed_replace_message)) - .setPositiveButton(R.string.dialog_yes) { _, _ -> - handleImportResult(runCatching { importKey(lastBytes!!, replace = true) }) - } - .setNegativeButton(R.string.dialog_no) { _, _ -> finish() } - .setCancelable(false) - .show() - } else { - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.pgp_key_import_failed)) - .setMessage(result.error.message) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .setCancelable(false) - .show() - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt deleted file mode 100644 index 312f5fb0..00000000 --- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.pgp - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -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.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 androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import app.passwordstore.R -import app.passwordstore.crypto.PGPIdentifier -import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.ui.compose.theme.SpacingLarge -import app.passwordstore.util.extensions.conditional -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList - -@Composable -fun KeyList( - identifiers: ImmutableList<PGPIdentifier>, - onItemClick: (identifier: PGPIdentifier) -> Unit, - modifier: Modifier = Modifier, - onKeySelected: ((identifier: PGPIdentifier) -> Unit)? = null, -) { - if (identifiers.isEmpty()) { - Column( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - painter = painterResource(id = R.drawable.ic_launcher_foreground), - contentDescription = "Password Store logo", - ) - Text(stringResource(R.string.pgp_key_manager_no_keys_guidance)) - } - } else { - LazyColumn(modifier = modifier) { - items(identifiers) { identifier -> - KeyItem(identifier = identifier, onItemClick = onItemClick, onKeySelected = onKeySelected) - } - } - } -} - -@Composable -private fun KeyItem( - identifier: PGPIdentifier, - onItemClick: (identifier: PGPIdentifier) -> Unit, - modifier: Modifier = Modifier, - onKeySelected: ((identifier: PGPIdentifier) -> Unit)? = null, -) { - var isDeleting by remember { mutableStateOf(false) } - DeleteConfirmationDialog( - isDeleting = isDeleting, - onDismiss = { isDeleting = false }, - onConfirm = { - onItemClick(identifier) - isDeleting = false - }, - ) - val label = - when (identifier) { - is PGPIdentifier.KeyId -> identifier.id.toString() - is PGPIdentifier.UserId -> identifier.email - } - Row( - modifier = - modifier.padding(SpacingLarge).fillMaxWidth().conditional(onKeySelected != null) { - clickable { onKeySelected?.invoke(identifier) } - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - modifier = Modifier.weight(1f), - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - if (onKeySelected == null) { - IconButton(onClick = { isDeleting = true }) { - Icon( - painter = painterResource(R.drawable.ic_delete_24dp), - stringResource(id = R.string.delete), - ) - } - } - } -} - -@Suppress("NOTHING_TO_INLINE") -@Composable -private inline fun DeleteConfirmationDialog( - isDeleting: Boolean, - noinline onDismiss: () -> Unit, - noinline onConfirm: () -> Unit, -) { - if (isDeleting) { - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text(text = stringResource(R.string.pgp_key_manager_delete_confirmation_dialog_title)) - }, - confirmButton = { - TextButton(onClick = onConfirm) { Text(text = stringResource(R.string.dialog_yes)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(text = stringResource(R.string.dialog_no)) } - }, - ) - } -} - -@Preview -@Composable -private fun KeyListPreview() { - APSTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { - KeyList( - identifiers = - listOfNotNull( - PGPIdentifier.fromString("ultramicroscopicsilicovolcanoconiosis@example.com"), - PGPIdentifier.fromString("0xB950AE2813841585"), - ) - .toPersistentList(), - onItemClick = {}, - ) - } - } -} - -@Preview -@Composable -private fun EmptyKeyListPreview() { - APSTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { - KeyList(identifiers = persistentListOf(), onItemClick = {}) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt deleted file mode 100644 index 77b9aaf1..00000000 --- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.pgp - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.activity.viewModels -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -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.APSAppBar -import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.util.viewmodel.PGPKeyListViewModel -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class PGPKeyListActivity : ComponentActivity() { - - private val viewModel: PGPKeyListViewModel by viewModels() - private val keyImportAction = - registerForActivityResult(StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - viewModel.updateKeySet() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val isSelecting = intent.extras?.getBoolean(EXTRA_KEY_SELECTION) ?: false - setContent { - APSTheme { - Scaffold( - topBar = { - APSAppBar( - title = stringResource(R.string.activity_label_pgp_key_manager), - navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp), - onNavigationIconClick = { finish() }, - backgroundColor = MaterialTheme.colorScheme.surface, - ) - }, - floatingActionButton = { - FloatingActionButton( - onClick = { keyImportAction.launch(Intent(this, PGPKeyImportActivity::class.java)) } - ) { - Icon( - painter = painterResource(R.drawable.ic_add_48dp), - stringResource(R.string.pref_import_pgp_key_title), - ) - } - }, - ) { paddingValues -> - KeyList( - identifiers = viewModel.keys, - onItemClick = viewModel::deleteKey, - modifier = Modifier.padding(paddingValues), - onKeySelected = - if (isSelecting) { - { identifier -> - val result = Intent() - result.putExtra(EXTRA_SELECTED_KEY, identifier.toString()) - setResult(RESULT_OK, result) - finish() - } - } else null, - ) - } - } - } - } - - companion object { - const val EXTRA_SELECTED_KEY = "SELECTED_KEY" - private const val EXTRA_KEY_SELECTION = "KEY_SELECTION_MODE" - - fun newSelectionActivity(context: Context): Intent { - val intent = Intent(context, PGPKeyListActivity::class.java) - intent.putExtra(EXTRA_KEY_SELECTION, true) - return intent - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt b/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt deleted file mode 100644 index cb5fbf40..00000000 --- a/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.proxy - -import android.content.SharedPreferences -import android.net.InetAddresses -import android.os.Build -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Patterns -import android.view.MenuItem -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.core.os.postDelayed -import androidx.core.widget.doOnTextChanged -import app.passwordstore.R -import app.passwordstore.databinding.ActivityProxySelectorBinding -import app.passwordstore.injection.prefs.ProxyPreferences -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.proxy.ProxyUtils -import app.passwordstore.util.settings.GitSettings -import app.passwordstore.util.settings.PreferenceKeys -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint -class ProxySelectorActivity : AppCompatActivity() { - - @Inject lateinit var gitSettings: GitSettings - @ProxyPreferences @Inject lateinit var proxyPrefs: SharedPreferences - @Inject lateinit var proxyUtils: ProxyUtils - private val binding by viewBinding(ActivityProxySelectorBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - with(binding) { - proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST)) - proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME)) - proxyPrefs - .getInt(PreferenceKeys.PROXY_PORT, -1) - .takeIf { it != -1 } - ?.let { proxyPort.setText("$it") } - proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD)) - save.setOnClickListener { saveSettings() } - proxyHost.doOnTextChanged { text, _, _, _ -> - if (text != null) { - proxyHost.error = - if (isNumericAddress(text) || text.matches(WEB_ADDRESS_REGEX)) { - null - } else { - getString(R.string.invalid_proxy_url) - } - } - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - } - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun isNumericAddress(text: CharSequence): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - InetAddresses.isNumericAddress(text as String) - } else { - @Suppress("DEPRECATION") Patterns.IP_ADDRESS.matcher(text).matches() - } - } - - private fun saveSettings() { - proxyPrefs.edit { - binding.proxyHost.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - .let { gitSettings.proxyHost = it } - binding.proxyUser.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - .let { gitSettings.proxyUsername = it } - binding.proxyPort.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - ?.let { gitSettings.proxyPort = it.toInt() } - binding.proxyPassword.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - .let { gitSettings.proxyPassword = it } - } - proxyUtils.setDefaultProxy() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } - - private companion object { - private val WEB_ADDRESS_REGEX = Patterns.WEB_URL.toRegex() - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt deleted file mode 100644 index afb2a691..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import android.annotation.SuppressLint -import android.content.Intent -import android.provider.Settings -import androidx.appcompat.widget.AppCompatTextView -import androidx.core.net.toUri -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import app.passwordstore.BuildConfig -import app.passwordstore.R -import app.passwordstore.util.extensions.autofillManager -import app.passwordstore.util.settings.PreferenceKeys -import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel -import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.editText -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.switch -import de.Maxr1998.modernpreferences.preferences.SwitchPreference - -class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider { - - private val isAutofillServiceEnabled: Boolean - get() { - return activity.autofillManager?.hasEnabledAutofillServices() == true - } - - private fun showAutofillDialog(pref: SwitchPreference) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - pref.checked = isAutofillServiceEnabled - } - else -> {} - } - } - MaterialAlertDialogBuilder(activity).run { - setTitle(R.string.pref_autofill_enable_title) - @SuppressLint("InflateParams") - val layout = activity.layoutInflater.inflate(R.layout.oreo_autofill_instructions, null) - val supportedBrowsersTextView = layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers) - supportedBrowsersTextView.text = - getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(separator = "\n") { - val appLabel = it.first - val supportDescription = - when (it.second) { - BrowserAutofillSupportLevel.None -> - activity.getString(R.string.oreo_autofill_no_support) - BrowserAutofillSupportLevel.FlakyFill -> - activity.getString(R.string.oreo_autofill_flaky_fill_support) - BrowserAutofillSupportLevel.PasswordFill -> - activity.getString(R.string.oreo_autofill_password_fill_support) - BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility -> - activity.getString( - R.string.oreo_autofill_password_fill_and_conditional_save_support - ) - BrowserAutofillSupportLevel.GeneralFill -> - activity.getString(R.string.oreo_autofill_general_fill_support) - BrowserAutofillSupportLevel.GeneralFillAndSave -> - activity.getString(R.string.oreo_autofill_general_fill_and_save_support) - } - "$appLabel: $supportDescription" - } - setView(layout) - setPositiveButton(R.string.dialog_ok) { _, _ -> - val intent = - Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { - data = "package:${BuildConfig.APPLICATION_ID}".toUri() - } - activity.startActivity(intent) - } - setNegativeButton(R.string.dialog_cancel, null) - setOnDismissListener { pref.checked = isAutofillServiceEnabled } - activity.lifecycle.addObserver(observer) - show() - } - } - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - switch(PreferenceKeys.AUTOFILL_ENABLE) { - titleRes = R.string.pref_autofill_enable_title - defaultValue = isAutofillServiceEnabled - onClick { - if (isAutofillServiceEnabled) { - activity.autofillManager?.disableAutofillServices() - } else { - showAutofillDialog(this) - } - false - } - } - editText(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) { - dependency = PreferenceKeys.AUTOFILL_ENABLE - titleRes = R.string.preference_default_username_title - summaryProvider = { activity.getString(R.string.preference_default_username_summary) } - } - editText(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) { - dependency = PreferenceKeys.AUTOFILL_ENABLE - titleRes = R.string.preference_custom_public_suffixes_title - summaryProvider = { activity.getString(R.string.preference_custom_public_suffixes_summary) } - textInputHintRes = R.string.preference_custom_public_suffixes_hint - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt deleted file mode 100644 index e3594269..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import android.content.pm.ShortcutManager -import android.os.Build -import androidx.core.content.edit -import androidx.core.content.getSystemService -import androidx.fragment.app.FragmentActivity -import app.passwordstore.R -import app.passwordstore.util.auth.BiometricAuthenticator -import app.passwordstore.util.auth.BiometricAuthenticator.Result -import app.passwordstore.util.extensions.sharedPrefs -import app.passwordstore.util.settings.DirectoryStructure -import app.passwordstore.util.settings.PreferenceKeys -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.singleChoice -import de.Maxr1998.modernpreferences.helpers.switch -import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem - -class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider { - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - val themeValues = activity.resources.getStringArray(R.array.app_theme_values) - val themeOptions = activity.resources.getStringArray(R.array.app_theme_options) - val themeItems = - themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) } - singleChoice(PreferenceKeys.APP_THEME, themeItems) { - initialSelection = activity.resources.getString(R.string.app_theme_def) - titleRes = R.string.pref_app_theme_title - } - - val values = activity.resources.getStringArray(R.array.directory_structure_values) - val titles = activity.resources.getStringArray(R.array.directory_structure_entries) - val items = values.zip(titles).map { SelectionItem(it.first, it.second, null) } - singleChoice(PreferenceKeys.DIRECTORY_STRUCTURE, items) { - initialSelection = DirectoryStructure.DEFAULT.value - titleRes = R.string.pref_directory_structure - } - - val sortValues = activity.resources.getStringArray(R.array.sort_order_values) - val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries) - val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) } - singleChoice(PreferenceKeys.SORT_ORDER, sortItems) { - initialSelection = sortValues[0] - titleRes = R.string.pref_sort_order_title - } - - switch(PreferenceKeys.DISABLE_SYNC_ACTION) { - titleRes = R.string.pref_disable_sync_on_pull_title - summaryRes = R.string.pref_disable_sync_on_pull_summary - defaultValue = false - } - - switch(PreferenceKeys.FILTER_RECURSIVELY) { - titleRes = R.string.pref_recursive_filter_title - summaryRes = R.string.pref_recursive_filter_summary - defaultValue = true - } - - switch(PreferenceKeys.SEARCH_ON_START) { - titleRes = R.string.pref_search_on_start_title - summaryRes = R.string.pref_search_on_start_summary - defaultValue = false - } - - switch(PreferenceKeys.SHOW_HIDDEN_CONTENTS) { - titleRes = R.string.pref_show_hidden_title - summaryRes = R.string.pref_show_hidden_summary - defaultValue = false - } - - // See https://github.com/android-password-store/Android-Password-Store/issues/2802 - val disableAuth = Build.VERSION.SDK_INT == Build.VERSION_CODES.UPSIDE_DOWN_CAKE - val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity) - switch(PreferenceKeys.BIOMETRIC_AUTH_2) { - titleRes = R.string.pref_biometric_auth_title - defaultValue = false - enabled = !disableAuth - summaryRes = - if (disableAuth) R.string.pref_biometric_auth_summary_disabled_platform - else if (canAuthenticate) R.string.pref_biometric_auth_summary - else R.string.pref_biometric_auth_summary_error - onClick { - enabled = false - val isChecked = checked - activity.sharedPrefs.edit { - BiometricAuthenticator.authenticate(activity) { result -> - when (result) { - is Result.Success -> { - // Apply the changes - putBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, checked) - enabled = true - } - is Result.Retry -> {} - else -> { - // If any error occurs, revert back to the previous - // state. This - // catch-all clause includes the cancellation case. - putBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, !checked) - checked = !isChecked - enabled = true - } - } - } - } - activity.getSystemService<ShortcutManager>()?.apply { - removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) - } - false - } - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt new file mode 100644 index 00000000..d008eea3 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt @@ -0,0 +1,329 @@ +/* + * 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.pref_edit_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() + // BreakOutOfDetached names the pushed branch conflicting-<branch>-<millis>: the newest one. + val pushed = + PasswordRepository.repository + ?.refDatabase + ?.getRefsByPrefix("refs/heads/conflicting-$aborted-") + ?.maxOfOrNull { it.name.removePrefix("refs/heads/") } ?: "conflicting-$aborted-..." + SetupDialog( + title = stringResource(R.string.git_abort_and_push_title), + text = stringResource(R.string.git_break_out_of_detached_success, aborted, pushed), + confirm = stringResource(R.string.dialog_ok), + onConfirm = onClose, + onDismiss = onClose, + ) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt new file mode 100644 index 00000000..8882ed87 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt @@ -0,0 +1,82 @@ +/* + * 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 + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt new file mode 100644 index 00000000..276c9c11 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt @@ -0,0 +1,118 @@ +/* + * 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) +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt deleted file mode 100644 index 9a5d1e3b..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.activity.result.contract.ActivityResultContracts -import androidx.documentfile.provider.DocumentFile -import androidx.fragment.app.FragmentActivity -import app.passwordstore.BuildConfig -import app.passwordstore.R -import app.passwordstore.util.services.PasswordExportService -import app.passwordstore.util.settings.PreferenceKeys -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.pref -import de.Maxr1998.modernpreferences.helpers.switch - -class MiscSettings(activity: FragmentActivity) : SettingsProvider { - - private val storeExportAction = - activity.registerForActivityResult( - object : ActivityResultContracts.OpenDocumentTree() { - override fun createIntent(context: Context, input: Uri?): Intent { - return 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 - } - } - } - ) { uri: Uri? -> - if (uri == null) return@registerForActivityResult - val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri) - - if (targetDirectory != null) { - val service = - Intent(activity.applicationContext, PasswordExportService::class.java).apply { - action = PasswordExportService.ACTION_EXPORT_PASSWORD - putExtra("uri", uri) - } - - activity.startForegroundService(service) - } - } - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - pref(PreferenceKeys.EXPORT_PASSWORDS) { - titleRes = R.string.prefs_export_passwords_title - summaryRes = R.string.prefs_export_passwords_summary - onClick { - storeExportAction.launch(null) - true - } - } - switch(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY) { - defaultValue = false - titleRes = R.string.pref_clear_clipboard_title - summaryRes = R.string.pref_clear_clipboard_summary - } - switch(PreferenceKeys.ENABLE_DEBUG_LOGGING) { - defaultValue = false - titleRes = R.string.pref_debug_logging_title - summaryRes = R.string.pref_debug_logging_summary - visible = !BuildConfig.DEBUG - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt deleted file mode 100644 index d2e63779..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import androidx.core.content.edit -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.crypto.PGPPassphraseCache -import app.passwordstore.ui.pgp.PGPKeyListActivity -import app.passwordstore.util.auth.BiometricAuthenticator -import app.passwordstore.util.extensions.launchActivity -import app.passwordstore.util.extensions.sharedPrefs -import app.passwordstore.util.features.Feature -import app.passwordstore.util.settings.PreferenceKeys -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.pref -import de.Maxr1998.modernpreferences.helpers.switch -import kotlinx.coroutines.launch - -class PGPSettings( - private val activity: FragmentActivity, - private val passphraseCache: PGPPassphraseCache, -) : SettingsProvider { - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - pref("_") { - titleRes = R.string.pref_pgp_key_manager_title - persistent = false - onClick { - activity.launchActivity(PGPKeyListActivity::class.java) - false - } - } - switch(PreferenceKeys.ASCII_ARMOR) { - titleRes = R.string.pref_pgp_ascii_armor_title - persistent = true - } - switch(Feature.EnablePGPPassphraseCache.configKey) { - enabled = BiometricAuthenticator.canAuthenticate(activity) - titleRes = R.string.pref_passphrase_cache_title - summaryRes = R.string.pref_passphrase_cache_summary - defaultValue = false - onClick { - if (BiometricAuthenticator.canAuthenticate(activity)) { - val promptTitle = - if (checked) R.string.pref_passphrase_cache_authenticate_enable - else R.string.pref_passphrase_cache_authenticate_disable - - BiometricAuthenticator.authenticate(activity, promptTitle) { result -> - when (result) { - is BiometricAuthenticator.Result.Success -> { - /* Any successful change of this setting clears the passphrase - * cache for safety */ - activity.lifecycleScope.launch { - passphraseCache.clearAllCachedPassphrases(activity) - } - activity.sharedPrefs.edit { - putBoolean(Feature.EnablePGPPassphraseCache.configKey, checked) - if (!checked) remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) - } - } - is BiometricAuthenticator.Result.Retry -> {} - else -> { - /* revert back to previous state in case of error or cancellation */ - checked = !checked - activity.sharedPrefs.edit { - putBoolean(Feature.EnablePGPPassphraseCache.configKey, checked) - } - } - } - } - } else { - /* we may get here if device lock has been disabled while PGP settings - * screen was left open */ - checked = false - enabled = false - activity.sharedPrefs.edit { - putBoolean(Feature.EnablePGPPassphraseCache.configKey, false) - } - } - false - } - } - switch(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) { - dependency = Feature.EnablePGPPassphraseCache.configKey - titleRes = R.string.pref_passphrase_cache_auto_clear_title - summaryRes = R.string.pref_passphrase_cache_auto_clear_summary - defaultValue = true - /* Clear the cache once when unchecking; this is to prevent a malicious user (someone - * knowing the screen-lock pin, but not knowing the PGP passphrase) from bypassing cache - * clearing via the settings. Disabling it requires authentication, and cache clearing - * stays enabled in case of any authentication failure. */ - onClick { - if (!checked) { - if ( - BiometricAuthenticator.canAuthenticate(activity) && - activity.sharedPrefs.getBoolean(Feature.EnablePGPPassphraseCache.configKey, false) - ) { - BiometricAuthenticator.authenticate( - activity, - R.string.pref_passphrase_cache_auto_clear_authenticate_disable, - ) { result -> - when (result) { - is BiometricAuthenticator.Result.Success -> { - activity.sharedPrefs.edit { - putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, false) - } - activity.lifecycleScope.launch { - passphraseCache.clearAllCachedPassphrases(activity) - } - } - is BiometricAuthenticator.Result.Retry -> {} - else -> { - activity.sharedPrefs.edit { remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) } - checked = true - } - } - } - } else { - activity.sharedPrefs.edit { remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) } - checked = true - enabled = false - } - } - false - } - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt deleted file mode 100644 index 9fe4cd42..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import android.text.InputType -import androidx.fragment.app.FragmentActivity -import app.passwordstore.R -import app.passwordstore.util.settings.PreferenceKeys -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.editText -import de.Maxr1998.modernpreferences.helpers.singleChoice -import de.Maxr1998.modernpreferences.helpers.switch -import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem - -class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider { - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - val values = activity.resources.getStringArray(R.array.pwgen_provider_values) - val labels = activity.resources.getStringArray(R.array.pwgen_provider_labels) - val items = values.zip(labels).map { SelectionItem(it.first, it.second, null) } - singleChoice(PreferenceKeys.PREF_KEY_PWGEN_TYPE, items) { - initialSelection = "diceware" - titleRes = R.string.pref_password_generator_type_title - } - editText(PreferenceKeys.GENERAL_SHOW_TIME) { - titleRes = R.string.pref_clipboard_timeout_title - summaryProvider = { timeout -> - activity.getString(R.string.pref_clipboard_timeout_summary, timeout ?: "45") - } - textInputType = InputType.TYPE_CLASS_NUMBER - } - switch(PreferenceKeys.SHOW_PASSWORD) { - titleRes = R.string.show_password_pref_title - summaryRes = R.string.show_password_pref_summary - defaultValue = true - } - switch(PreferenceKeys.COPY_ON_DECRYPT) { - titleRes = R.string.pref_copy_title - summaryRes = R.string.pref_copy_summary - defaultValue = false - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt b/app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt new file mode 100644 index 00000000..cfaa741d --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt @@ -0,0 +1,217 @@ +/* + * 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)) } + }, + ) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt new file mode 100644 index 00000000..b7116bb7 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt @@ -0,0 +1,197 @@ +/* + * 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, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt new file mode 100644 index 00000000..fb2bfe93 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt @@ -0,0 +1,62 @@ +/* + * 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 plain host name: no scheme, port or path, which the port field holds. */ +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 || + (!text.contains(':') && !text.contains('/') && Patterns.DOMAIN_NAME.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()) + + /** Invalid, or empty while a host is set: a proxy without a port is not used at all. */ + val portError: Boolean + get() = proxyPort(port).let { it == null || (it == -1 && host.isNotBlank()) } + + /** Stores the proxy and applies it at once; false, storing nothing, when a field is invalid. */ + fun save(): Boolean { + if (hostError || portError) 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 + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt deleted file mode 100644 index fac95949..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import android.content.Intent -import android.content.SharedPreferences -import android.content.pm.ShortcutManager -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.core.content.edit -import androidx.core.content.getSystemService -import androidx.fragment.app.FragmentActivity -import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.injection.prefs.GitPreferences -import app.passwordstore.ui.git.config.GitConfigActivity -import app.passwordstore.ui.git.config.GitServerConfigActivity -import app.passwordstore.ui.proxy.ProxySelectorActivity -import app.passwordstore.ui.sshkeygen.ShowSshKeyFragment -import app.passwordstore.ui.sshkeygen.SshKeyGenActivity -import app.passwordstore.ui.sshkeygen.SshKeyImportActivity -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.extensions.launchActivity -import app.passwordstore.util.extensions.sharedPrefs -import app.passwordstore.util.extensions.snackbar -import app.passwordstore.util.extensions.unsafeLazy -import app.passwordstore.util.git.sshj.SshKey -import app.passwordstore.util.settings.GitSettings -import app.passwordstore.util.settings.PreferenceKeys -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent -import de.Maxr1998.modernpreferences.Preference -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.pref -import de.Maxr1998.modernpreferences.helpers.switch - -class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider { - - private val generateSshKey = - activity.registerForActivityResult(StartActivityForResult()) { - showSshKeyPref?.visible = SshKey.canShowSshPublicKey - } - - private val hiltEntryPoint by unsafeLazy { - EntryPointAccessors.fromApplication( - activity.applicationContext, - RepositorySettingsEntryPoint::class.java, - ) - } - - private var showSshKeyPref: Preference? = null - - override fun provideSettings(builder: PreferenceScreen.Builder) { - val encryptedPreferences = hiltEntryPoint.encryptedPreferences() - val gitSettings = hiltEntryPoint.gitSettings() - - builder.apply { - switch(PreferenceKeys.REBASE_ON_PULL) { - titleRes = R.string.pref_rebase_on_pull_title - summaryRes = R.string.pref_rebase_on_pull_summary - summaryOnRes = R.string.pref_rebase_on_pull_summary_on - defaultValue = true - } - pref(PreferenceKeys.GIT_SERVER_INFO) { - titleRes = R.string.pref_edit_git_server_settings - visible = PasswordRepository.isGitRepo() - onClick { - activity.launchActivity(GitServerConfigActivity::class.java) - true - } - } - pref(PreferenceKeys.PROXY_SETTINGS) { - titleRes = R.string.pref_edit_proxy_settings - visible = gitSettings.url?.startsWith("https") == true && PasswordRepository.isGitRepo() - onClick { - activity.launchActivity(ProxySelectorActivity::class.java) - true - } - } - pref(PreferenceKeys.GIT_CONFIG) { - titleRes = R.string.pref_edit_git_config - visible = PasswordRepository.isGitRepo() - onClick { - activity.launchActivity(GitConfigActivity::class.java) - true - } - } - pref(PreferenceKeys.SSH_KEY) { - titleRes = R.string.pref_import_ssh_key_title - visible = PasswordRepository.isGitRepo() - onClick { - activity.launchActivity(SshKeyImportActivity::class.java) - true - } - } - pref(PreferenceKeys.SSH_KEYGEN) { - titleRes = R.string.pref_ssh_keygen_title - onClick { - generateSshKey.launch(Intent(activity, SshKeyGenActivity::class.java)) - true - } - } - showSshKeyPref = - pref(PreferenceKeys.SSH_SEE_KEY) { - titleRes = R.string.pref_ssh_see_key_title - visible = PasswordRepository.isGitRepo() && SshKey.canShowSshPublicKey - onClick { - ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key") - true - } - } - pref(PreferenceKeys.CLEAR_SAVED_PASS) { - fun Preference.updatePref() { - val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) - val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD) - if (sshPass == null && httpsPass == null) { - visible = false - return - } - titleRes = - when { - httpsPass != null -> R.string.clear_saved_passphrase_https - else -> R.string.clear_saved_passphrase_ssh - } - visible = true - requestRebind() - } - onClick { - updatePref() - true - } - updatePref() - } - pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) { - titleRes = R.string.pref_title_openkeystore_clear_keyid - visible = - activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty() - ?: false - onClick { - activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) } - visible = false - true - } - } - pref(PreferenceKeys.GIT_DELETE_REPO) { - titleRes = R.string.pref_git_delete_repo_title - summaryRes = R.string.pref_git_delete_repo_summary - onClick { - val repoDir = PasswordRepository.getRepositoryDirectory() - MaterialAlertDialogBuilder(activity) - .setTitle(R.string.pref_dialog_delete_title) - .setMessage(activity.getString(R.string.dialog_delete_msg, repoDir)) - .setCancelable(false) - .setPositiveButton(R.string.dialog_delete) { dialogInterface, _ -> - runCatching { - PasswordRepository.closeRepository() - PasswordRepository.getRepositoryDirectory().let { dir -> - dir.deleteRecursively() - dir.mkdirs() - } - } - .onFailure { it.message?.let { message -> activity.snackbar(message = message) } } - - activity.getSystemService<ShortcutManager>()?.apply { - removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) - } - activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) } - dialogInterface.cancel() - activity.finish() - } - .setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ -> - run { dialogInterface.cancel() } - } - .show() - true - } - } - } - } - - @EntryPoint - @InstallIn(SingletonComponent::class) - interface RepositorySettingsEntryPoint { - fun gitSettings(): GitSettings - - @GitPreferences fun encryptedPreferences(): SharedPreferences - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt deleted file mode 100644 index 02addd84..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import android.os.Bundle -import android.view.MenuItem -import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AppCompatActivity -import androidx.core.os.BundleCompat -import app.passwordstore.R -import app.passwordstore.data.crypto.PGPPassphraseCache -import app.passwordstore.databinding.ActivityPreferenceRecyclerviewBinding -import app.passwordstore.util.extensions.viewBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import de.Maxr1998.modernpreferences.Preference -import de.Maxr1998.modernpreferences.PreferencesAdapter -import de.Maxr1998.modernpreferences.helpers.screen -import de.Maxr1998.modernpreferences.helpers.subScreen -import javax.inject.Inject - -@AndroidEntryPoint -class SettingsActivity : AppCompatActivity() { - - @Inject lateinit var passphraseCache: PGPPassphraseCache - private val miscSettings = MiscSettings(this) - private val autofillSettings = AutofillSettings(this) - private val passwordSettings = PasswordSettings(this) - private val repositorySettings = RepositorySettings(this) - private val generalSettings = GeneralSettings(this) - private lateinit var pgpSettings: PGPSettings - - private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate) - private val preferencesAdapter: PreferencesAdapter - get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - Preference.Config.dialogBuilderFactory = { context -> MaterialAlertDialogBuilder(context) } - pgpSettings = PGPSettings(this, passphraseCache) - val screen = - screen(this) { - subScreen { - collapseIcon = true - titleRes = R.string.pref_category_general_title - iconRes = R.drawable.app_settings_alt_24px - generalSettings.provideSettings(this) - } - subScreen { - collapseIcon = true - titleRes = R.string.pref_category_autofill_title - iconRes = R.drawable.ic_wysiwyg_24px - autofillSettings.provideSettings(this) - } - subScreen { - collapseIcon = true - titleRes = R.string.pref_category_passwords_title - iconRes = R.drawable.ic_password_24px - passwordSettings.provideSettings(this) - } - subScreen { - collapseIcon = true - titleRes = R.string.pref_category_repository_title - iconRes = R.drawable.ic_call_merge_24px - repositorySettings.provideSettings(this) - } - subScreen { - collapseIcon = true - titleRes = R.string.pref_category_misc_title - iconRes = R.drawable.ic_miscellaneous_services_24px - miscSettings.provideSettings(this) - } - subScreen { - collapseIcon = true - titleRes = R.string.pref_category_pgp_title - iconRes = R.drawable.ic_lock_open_24px - pgpSettings.provideSettings(this) - } - } - val backPressedCallback = - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - preferencesAdapter.goBack() - } - } - onBackPressedDispatcher.addCallback(backPressedCallback) - val adapter = PreferencesAdapter(screen) - adapter.onScreenChangeListener = - PreferencesAdapter.OnScreenChangeListener { subScreen, entering -> - backPressedCallback.isEnabled = entering - supportActionBar?.title = - if (!entering) { - getString(R.string.action_settings) - } else { - getString(subScreen.titleRes) - } - } - if (savedInstanceState != null) { - BundleCompat.getParcelable( - savedInstanceState, - "adapter", - PreferencesAdapter.SavedState::class.java, - ) - ?.let(adapter::loadSavedState) - } - binding.preferenceRecyclerView.adapter = adapter - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putParcelable("adapter", preferencesAdapter.getSavedState()) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> - if (!preferencesAdapter.goBack()) { - super.onOptionsItemSelected(item) - } else { - true - } - else -> super.onOptionsItemSelected(item) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt new file mode 100644 index 00000000..c3809b36 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt @@ -0,0 +1,304 @@ +/* + * 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() + // hasEnabledAutofillServices() still answers true right after this call. + autofillEnabled = false + } + 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 + } diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt new file mode 100644 index 00000000..ae4e277b --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt @@ -0,0 +1,87 @@ +/* + * 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) }) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt new file mode 100644 index 00000000..eca6770c --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt @@ -0,0 +1,79 @@ +/* + * 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() + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt deleted file mode 100644 index cacca80f..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import de.Maxr1998.modernpreferences.PreferenceScreen - -/** Used to generate a uniform API for all settings UI classes. */ -interface SettingsProvider { - - /** Inserts the settings items for the class into the given [builder]. */ - fun provideSettings(builder: PreferenceScreen.Builder) -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt new file mode 100644 index 00000000..7d45ad74 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt @@ -0,0 +1,349 @@ +/* + * 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, + ) + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt new file mode 100644 index 00000000..ef604f1f --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt @@ -0,0 +1,226 @@ +/* + * 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), + ) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt new file mode 100644 index 00000000..fd440996 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt @@ -0,0 +1,345 @@ +/* + * 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), + ) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt new file mode 100644 index 00000000..5995bf5b --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt @@ -0,0 +1,130 @@ +/* + * 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 + 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. Returns null, or why it failed as a string resource: then the clone must not + * start. + */ + suspend fun clearStore(root: File): Int? { + if (withContext(dispatcherProvider.io()) { cloneTarget(root) } == CloneTarget.Empty) return null + progress = R.string.delete_directory_progress_text + if (!withContext(dispatcherProvider.io()) { root.deleteWithoutFollowingLinks() }) + return R.string.git_clone_delete_failed + progress = R.string.git_clone_progress + return null + } + + /** 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() + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt new file mode 100644 index 00000000..11fd979c --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt @@ -0,0 +1,337 @@ +/* + * 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.annotation.Keep +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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +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. A route argument: kept from minification. */ +@Keep +@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 semantics of the FAB's content, label and icon alike: describe the FAB. + val label = stringResource(R.string.pref_import_pgp_key_title) + ExtendedFloatingActionButton( + text = { Text(label) }, + icon = { Icon(Icons.Filled.Add, null) }, + onClick = { if (!busy) onImport() }, + modifier = Modifier.semantics { contentDescription = label }, + ) + }, + ) { 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. Choosing the same key + // again finds the store already made for it (createdFor), commits the `.gpg-id` and moves on. + fun choose(row: KeyRow) { + if (viewModel.busy) return + viewModel.busy = true + activity.lifecycleScope.launch { + try { + val error = viewModel.createStore(root, row, activity.getString(R.string.store_not_empty)) + 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 = ::leave, + 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, + ) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt new file mode 100644 index 00000000..8f1b52ec --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt @@ -0,0 +1,255 @@ +/* + * 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.R +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.crypto.errors.KeyDowngradeException +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.list.deleteWithoutFollowingLinks +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] (at most [MAX_KEY_BYTES]) and imports it. */ + fun import(uri: Uri, context: Context) { + if (busy) return + busy = true + val resolver = context.applicationContext.contentResolver + val tooLarge = context.getString(R.string.pgp_key_import_too_large) + val downgrade = context.getString(R.string.pgp_key_import_downgrade) + viewModelScope.launch { + try { + val bytes = + withContext(dispatcherProvider.io()) { + resolver.openInputStream(uri)?.use { + readAtMost(it, MAX_KEY_BYTES) ?: throw IOException(tooLarge) + } + } + if (bytes == null) importResult = KeyImport.Failed(null) + else add(bytes, replace = false, downgrade) + } 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, also on failure or cancellation, unless the + * replace question keeps them. [downgrade] is the message for a public key over a secret one. + */ + private suspend fun add(bytes: ByteArray, replace: Boolean, downgrade: String? = null) { + var keep = false + try { + val result = keyManager.addKey(PGPKey(bytes), replace) + if (!replace && result.isErr && result.error is KeyAlreadyExistsException) { + // A question still open from an earlier pick is superseded: zero its bytes first. + dropPending() + pending = bytes + keep = true + 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 if (result.error is KeyDowngradeException) { + KeyImport.Failed(downgrade ?: result.error.message) + } else KeyImport.Failed(result.error.message) + if (result.isOk) updateKeySet() + } finally { + if (!keep) bytes.fill(0) + } + } + + /** + * 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; [occupied] is the + * reason given when [root] already holds a store, unless that store was made for [row] already. + */ + suspend fun createStore(root: File, row: KeyRow, occupied: String): String? = + withContext(dispatcherProvider.io()) { + if (root.isDirectory && cloneTarget(root) == CloneTarget.Occupied) { + // Made by an earlier attempt for this key whose commit a recreation cancelled: done. + if (createdFor(root, row.keyId)) return@withContext null + // A restored, already-cloned store must keep its .gpg-id. + return@withContext occupied + } + 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() } + // Removes what this attempt made, including a fresh .git, without following links. + root.deleteWithoutFollowingLinks() + return e.message.orEmpty() + } + + fun markDone() { + done = true + } + + override fun onCleared() { + dropPending() + } +} + +/** The largest key file imported: far above any real key, small enough to hold in memory. */ +private const val MAX_KEY_BYTES = 1024 * 1024 diff --git a/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt b/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt new file mode 100644 index 00000000..40dfae74 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt @@ -0,0 +1,99 @@ +/* + * 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 app.passwordstore.ui.settings.SettingsRoute +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) { + Ssh(SshKeyRoute), + SshGenerate(SshKeyGenRoute), + 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 = { if (onTop(entry)) nav.navigate(SettingsRoute) }, + ) + } + 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) }, + ) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt new file mode 100644 index 00000000..4e32505f --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt @@ -0,0 +1,142 @@ +/* + * 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.io.InputStream +import java.nio.channels.Channels +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.StandardOpenOption +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], read as pass reads it: a `#` starts a comment, + * and a trailing `!` (GnuPG's "this exact subkey") is dropped. Blank and unparsable lines are + * skipped. + */ +fun storeRecipients(text: String): List<PGPIdentifier> = + text + .lines() + .map { it.substringBefore('#').trim().removeSuffix("!") } + .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" } + +/** + * Whether [dir] holds a store already made for [key]: its `.gpg-id` is exactly what creating one + * for [key] writes. Blocking: call it off the main thread. + */ +fun createdFor(dir: File, key: PGPIdentifier.KeyId): Boolean = + readGpgId(dir) == gpgIdText(listOf(key)) + +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 path = File(dir, ".gpg-id").toPath() + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) return null + // Opened without following a link too: one swapped in after the check is refused. + val options = setOf(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS) + return try { + Channels.newInputStream(Files.newByteChannel(path, options)) + .use { readAtMost(it, MAX_GPG_ID_BYTES) } + ?.decodeToString() + } catch (_: IOException) { + null + } +} + +/** + * At most [limit] bytes of [input], or null when it holds more. Reads at most [limit] + 1 bytes, + * whatever the stream's size, and zeroes its buffer: the bytes may be a private key. + */ +fun readAtMost(input: InputStream, limit: Int): ByteArray? { + val buffer = ByteArray(limit + 1) + try { + var size = 0 + while (size < buffer.size) { + val read = input.read(buffer, size, buffer.size - size) + if (read < 0) break + size += read + } + return if (size > limit) null else buffer.copyOf(size) + } finally { + buffer.fill(0) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt new file mode 100644 index 00000000..f93fc9be --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt @@ -0,0 +1,379 @@ +/* + * 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, + ) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt new file mode 100644 index 00000000..bc3e9474 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt @@ -0,0 +1,137 @@ +/* + * 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 javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException +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: CancellationException) { + throw e + } catch (e: Exception) { + // Any failure of a provider or of sshj, not only I/O: an uncaught one would crash the app. + 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: CancellationException) { + throw e + } catch (e: Exception) { + // Keystore and provider failures come as many exception types; none may crash the app. + 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 + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt new file mode 100644 index 00000000..b4197604 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt @@ -0,0 +1,103 @@ +/* + * 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, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt deleted file mode 100644 index 3f04e35e..00000000 --- a/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.sshkeygen - -import android.app.Dialog -import android.content.Intent -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import app.passwordstore.R -import app.passwordstore.util.git.sshj.SshKey -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class ShowSshKeyFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val activity = requireActivity() - val publicKey = SshKey.sshPublicKey - return MaterialAlertDialogBuilder(requireActivity()).run { - setMessage(getString(R.string.ssh_keygen_message, publicKey)) - setTitle(R.string.your_public_key) - setNegativeButton(R.string.ssh_keygen_later) { _, _ -> - (activity as? SshKeyGenActivity)?.finish() - } - setPositiveButton(R.string.ssh_keygen_share) { _, _ -> - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, publicKey) - } - startActivity(Intent.createChooser(sendIntent, null)) - (activity as? SshKeyGenActivity)?.finish() - } - create() - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt deleted file mode 100644 index 618a4be0..00000000 --- a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.sshkeygen - -import android.content.SharedPreferences -import android.os.Bundle -import android.security.keystore.UserNotAuthenticatedException -import android.view.MenuItem -import android.view.View -import android.view.inputmethod.InputMethodManager -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.core.content.getSystemService -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.databinding.ActivitySshKeygenBinding -import app.passwordstore.injection.prefs.GitPreferences -import app.passwordstore.util.auth.BiometricAuthenticator -import app.passwordstore.util.auth.BiometricAuthenticator.Result -import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.keyguardManager -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.git.sshj.SshKey -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) { - Rsa({ requireAuthentication -> - SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) - }), - Ecdsa({ requireAuthentication -> - SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) - }), - Ed25519({ requireAuthentication -> - SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) - }), -} - -@AndroidEntryPoint -class SshKeyGenActivity : AppCompatActivity() { - - private var keyGenType = KeyGenType.Ecdsa - private val binding by viewBinding(ActivitySshKeygenBinding::inflate) - @GitPreferences @Inject lateinit var gitPrefs: SharedPreferences - @Inject lateinit var dispatcherProvider: DispatcherProvider - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - with(binding) { - generate.setOnClickListener { - if (SshKey.exists) { - MaterialAlertDialogBuilder(this@SshKeyGenActivity).run { - setTitle(R.string.ssh_keygen_existing_title) - setMessage(R.string.ssh_keygen_existing_message) - setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> - lifecycleScope.launch { generate() } - } - setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> - setResult(RESULT_CANCELED) - finish() - } - show() - } - } else { - lifecycleScope.launch { generate() } - } - } - keyTypeGroup.check(R.id.key_type_ecdsa) - keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa) - keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> - if (isChecked) { - keyGenType = - when (checkedId) { - R.id.key_type_ed25519 -> KeyGenType.Ed25519 - R.id.key_type_ecdsa -> KeyGenType.Ecdsa - R.id.key_type_rsa -> KeyGenType.Rsa - else -> throw IllegalStateException("Impossible key type selection") - } - keyTypeExplanation.setText( - when (keyGenType) { - KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519 - KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa - KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa - } - ) - } - } - keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure - keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private suspend fun generate() { - binding.generate.apply { - text = getString(R.string.ssh_key_gen_generating_progress) - isEnabled = false - } - binding.generate.text = getString(R.string.ssh_key_gen_generating_progress) - val result = runCatching { - withContext(dispatcherProvider.io()) { - val requireAuthentication = binding.keyRequireAuthentication.isChecked - if (requireAuthentication) { - val result = - withContext(dispatcherProvider.main()) { - suspendCoroutine { cont -> - BiometricAuthenticator.authenticate( - this@SshKeyGenActivity, - R.string.biometric_prompt_title_ssh_keygen, - ) { result -> - // Do not cancel on failed attempts as these are handled by the - // authenticator UI. - if (result !is Result.Retry) cont.resume(result) - } - } - } - if (result !is Result.Success) - throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure)) - } - keyGenType.generateKey(requireAuthentication) - } - } - gitPrefs.edit { remove("ssh_key_local_passphrase") } - binding.generate.apply { - text = getString(R.string.ssh_keygen_generate) - isEnabled = true - } - result.fold( - success = { ShowSshKeyFragment().show(supportFragmentManager, "public_key") }, - failure = { e -> - e.printStackTrace() - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.error_generate_ssh_key)) - .setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message) - .setPositiveButton(getString(R.string.dialog_ok)) { _, _ -> - setResult(RESULT_OK) - finish() - } - .show() - }, - ) - hideKeyboard() - } - - private fun hideKeyboard() { - val imm = getSystemService<InputMethodManager>() ?: return - var view = currentFocus - if (view == null) { - view = View(this) - } - imm.hideSoftInputFromWindow(view.windowToken, 0) - } -} diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt deleted file mode 100644 index e4740741..00000000 --- a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.sshkeygen - -import android.net.Uri -import android.os.Bundle -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import app.passwordstore.R -import app.passwordstore.util.git.sshj.SshKey -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class SshKeyImportActivity : AppCompatActivity() { - - private val sshKeyImportAction = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> - if (uri == null) { - finish() - return@registerForActivityResult - } - runCatching { - SshKey.import(uri) - Toast.makeText( - this, - resources.getString(R.string.ssh_key_success_dialog_title), - Toast.LENGTH_LONG, - ) - .show() - setResult(RESULT_OK) - finish() - } - .onFailure { e -> - MaterialAlertDialogBuilder(this) - .setTitle(resources.getString(R.string.ssh_key_error_dialog_title)) - .setMessage(e.message) - .setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> finish() } - .show() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (SshKey.exists) { - MaterialAlertDialogBuilder(this).run { - setTitle(R.string.ssh_keygen_existing_title) - setMessage(R.string.ssh_keygen_existing_message) - setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> importSshKey() } - setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() } - setOnCancelListener { finish() } - show() - } - } else { - importSshKey() - } - } - - private fun importSshKey() { - sshKeyImportAction.launch(arrayOf("*/*")) - } -} diff --git a/app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt b/app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt deleted file mode 100644 index cd29f626..00000000 --- a/app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.util - -import androidx.recyclerview.widget.DefaultItemAnimator -import androidx.recyclerview.widget.RecyclerView - -class OnOffItemAnimator : DefaultItemAnimator() { - - var isEnabled: Boolean = true - set(value) { - // Defer update until no animation is running anymore. - isRunning { field = value } - } - - private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean { - dispatchAnimationFinished(viewHolder) - return false - } - - override fun animateAppearance( - viewHolder: RecyclerView.ViewHolder, - preLayoutInfo: ItemHolderInfo?, - postLayoutInfo: ItemHolderInfo, - ): Boolean { - return if (isEnabled) { - super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo) - } else { - dontAnimate(viewHolder) - } - } - - override fun animateChange( - oldHolder: RecyclerView.ViewHolder, - newHolder: RecyclerView.ViewHolder, - preInfo: ItemHolderInfo, - postInfo: ItemHolderInfo, - ): Boolean { - return if (isEnabled) { - super.animateChange(oldHolder, newHolder, preInfo, postInfo) - } else { - dontAnimate(oldHolder) - } - } - - override fun animateDisappearance( - viewHolder: RecyclerView.ViewHolder, - preLayoutInfo: ItemHolderInfo, - postLayoutInfo: ItemHolderInfo?, - ): Boolean { - return if (isEnabled) { - super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo) - } else { - dontAnimate(viewHolder) - } - } - - override fun animatePersistence( - viewHolder: RecyclerView.ViewHolder, - preInfo: ItemHolderInfo, - postInfo: ItemHolderInfo, - ): Boolean { - return if (isEnabled) { - super.animatePersistence(viewHolder, preInfo, postInfo) - } else { - dontAnimate(viewHolder) - } - } -} diff --git a/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt b/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt index 4aad4588..98f2229a 100644 --- a/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt +++ b/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt @@ -46,7 +46,7 @@ object BiometricAuthenticator { /** * An incorrect biometric was entered, but the prompt UI is offering the option to retry the - * operation. + * operation. The prompt is still open; a terminal result follows. */ data object Retry : Result() @@ -100,7 +100,8 @@ object BiometricAuthenticator { when (errorCode) { /** Keep in sync with [androidx.biometric.BiometricPrompt.AuthenticationError] */ BiometricPrompt.ERROR_HW_UNAVAILABLE -> callback(Result.HardwareUnavailableOrDisabled) - BiometricPrompt.ERROR_UNABLE_TO_PROCESS -> callback(Result.Retry) + // An error always closes the prompt, so none of them is a Retry. + BiometricPrompt.ERROR_UNABLE_TO_PROCESS, BiometricPrompt.ERROR_TIMEOUT -> callback( Result.Failure( diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt index ebe4bc7d..59fd5cca 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt @@ -20,7 +20,7 @@ import androidx.autofill.inline.UiVersions import androidx.autofill.inline.v1.InlineSuggestionUi import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.passwords.PasswordStore +import app.passwordstore.ui.main.LaunchActivity import java.io.File data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int) @@ -55,7 +55,7 @@ fun makeInlinePresentation( PendingIntent.getActivity( context, 0, - Intent(context, PasswordStore::class.java), + Intent(context, LaunchActivity::class.java), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { PendingIntent.FLAG_MUTABLE } else { diff --git a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt index a13484c6..6528334d 100644 --- a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt +++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt @@ -8,7 +8,6 @@ package app.passwordstore.util.extensions import android.app.KeyguardManager import android.content.ClipboardManager import android.content.Context -import android.content.Intent import android.content.SharedPreferences import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo @@ -17,12 +16,8 @@ import android.content.pm.PackageManager.ApplicationInfoFlags import android.content.pm.PackageManager.PackageInfoFlags import android.os.Build import android.util.Base64 -import android.util.TypedValue import android.view.View import android.view.autofill.AutofillManager -import androidx.activity.ComponentActivity -import androidx.compose.ui.Modifier -import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.fragment.app.FragmentActivity import app.passwordstore.BuildConfig @@ -56,13 +51,6 @@ val Context.keyguardManager: KeyguardManager val Context.sharedPrefs: SharedPreferences get() = getSharedPreferences("${BuildConfig.APPLICATION_ID}_preferences", 0) -/** Resolve [attr] from the [Context]'s theme */ -fun Context.resolveAttribute(attr: Int): Int { - val typedValue = TypedValue() - this.theme.resolveAttribute(attr, typedValue, true) - return typedValue.data -} - /** * Commit changes to the store from a [FragmentActivity] using a custom implementation of * [GitOperation] @@ -90,32 +78,17 @@ suspend fun FragmentActivity.commitChange(message: String): Result<Unit, Throwab .execute() } -/** Check if [permission] has been granted to the app. */ -fun FragmentActivity.isPermissionGranted(permission: String): Boolean { - return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED -} - -/** - * Show a [Snackbar] in a [FragmentActivity] and correctly anchor it to a - * [com.google.android.material.floatingactionbutton.FloatingActionButton] if one exists in the - * [view] - */ +/** Show a [Snackbar] in a [FragmentActivity]. */ fun FragmentActivity.snackbar( view: View = findViewById(android.R.id.content), message: String, length: Int = Snackbar.LENGTH_SHORT, ): Snackbar { val snackbar = Snackbar.make(view, message, length) - snackbar.anchorView = findViewById(R.id.fab) snackbar.show() return snackbar } -/** Launch an activity denoted by [clazz]. */ -fun <T : ComponentActivity> ComponentActivity.launchActivity(clazz: Class<T>) { - startActivity(Intent(this, clazz).setAction(Intent.ACTION_VIEW)) -} - /** Simplifies the common `getString(key, null) ?: defaultValue` case slightly */ fun SharedPreferences.getString(key: String): String? = getString(key, null) @@ -139,12 +112,3 @@ fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): Ap getApplicationInfo(packageName, flags) } } - -/** Allows conditionally applying the given [modifier] if [isEnabled] is `true`. */ -fun Modifier.conditional(isEnabled: Boolean, modifier: Modifier.() -> Modifier): Modifier { - return if (isEnabled) { - then(modifier()) - } else { - this - } -} diff --git a/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt deleted file mode 100644 index 2dd3af0c..00000000 --- a/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.util.extensions - -import androidx.annotation.IdRes -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.commit -import app.passwordstore.R - -/** Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally. */ -fun Fragment.isPermissionGranted(permission: String): Boolean { - return requireActivity().isPermissionGranted(permission) -} - -/** Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity] */ -fun Fragment.finish() = requireActivity().finish() - -/** - * Perform a [commit] on this [FragmentManager] with custom animations and adding the - * [destinationFragment] to the fragment backstack - */ -fun FragmentManager.performTransactionWithBackStack( - destinationFragment: Fragment, - @IdRes containerViewId: Int = android.R.id.content, -) { - commit { - addToBackStack(destinationFragment.tag) - setCustomAnimations( - R.animator.slide_in_left, - R.animator.slide_out_left, - R.animator.slide_in_right, - R.animator.slide_out_right, - ) - replace(containerViewId, destinationFragment) - } -} diff --git a/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt b/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt deleted file mode 100644 index 0050f2e5..00000000 --- a/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.util.extensions - -import android.view.LayoutInflater -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.viewbinding.ViewBinding -import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KProperty - -/** - * Imported from - * https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c - */ -class FragmentViewBindingDelegate<T : ViewBinding>( - val fragment: Fragment, - val viewBindingFactory: (View) -> T, -) : ReadOnlyProperty<Fragment, T> { - - private var binding: T? = null - - init { - fragment.lifecycle.addObserver( - object : DefaultLifecycleObserver { - override fun onCreate(owner: LifecycleOwner) { - fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner -> - viewLifecycleOwner.lifecycle.addObserver( - object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - binding = null - } - } - ) - } - } - } - ) - } - - override fun getValue(thisRef: Fragment, property: KProperty<*>): T { - val binding = binding - if (binding != null) { - return binding - } - - val lifecycle = fragment.viewLifecycleOwner.lifecycle - if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { - throw IllegalStateException( - "Should not attempt to get bindings when Fragment views are destroyed." - ) - } - - return viewBindingFactory(thisRef.requireView()).also { this.binding = it } - } -} - -fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) = - FragmentViewBindingDelegate(this, viewBindingFactory) - -inline fun <T : ViewBinding> AppCompatActivity.viewBinding( - crossinline bindingInflater: (LayoutInflater) -> T -) = unsafeLazy { bindingInflater.invoke(layoutInflater) } diff --git a/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt b/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt deleted file mode 100644 index 329db6d5..00000000 --- a/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.util.git - -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.util.extensions.asLog -import app.passwordstore.util.extensions.hash -import app.passwordstore.util.extensions.time -import app.passwordstore.util.extensions.unsafeLazy -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import logcat.LogPriority.ERROR -import logcat.logcat -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.revwalk.RevCommit - -private val TAG = GitLogModel::class.java.simpleName - -private fun commits(): Iterable<RevCommit> { - val repo = PasswordRepository.repository - if (repo == null) { - logcat(TAG, ERROR) { "Could not access git repository" } - return listOf() - } - return runCatching { Git(repo).log().call() } - .getOrElse { e -> - logcat(TAG, ERROR) { e.asLog("Failed to obtain git commits") } - listOf() - } -} - -/** - * Provides [GitCommit] s from a git-log of the password git repository. - * - * All commits are acquired on the first request to this object. - */ -class GitLogModel { - - // All commits are acquired here at once. Acquiring the commits in batches would not have been - // entirely sensible because the amount of computation required to obtain commit number n from - // the log includes the amount of computation required to obtain commit number n-1 from the log. - // This is because the commit graph is walked from HEAD to the last commit to obtain. - // Additionally, tests with 1000 commits in the log have not produced a significant delay in the - // user experience. - private val cache: MutableList<GitCommit> by unsafeLazy { - commits() - .map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) } - .toMutableList() - } - val size = cache.size - - fun get(index: Int): GitCommit? { - if (index >= size) - logcat(ERROR) { "Cannot get git commit with index $index. There are only $size." } - return cache.getOrNull(index) - } -} diff --git a/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt b/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt index d55d9970..4b4913d0 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt @@ -57,9 +57,8 @@ class BreakOutOfDetached(callingActivity: AppCompatActivity) : GitOperation(call .setMessage( callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded) ) - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> - callingActivity.finish() - } + // Only closes the dialog: the calling activity is the app's main screen. + .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok), null) .show() false } else { diff --git a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt index a3edac7d..25d99d6d 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt @@ -9,16 +9,13 @@ import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.sshkeygen.SshKeyGenActivity -import app.passwordstore.ui.sshkeygen.SshKeyImportActivity +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.CanceledBySystem import app.passwordstore.util.auth.BiometricAuthenticator.Result.CanceledByUser -import app.passwordstore.util.auth.BiometricAuthenticator.Result.Failure import app.passwordstore.util.auth.BiometricAuthenticator.Result.Retry import app.passwordstore.util.auth.BiometricAuthenticator.Result.Success import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.launchActivity import app.passwordstore.util.git.GitCommandExecutor import app.passwordstore.util.git.sshj.SshAuthMethod import app.passwordstore.util.git.sshj.SshKey @@ -27,8 +24,6 @@ import app.passwordstore.util.settings.AuthMode import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.EntryPoint import dagger.hilt.InstallIn @@ -37,9 +32,6 @@ import dagger.hilt.components.SingletonComponent import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat import net.schmizz.sshj.common.DisconnectReason import net.schmizz.sshj.common.SSHException import net.schmizz.sshj.userauth.password.PasswordFinder @@ -106,12 +98,12 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { } private fun getSshKey(generateKey: Boolean) { - runCatching { - callingActivity.launchActivity( - if (generateKey) SshKeyGenActivity::class.java else SshKeyImportActivity::class.java - ) - } - .onFailure { e -> logcat(ERROR) { e.asLog() } } + callingActivity.startActivity( + MainActivity.setupIntent( + callingActivity, + if (generateKey) SetupStart.SshGenerate else SetupStart.Ssh, + ) + ) } private fun registerAuthProviders( @@ -152,10 +144,8 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { ) { _, _ -> getSshKey(true) } - .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> - // Finish the blank GitActivity so user doesn't have to press back - callingActivity.finish() - } + // Cancel only closes the dialog: the calling activity is the app's own screen now. + .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel), null) .show() } @@ -171,7 +161,9 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { callingActivity, R.string.biometric_prompt_title_ssh_auth, ) { result -> - if (result !is Failure && result !is Retry) cont.resume(result) + // Retry: a failed attempt while the prompt stays open. Every other result + // closes it, so waiting for more would leave the operation hanging. + if (result !is Retry) cont.resume(result) } } } @@ -182,10 +174,6 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { is CanceledByUser -> { return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) } - is Failure, - is CanceledBySystem -> { - throw IllegalStateException("Biometric authentication failures should be ignored") - } else -> { // There is a chance we succeed if the user recently confirmed // their screen lock. Doing so would have a potential to confuse @@ -197,7 +185,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { Toast.LENGTH_LONG, ) .show() - callingActivity.finish() + return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) } } } else { diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt index 109e36c9..7c8f5ba5 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt @@ -16,6 +16,7 @@ import android.util.Base64 import androidx.core.content.edit import app.passwordstore.Application import app.passwordstore.R +import app.passwordstore.ui.setup.readAtMost import app.passwordstore.util.crypto.keystoreAesKey import app.passwordstore.util.crypto.requireUserAuthentication import app.passwordstore.util.crypto.seal @@ -29,6 +30,8 @@ import com.github.michaelbull.result.getOrElse import com.github.michaelbull.result.runCatching import java.io.File import java.io.IOException +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.security.KeyFactory import java.security.KeyPair import java.security.KeyPairGenerator @@ -46,7 +49,9 @@ import net.schmizz.sshj.SSHClient import net.schmizz.sshj.common.Buffer import net.schmizz.sshj.common.Ed25519KeyFactory import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.keyprovider.KeyFormat import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import net.schmizz.sshj.userauth.keyprovider.KeyProviderUtil import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore" @@ -82,6 +87,25 @@ internal fun ed25519KeyPair(seed: ByteArray) = Ed25519KeyFactory.getPrivateKey(seed), ) +/** + * 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 + } +} + object SshKey { val sshPublicKey @@ -182,15 +206,20 @@ object SshKey { } private fun delete() { - androidKeystore.deleteEntry(KEYSTORE_ALIAS) if (privateKeyFile.isFile) { privateKeyFile.delete() } + deleteKeyExtras() + type = null + } + + /** Everything of the current key but its private key file. */ + private fun deleteKeyExtras() { + androidKeystore.deleteEntry(KEYSTORE_ALIAS) if (publicKeyFile.isFile) { publicKeyFile.delete() } context.getEncryptedGitPrefs().edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) } - type = null } fun import(uri: Uri) { @@ -198,9 +227,8 @@ object SshKey { val fileSize = context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor -> - // Cursor returns only a single row. - cursor.moveToFirst() - cursor.getInt(0) + // A single row; none when the provider cannot tell: the IOException below. + if (cursor.moveToFirst()) cursor.getInt(0) else null } ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes. @@ -212,26 +240,43 @@ object SshKey { val sshKeyInputStream = context.contentResolver.openInputStream(uri) ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) - val lines = sshKeyInputStream.use { `is` -> `is`.bufferedReader().readLines() } - - // 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()) - ) + // Bounded whatever the provider claimed above: a larger stream is refused, not read whole. + val bytes = + sshKeyInputStream.use { readAtMost(it, 100_000) } + ?: throw IllegalArgumentException( + context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message) + ) + val lines = + try { + bytes.inputStream().bufferedReader().readLines() + } finally { + bytes.fill(0) + } + + // Private key markers and a format sshj can read; the current key stays when this fails. + if (!isSshPrivateKey(lines)) throw IllegalArgumentException( context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message) ) // At this point, we are reasonably confident that we have actually been provided a private - // key and delete the old key. - delete() - // Canonicalize line endings to '\n'. - privateKeyFile.writeText(lines.joinToString("\n")) - + // key. It replaces the old one atomically (an I/O error keeps the old key), then the old key's + // Keystore entry, public key and passphrase go. + val temp = File(privateKeyFile.parentFile, ".${privateKeyFile.name}.tmp") + try { + // Canonicalize line endings to '\n'. + temp.writeText(lines.joinToString("\n")) + Files.move( + temp.toPath(), + privateKeyFile.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } finally { + temp.delete() + } type = Type.Imported + deleteKeyExtras() } /** Deletes an Ed25519 key wrapped by androidx security-crypto, which can no longer be read. */ diff --git a/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt b/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt index 1b521141..6382e1ed 100644 --- a/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt +++ b/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt @@ -42,13 +42,10 @@ class ProxyUtils @Inject constructor(private val gitSettings: GitSettings) { ) val user = gitSettings.proxyUsername ?: "" val password = gitSettings.proxyPassword ?: "" - if (user.isEmpty() || password.isEmpty()) { - System.clearProperty(HTTP_PROXY_USER_PROPERTY) - System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY) - } else { - System.setProperty(HTTP_PROXY_USER_PROPERTY, user) - System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password) - } + // The Authenticator below supplies the credentials; never copy the password into a + // process-wide system property. + System.clearProperty(HTTP_PROXY_USER_PROPERTY) + System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY) Authenticator.setDefault( object : Authenticator() { override fun getPasswordAuthentication(): PasswordAuthentication? { diff --git a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt index d315daf3..004028c4 100644 --- a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt +++ b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt @@ -108,7 +108,7 @@ constructor( } var proxyUsername - get() = settings.getString(PreferenceKeys.PROXY_USERNAME) + get() = proxySettings.getString(PreferenceKeys.PROXY_USERNAME) set(value) { proxySettings.edit { putString(PreferenceKeys.PROXY_USERNAME, value) } } diff --git a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt index 06075655..82847f02 100644 --- a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt +++ b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt @@ -32,6 +32,8 @@ fun runMigrations(filesDirPath: String, sharedPrefs: SharedPreferences, gitSetti migrateToDiceware(sharedPrefs) removeExternalStorageProperties(sharedPrefs) removeCurrentBranchValue(sharedPrefs) + migrateRemovedSortOrders(sharedPrefs) + removeOpenKeystoreKeyId(sharedPrefs) } /** @@ -194,3 +196,20 @@ private fun removeExternalStorageProperties(prefs: SharedPreferences) { } } } + +/** + * "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) } +} diff --git a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt index 224d9d66..921e9bc4 100644 --- a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt +++ b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt @@ -18,11 +18,6 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) (p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true) } ), - INDEPENDENT( - Comparator { p1: PasswordItem, p2: PasswordItem -> - p1.name.compareTo(p2.name, ignoreCase = true) - } - ), RECENTLY_USED( Comparator { p1: PasswordItem, p2: PasswordItem -> val recentHistory = @@ -36,18 +31,18 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) else -> p1.name.compareTo(p2.name, ignoreCase = true) } } - ), - FILE_FIRST( - Comparator { p1: PasswordItem, p2: PasswordItem -> - (p2.type + p1.name).compareTo(p1.type + 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 { - return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name) + val stored = settings.getString(PreferenceKeys.SORT_ORDER) + return entries.firstOrNull { it.name == stored } ?: FOLDER_FIRST } } } diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt deleted file mode 100644 index 5c7429cd..00000000 --- a/app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.util.viewmodel - -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.PGPKeyManager -import com.github.michaelbull.result.map -import com.github.michaelbull.result.onSuccess -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch - -@HiltViewModel -class PGPKeyListViewModel @Inject constructor(private val keyManager: PGPKeyManager) : ViewModel() { - var keys: ImmutableList<PGPIdentifier> by mutableStateOf(persistentListOf()) - - init { - updateKeySet() - } - - fun updateKeySet() { - viewModelScope.launch { - keyManager - .getAllKeys() - .map { keys -> keys.mapNotNull { key -> KeyUtils.tryGetEmail(key) } } - .onSuccess { keys = it.toPersistentList() } - } - } - - fun deleteKey(identifier: PGPIdentifier) { - viewModelScope.launch { - keyManager.removeKey(identifier) - updateKeySet() - } - } -} diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt index 48804e7a..5f35644d 100644 --- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -6,24 +6,12 @@ package app.passwordstore.util.viewmodel import android.app.Application import android.content.SharedPreferences -import android.os.Parcelable -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.annotation.VisibleForTesting import androidx.lifecycle.AndroidViewModel -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 app.passwordstore.data.password.PasswordItem import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.ui.list.listRows import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.checkMainThread import app.passwordstore.util.coroutines.DispatcherProvider @@ -34,9 +22,7 @@ import com.github.androidpasswordstore.sublimefuzzy.Fuzzy import dagger.hilt.android.lifecycle.HiltViewModel import java.io.File import java.text.Collator -import java.util.Locale import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -50,9 +36,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.yield -import me.zhanghai.android.fastscroll.PopupTextProvider private fun File.toPasswordItem() = if (isFile) PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory()) @@ -70,12 +54,7 @@ private fun PasswordItem.Companion.makeComparator( directoryStructure: DirectoryStructure, ): Comparator<PasswordItem> { 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.FOLDER_FIRST -> compareBy<PasswordItem> { it.type } PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator } .then( @@ -88,9 +67,6 @@ private fun PasswordItem.Companion.makeComparator( ) } -val PasswordItem.stableId: String - get() = file.absolutePath - enum class FilterMode { NoFilter, StrictDomain, @@ -105,7 +81,6 @@ enum class SearchMode { enum class ListMode { FilesOnly, - DirectoriesOnly, AllEntries, } @@ -191,7 +166,7 @@ constructor( ) ) - data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean) + data class SearchResult(val passwordItems: List<PasswordItem>) val searchResult = searchActionFlow @@ -207,8 +182,6 @@ constructor( when (searchAction.listMode) { ListMode.FilesOnly -> listResultFlow.filter { it.isFile }.flowOn(dispatcherProvider.io()) - ListMode.DirectoriesOnly -> - listResultFlow.filter { it.isDirectory }.flowOn(dispatcherProvider.io()) ListMode.AllEntries -> listResultFlow } val passwordList = @@ -264,7 +237,7 @@ constructor( .map { it.second } } } - SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter) + SearchResult(passwordList) } .flowOn(dispatcherProvider.io()) @@ -302,55 +275,28 @@ constructor( private val _currentDir = MutableStateFlow(root) val currentDir = _currentDir.asStateFlow() - data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?) - - private val navigationStack = ArrayDeque<NavigationStackEntry>() + /** [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()) - fun navigateTo( - newDirectory: File = root, - listMode: ListMode = ListMode.AllEntries, - recyclerViewState: Parcelable? = null, - pushPreviousLocation: Boolean = true, - ) { + /** Lists [newDirectory]; the Compose list keeps the back stack (one route per folder). */ + fun navigateTo(newDirectory: File) { if (!newDirectory.exists()) return require(newDirectory.isDirectory) { "Can only navigate to a directory" } - if (pushPreviousLocation) { - navigationStack.addFirst(NavigationStackEntry(_currentDir.value, recyclerViewState)) - } searchActionFlow.update { makeSearchAction( filter = "", baseDirectory = newDirectory, filterMode = FilterMode.NoFilter, searchMode = SearchMode.InCurrentDirectoryOnly, - listMode = listMode, + listMode = ListMode.AllEntries, ) } _currentDir.update { newDirectory } } - val canNavigateBack - get() = navigationStack.isNotEmpty() - - /** - * Navigate back to the last location on the [navigationStack] and restore a cached scroll - * position if possible. - * - * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached. - */ - fun navigateBack(): Parcelable? { - if (!canNavigateBack) return null - val (oldDir, oldRecyclerViewState) = navigationStack.removeFirst() - navigateTo(oldDir, pushPreviousLocation = false) - return oldRecyclerViewState - } - - fun reset() { - navigationStack.clear() - forceUpdateOnNextSearchAction() - navigateTo(pushPreviousLocation = false) - } - fun search( filter: String, baseDirectory: File? = null, @@ -397,114 +343,3 @@ constructor( } } } - -private object PasswordItemDiffCallback : DiffUtil.ItemCallback<PasswordItem>() { - - override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = - oldItem.file.absolutePath == newItem.file.absolutePath - - override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem -} - -open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>( - private val layoutRes: Int, - private val viewHolderCreator: (view: View) -> T, - private val coroutineScope: CoroutineScope, - private val dispatcherProvider: DispatcherProvider, - private val viewHolderBinder: - suspend T.(item: PasswordItem, dispatcherProvider: DispatcherProvider) -> Unit, -) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback), PopupTextProvider { - - fun <T : ItemDetailsLookup<String>> makeSelectable( - recyclerView: RecyclerView, - itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T, - ) { - selectionTracker = - SelectionTracker.Builder( - "SearchableRepositoryAdapter", - recyclerView, - itemKeyProvider, - itemDetailsLookupCreator(recyclerView), - StorageStrategy.createStringStorage(), - ) - .withSelectionPredicate(SelectionPredicates.createSelectAnything()) - .build() - .apply { - addObserver( - object : SelectionTracker.SelectionObserver<String>() { - override fun onSelectionChanged() { - this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke( - requireSelectionTracker().selection - ) - } - } - ) - } - } - - private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null - - open fun onItemClicked( - listener: (holder: T, item: PasswordItem) -> Unit - ): SearchableRepositoryAdapter<T> { - check(onItemClickedListener == null) { - "Only a single listener can be registered for onItemClicked" - } - onItemClickedListener = listener - return this - } - - private var onSelectionChangedListener: ((selection: Selection<String>) -> Unit)? = null - - open fun onSelectionChanged( - listener: (selection: Selection<String>) -> Unit - ): SearchableRepositoryAdapter<T> { - check(onSelectionChangedListener == null) { - "Only a single listener can be registered for onSelectionChanged" - } - onSelectionChangedListener = listener - return this - } - - private val itemKeyProvider = - object : ItemKeyProvider<String>(SCOPE_MAPPED) { - override fun getKey(position: Int) = getItem(position).stableId - - override fun getPosition(key: String) = - (0 until itemCount).firstOrNull { getItem(it).stableId == key } ?: RecyclerView.NO_POSITION - } - - private var selectionTracker: SelectionTracker<String>? = null - - fun requireSelectionTracker() = selectionTracker!! - - private val selectedFiles - get() = requireSelectionTracker().selection.map { File(it) } - - fun getSelectedItems() = selectedFiles.map { it.toPasswordItem() } - - fun getPositionForFile(file: File) = itemKeyProvider.getPosition(file.absolutePath) - - final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T { - val view = LayoutInflater.from(parent.context).inflate(layoutRes, parent, false) - return viewHolderCreator(view) - } - - final override fun onBindViewHolder(holder: T, position: Int) { - val item = getItem(position) - holder.apply { - coroutineScope.launch(dispatcherProvider.mainImmediate()) { - viewHolderBinder.invoke(holder, item, dispatcherProvider) - } - selectionTracker?.let { itemView.isSelected = it.isSelected(item.stableId) } - itemView.setOnClickListener { - // Do not emit custom click events while the user is selecting items. - if (selectionTracker?.hasSelection() != true) onItemClickedListener?.invoke(holder, item) - } - } - } - - final override fun getPopupText(view: View, position: Int): String { - return getItem(position).name[0].toString().uppercase(Locale.getDefault()) - } -} diff --git a/app/src/main/res/anim/scale_down.xml b/app/src/main/res/anim/scale_down.xml deleted file mode 100644 index cbb4cb97..00000000 --- a/app/src/main/res/anim/scale_down.xml +++ /dev/null @@ -1,16 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<set xmlns:android="http://schemas.android.com/apk/res/android"> - <scale - android:duration="300" - android:fromXScale="1.0" - android:fromYScale="1.0" - android:interpolator="@android:anim/linear_interpolator" - android:pivotX="50%" - android:pivotY="50%" - android:toXScale="0" - android:toYScale="0" /> -</set> diff --git a/app/src/main/res/anim/scale_up.xml b/app/src/main/res/anim/scale_up.xml deleted file mode 100644 index 1e149e38..00000000 --- a/app/src/main/res/anim/scale_up.xml +++ /dev/null @@ -1,16 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<set xmlns:android="http://schemas.android.com/apk/res/android"> - <scale - android:duration="300" - android:fromXScale="0" - android:fromYScale="0" - android:interpolator="@android:anim/linear_interpolator" - android:pivotX="50%" - android:pivotY="50%" - android:toXScale="1.0" - android:toYScale="1.0" /> -</set> diff --git a/app/src/main/res/animator/slide_in_left.xml b/app/src/main/res/animator/slide_in_left.xml deleted file mode 100644 index 355aeefb..00000000 --- a/app/src/main/res/animator/slide_in_left.xml +++ /dev/null @@ -1,15 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<set xmlns:android="http://schemas.android.com/apk/res/android"> - - <objectAnimator - android:duration="250" - android:propertyName="x" - android:valueFrom="1000" - android:valueTo="0" - android:valueType="floatType" /> - -</set> diff --git a/app/src/main/res/animator/slide_in_right.xml b/app/src/main/res/animator/slide_in_right.xml deleted file mode 100644 index 685bda4d..00000000 --- a/app/src/main/res/animator/slide_in_right.xml +++ /dev/null @@ -1,15 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<set xmlns:android="http://schemas.android.com/apk/res/android"> - - <objectAnimator - android:duration="250" - android:propertyName="x" - android:valueFrom="-1000" - android:valueTo="0" - android:valueType="floatType" /> - -</set> diff --git a/app/src/main/res/animator/slide_out_left.xml b/app/src/main/res/animator/slide_out_left.xml deleted file mode 100644 index 06c3f076..00000000 --- a/app/src/main/res/animator/slide_out_left.xml +++ /dev/null @@ -1,15 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<set xmlns:android="http://schemas.android.com/apk/res/android"> - - <objectAnimator - android:duration="250" - android:propertyName="x" - android:valueFrom="0" - android:valueTo="-1000" - android:valueType="floatType" /> - -</set> diff --git a/app/src/main/res/animator/slide_out_right.xml b/app/src/main/res/animator/slide_out_right.xml deleted file mode 100644 index e5c61bdc..00000000 --- a/app/src/main/res/animator/slide_out_right.xml +++ /dev/null @@ -1,15 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<set xmlns:android="http://schemas.android.com/apk/res/android"> - - <objectAnimator - android:duration="250" - android:propertyName="x" - android:valueFrom="0" - android:valueTo="1000" - android:valueType="floatType" /> - -</set> diff --git a/app/src/main/res/drawable/divider.xml b/app/src/main/res/drawable/divider.xml deleted file mode 100644 index 39ec0614..00000000 --- a/app/src/main/res/drawable/divider.xml +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<shape xmlns:android="http://schemas.android.com/apk/res/android" - android:shape="rectangle"> - <size android:height="1dp" /> - <solid android:color="?attr/colorPrimaryDark" /> -</shape> diff --git a/app/src/main/res/drawable/ic_add_48dp.xml b/app/src/main/res/drawable/ic_add_48dp.xml deleted file mode 100644 index 7192e79d..00000000 --- a/app/src/main/res/drawable/ic_add_48dp.xml +++ /dev/null @@ -1,15 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:tint="?attr/colorControlNormal" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:fillColor="#FFFFFF" - android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" /> -</vector> diff --git a/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml b/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml deleted file mode 100644 index 234364a9..00000000 --- a/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml +++ /dev/null @@ -1,15 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:tint="?attr/colorControlNormal" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:fillColor="#FFFFFFFF" - android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" /> -</vector> diff --git a/app/src/main/res/drawable/ic_content_copy.xml b/app/src/main/res/drawable/ic_content_copy.xml deleted file mode 100644 index cdee1758..00000000 --- a/app/src/main/res/drawable/ic_content_copy.xml +++ /dev/null @@ -1,15 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:tint="?attr/colorControlNormal" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:fillColor="#FFFFFF" - android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM15,5L8,5c-1.1,0 -1.99,0.9 -1.99,2L6,21c0,1.1 0.89,2 1.99,2L19,23c1.1,0 2,-0.9 2,-2L21,11l-6,-6zM8,21L8,7h6v5h5v9L8,21z" /> -</vector> diff --git a/app/src/main/res/drawable/ic_done_24dp.xml b/app/src/main/res/drawable/ic_done_24dp.xml deleted file mode 100644 index f8b6cef6..00000000 --- a/app/src/main/res/drawable/ic_done_24dp.xml +++ /dev/null @@ -1,15 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:tint="?attr/colorControlNormal" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:fillColor="#FFFFFF" - android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z" /> -</vector> diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_right_24dp.xml b/app/src/main/res/drawable/ic_keyboard_arrow_right_24dp.xml deleted file mode 100644 index 6861aa87..00000000 --- a/app/src/main/res/drawable/ic_keyboard_arrow_right_24dp.xml +++ /dev/null @@ -1,15 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:tint="?attr/colorControlNormal" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:fillColor="#FFFFFFFF" - android:pathData="M8.59,16.59L13.17,12 8.59,7.41 10,6l6,6 -6,6 -1.41,-1.41z" /> -</vector> diff --git a/app/src/main/res/drawable/ic_save_24dp.xml b/app/src/main/res/drawable/ic_save_24dp.xml deleted file mode 100644 index 8a4276c4..00000000 --- a/app/src/main/res/drawable/ic_save_24dp.xml +++ /dev/null @@ -1,15 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:tint="?attr/colorControlNormal" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:fillColor="#FFFFFF" - android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM19,19L5,19L5,5h11.17L19,7.83L19,19zM12,12c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3zM6,6h9v4L6,10z" /> -</vector> diff --git a/app/src/main/res/drawable/ic_save_copy_24dp.xml b/app/src/main/res/drawable/ic_save_copy_24dp.xml deleted file mode 100644 index 06419aac..00000000 --- a/app/src/main/res/drawable/ic_save_copy_24dp.xml +++ /dev/null @@ -1,15 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:tint="?attr/colorControlNormal" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:fillColor="#FFFFFF" - android:pathData="M19,5L7,5A2,2 0,0 0,5 7L5,21a2,2 0,0 0,2 2L21,23a2,2 0,0 0,2 -2L23,9ZM21,21L7,21L7,7L18.17,7L21,9.83ZM14,14a3,3 0,1 0,3 3A3,3 0,0 0,14 14ZM8,8h9v4L8,12ZM15,1L3,1A2,2 0,0 0,1 3L1,17L3,17L3,3L15,3Z" /> -</vector> diff --git a/app/src/main/res/drawable/password_row_background.xml b/app/src/main/res/drawable/password_row_background.xml deleted file mode 100644 index 0f681cd8..00000000 --- a/app/src/main/res/drawable/password_row_background.xml +++ /dev/null @@ -1,20 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<!-- -Requires a layer-list since attributes cannot be resolved in selectors, see: -https://stackoverflow.com/a/36424426/297261 - --> -<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> - <item> - <selector> - <item - android:drawable="@color/list_multiselect_background" - android:state_selected="true" /> - <item android:drawable="@android:color/transparent" /> - </selector> - </item> - <item android:drawable="?android:attr/selectableItemBackground" /> -</layer-list> diff --git a/app/src/main/res/layout/activity_git_clone.xml b/app/src/main/res/layout/activity_git_clone.xml deleted file mode 100644 index b2f699b1..00000000 --- a/app/src/main/res/layout/activity_git_clone.xml +++ /dev/null @@ -1,94 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:background="?android:attr/windowBackground" - android:padding="@dimen/activity_horizontal_margin" - tools:context="app.passwordstore.ui.git.config.GitServerConfigActivity"> - - <androidx.constraintlayout.widget.ConstraintLayout - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/label_server_url" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:hint="@string/server_url" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/server_url" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:imeOptions="actionNext" - android:inputType="textWebEmailAddress" - android:nextFocusForward="@id/auth_mode_group" /> - - </com.google.android.material.textfield.TextInputLayout> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/label_auth_mode" - style="@style/TextAppearance.MaterialComponents.Headline6" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - android:layout_marginBottom="16dp" - android:text="@string/connection_mode" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/label_server_url" /> - - <com.google.android.material.button.MaterialButtonToggleGroup - android:id="@+id/auth_mode_group" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/label_auth_mode" - app:singleSelection="true"> - - <com.google.android.material.button.MaterialButton - android:id="@+id/auth_mode_ssh_key" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/connection_mode_ssh_key" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/auth_mode_password" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/connection_mode_basic_authentication" /> - - </com.google.android.material.button.MaterialButtonToggleGroup> - - <com.google.android.material.button.MaterialButton - android:id="@+id/save_button" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/crypto_save" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/auth_mode_group" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/clear_host_key_button" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/clear_saved_host_key" - android:visibility="gone" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/auth_mode_group" - tools:visibility="visible" /> - </androidx.constraintlayout.widget.ConstraintLayout> -</ScrollView> diff --git a/app/src/main/res/layout/activity_git_config.xml b/app/src/main/res/layout/activity_git_config.xml deleted file mode 100644 index b43ec6d6..00000000 --- a/app/src/main/res/layout/activity_git_config.xml +++ /dev/null @@ -1,113 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:padding="@dimen/activity_horizontal_margin" - tools:context="app.passwordstore.ui.git.config.GitConfigActivity" - tools:layout_editor_absoluteX="0dp" - tools:layout_editor_absoluteY="81dp"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/username_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:hint="@string/git_user_name_hint" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:layout_editor_absoluteY="64dp"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/git_user_name" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textPersonName" /> - - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/email_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:hint="@string/git_user_email" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/username_input_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/git_user_email" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textEmailAddress" /> - - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.button.MaterialButton - android:id="@+id/save_button" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/crypto_save" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/email_input_layout" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/git_tools_title" - style="@style/TextAppearance.MaterialComponents.Headline5" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/git_tools" - android:textSize="24sp" - android:textStyle="bold" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/save_button" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/git_head_status" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/git_tools_title" - tools:text="HEAD status" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/git_log" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/git_log" - app:layout_constraintTop_toBottomOf="@+id/git_head_status" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/git_abort_rebase" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/abort_rebase" - app:layout_constraintTop_toBottomOf="@+id/git_log" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/git_reset_to_remote" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/reset_to_remote" - app:layout_constraintTop_toBottomOf="@id/git_abort_rebase" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/git_gc" - android:layout_marginTop="8dp" - android:text="@string/git_run_gc_job" - app:layout_constraintTop_toBottomOf="@id/git_reset_to_remote" - android:layout_width="match_parent" - android:layout_height="wrap_content" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/activity_git_log.xml b/app/src/main/res/layout/activity_git_log.xml deleted file mode 100644 index e9884db0..00000000 --- a/app/src/main/res/layout/activity_git_log.xml +++ /dev/null @@ -1,22 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/git_log_recycler_view" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" - android:scrollbars="vertical" - app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" - tools:context="app.passwordstore.ui.git.log.GitLogActivity" - tools:listitem="@layout/git_log_row_layout" /> - -</LinearLayout> diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml deleted file mode 100644 index d1153f5d..00000000 --- a/app/src/main/res/layout/activity_onboarding.xml +++ /dev/null @@ -1,11 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android" - android:id="@+id/fragment_first_run" - android:name="app.passwordstore.ui.onboarding.fragments.WelcomeFragment" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:tag="welcome_fragment" /> diff --git a/app/src/main/res/layout/activity_oreo_autofill_filter.xml b/app/src/main/res/layout/activity_oreo_autofill_filter.xml deleted file mode 100644 index 0641f5c6..00000000 --- a/app/src/main/res/layout/activity_oreo_autofill_filter.xml +++ /dev/null @@ -1,132 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:elevation="2dp" - tools:context="app.passwordstore.ui.autofill.AutofillFilterView"> - - <ImageView - android:id="@+id/cover" - android:layout_width="0dp" - android:layout_height="60dp" - android:background="@android:color/transparent" - android:contentDescription="@string/app_name" - android:src="@drawable/ic_launcher_foreground" - app:layout_constraintBottom_toTopOf="@id/origin" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_bias="0.0" /> - - <TextView - android:id="@+id/origin" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:singleLine="false" - android:textAlignment="center" - app:layout_constraintBottom_toTopOf="@id/searchLayout" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/cover" - app:layout_constraintVertical_bias="0.0" /> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/searchLayout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - app:endIconMode="clear_text" - app:layout_constraintBottom_toTopOf="@id/rvPasswordSwitcher" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/origin"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/search" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/action_search" - android:imeOptions="actionDone" - android:inputType="text" - tools:text="example.com" /> - </com.google.android.material.textfield.TextInputLayout> - - <ViewSwitcher - android:id="@+id/rvPasswordSwitcher" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginTop="@dimen/activity_vertical_margin" - app:layout_constraintBottom_toTopOf="@id/strictDomainSearch" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/searchLayout"> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/rvPassword" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:scrollbars="vertical" - tools:itemCount="5" - tools:listitem="@layout/oreo_autofill_filter_row" /> - - <TextView - android:id="@+id/rvPasswordEmpty" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:text="@string/oreo_autofill_filter_no_results" - android:textSize="18sp" /> - - </ViewSwitcher> - - <com.google.android.material.switchmaterial.SwitchMaterial - android:id="@+id/strictDomainSearch" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:text="@string/oreo_autofill_strict_domain_search" - app:layout_constraintBottom_toTopOf="@id/shouldMatch" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/rvPasswordSwitcher" - app:layout_constraintVertical_bias="1.0" - tools:text="Phishing-resistant search" /> - - <com.google.android.material.switchmaterial.SwitchMaterial - android:id="@+id/shouldMatch" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:checked="true" - app:layout_constraintBottom_toTopOf="@id/shouldClear" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/strictDomainSearch" - app:layout_constraintVertical_bias="1.0" - tools:text="Match with example.org" /> - - <com.google.android.material.switchmaterial.SwitchMaterial - android:id="@+id/shouldClear" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:text="@string/oreo_autofill_matches_clear_existing" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/shouldMatch" - app:layout_constraintVertical_bias="1.0" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/activity_oreo_autofill_publisher_changed.xml b/app/src/main/res/layout/activity_oreo_autofill_publisher_changed.xml deleted file mode 100644 index 89d64fe4..00000000 --- a/app/src/main/res/layout/activity_oreo_autofill_publisher_changed.xml +++ /dev/null @@ -1,174 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:animateLayoutChanges="true" - android:elevation="2dp" - android:paddingBottom="16dp" - android:scrollbars="vertical" - tools:context="app.passwordstore.ui.autofill.AutofillPublisherChangedActivity"> - - <ImageView - android:id="@+id/cover" - android:layout_width="60dp" - android:layout_height="60dp" - android:background="@android:color/transparent" - android:contentDescription="@string/app_name" - android:src="@drawable/ic_launcher_foreground" - app:layout_constraintBottom_toTopOf="@id/warningSign" - app:layout_constraintEnd_toStartOf="@id/app_name" - app:layout_constraintHorizontal_chainStyle="packed" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <TextView - android:id="@+id/app_name" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/app_name" - android:textSize="18sp" - android:textStyle="bold" - app:layout_constraintBottom_toBottomOf="@id/cover" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@id/cover" - app:layout_constraintTop_toTopOf="@id/cover" /> - - <ImageView - android:id="@+id/warningSign" - android:layout_width="40dp" - android:layout_height="40dp" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:contentDescription="@string/oreo_autofill_warning_publisher_warning_sign_description" - android:src="@drawable/ic_warning_red_24dp" - app:layout_constraintBottom_toBottomOf="@id/warningHeader" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="@id/warningHeader" /> - - <TextView - android:id="@+id/warningHeader" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:text="@string/oreo_autofill_warning_publisher_header" - android:textSize="12sp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@id/warningSign" - app:layout_constraintTop_toBottomOf="@id/cover" /> - - <TextView - android:id="@+id/warningAppName" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:gravity="center_horizontal" - android:textSize="12sp" - app:layout_constraintBottom_toTopOf="@id/warningAppInstallDate" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/warningHeader" - tools:text="Example Banking" /> - - <TextView - android:id="@+id/warningAppInstallDate" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:gravity="center_horizontal" - android:textSize="12sp" - app:layout_constraintBottom_toTopOf="@id/warningAppFooter" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/warningAppName" - tools:text="Installed: 5 days ago" /> - - <TextView - android:id="@+id/warningAppFooter" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:text="@string/oreo_autofill_warning_publisher_footer" - android:textSize="12sp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/warningAppInstallDate" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/okButton" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:minWidth="240dp" - android:text="@string/oreo_autofill_warning_publisher_changed_disable_autofill_button" - android:textSize="12sp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/warningAppFooter" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/advancedButton" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:minWidth="240dp" - android:text="@string/oreo_autofill_warning_publisher_advanced_info_button" - android:textSize="12sp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/okButton" - tools:visibility="visible" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/resetButton" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:minWidth="240dp" - android:text="@string/oreo_autofill_warning_publisher_reenable_button" - android:textSize="12sp" - android:visibility="gone" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/advancedButton" - tools:visibility="visible" /> - - <TextView - android:id="@+id/warningAppAdvancedInfo" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="16dp" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:fontFamily="@font/jetbrainsmono_nl_regular" - android:gravity="center_horizontal" - android:textIsSelectable="true" - android:textSize="10sp" - android:visibility="gone" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/resetButton" - tools:ignore="SmallSp" - tools:text="Package: com.example.banking\n\nHash:\n8P1sW0EPJcslw7UzRsiXL64w+O50Ed+RBICtay1g24M=" - tools:visibility="visible" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/activity_preference_recyclerview.xml b/app/src/main/res/layout/activity_preference_recyclerview.xml deleted file mode 100644 index 805bc79a..00000000 --- a/app/src/main/res/layout/activity_preference_recyclerview.xml +++ /dev/null @@ -1,18 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<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"> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/preference_recycler_view" - android:layout_width="match_parent" - android:layout_height="match_parent" - app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" /> - -</LinearLayout> diff --git a/app/src/main/res/layout/activity_proxy_selector.xml b/app/src/main/res/layout/activity_proxy_selector.xml deleted file mode 100644 index b83ca9f7..00000000 --- a/app/src/main/res/layout/activity_proxy_selector.xml +++ /dev/null @@ -1,106 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/proxy_host_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:hint="@string/proxy_hostname" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:layout_editor_absoluteY="64dp"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/proxy_host" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textUri" - android:nextFocusForward="@id/proxy_user" /> - - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/proxy_user_input_layout" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/normal_margin" - android:layout_marginEnd="@dimen/normal_margin" - android:hint="@string/username" - app:layout_constraintEnd_toStartOf="@id/proxy_port_input_layout" - app:layout_constraintHorizontal_weight="0.65" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/proxy_host_input_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/proxy_user" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textWebEmailAddress" - android:nextFocusForward="@id/proxy_port" /> - - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/proxy_port_input_layout" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/normal_margin" - android:layout_marginTop="@dimen/normal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:hint="@string/port" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_weight="0.35" - app:layout_constraintStart_toEndOf="@id/proxy_user_input_layout" - app:layout_constraintTop_toBottomOf="@id/proxy_host_input_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/proxy_port" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="number" - android:nextFocusForward="@id/proxy_password" /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/proxy_password_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/normal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:hint="@string/password" - app:endIconMode="password_toggle" - app:layout_constraintStart_toStartOf="@id/proxy_user_input_layout" - app:layout_constraintTop_toBottomOf="@id/proxy_user_input_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/proxy_password" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textPassword" /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.button.MaterialButton - android:id="@+id/save" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/normal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:text="@string/crypto_save" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/proxy_password_input_layout" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/activity_pwdstore.xml b/app/src/main/res/layout/activity_pwdstore.xml deleted file mode 100644 index 295efd9c..00000000 --- a/app/src/main/res/layout/activity_pwdstore.xml +++ /dev/null @@ -1,19 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" - tools:context=".PasswordStore"> - - <LinearLayout - android:id="@+id/main_layout" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" /> - -</LinearLayout> diff --git a/app/src/main/res/layout/activity_ssh_keygen.xml b/app/src/main/res/layout/activity_ssh_keygen.xml deleted file mode 100644 index 49548b36..00000000 --- a/app/src/main/res/layout/activity_ssh_keygen.xml +++ /dev/null @@ -1,69 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<ScrollView 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"> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:paddingLeft="@dimen/activity_horizontal_margin" - android:paddingTop="@dimen/activity_vertical_margin" - android:paddingRight="@dimen/activity_horizontal_margin"> - - <com.google.android.material.button.MaterialButtonToggleGroup - android:id="@+id/key_type_group" - style="@style/TextAppearance.MaterialComponents.Headline1" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:selectionRequired="true" - app:singleSelection="true"> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key_type_rsa" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/ssh_keygen_label_rsa" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key_type_ecdsa" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/ssh_keygen_label_ecdsa" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key_type_ed25519" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/ssh_keygen_label_ed25519" /> - </com.google.android.material.button.MaterialButtonToggleGroup> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/key_type_explanation" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingTop="8dp" /> - - <com.google.android.material.checkbox.MaterialCheckBox - android:id="@+id/key_require_authentication" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="@string/ssh_keygen_require_authentication" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/generate" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_gravity="end" - android:layout_marginTop="8dp" - android:text="@string/ssh_keygen_generate" /> - </LinearLayout> -</ScrollView> diff --git a/app/src/main/res/layout/basic_bottom_sheet.xml b/app/src/main/res/layout/basic_bottom_sheet.xml deleted file mode 100644 index b45fc9c9..00000000 --- a/app/src/main/res/layout/basic_bottom_sheet.xml +++ /dev/null @@ -1,62 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:paddingBottom="24dp"> - - <com.google.android.material.textview.MaterialTextView - android:id="@+id/bottom_sheet_title" - style="?attr/textAppearanceTitleLarge" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginTop="8dp" - android:visibility="gone" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="Bottom sheet title" /> - - <com.google.android.material.textview.MaterialTextView - android:id="@+id/bottom_sheet_message" - style="?attr/textAppearanceBodyLarge" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginTop="16dp" - android:layout_marginEnd="8dp" - app:layout_constraintTop_toBottomOf="@id/bottom_sheet_title" - tools:text="A long body of text that serves as the bottom sheet message" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/bottom_sheet_cancel_button" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="24dp" - android:layout_marginEnd="8dp" - android:text="@string/dialog_cancel" - android:visibility="gone" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message" - tools:visibility="visible" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/bottom_sheet_ok_button" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="24dp" - android:layout_marginEnd="8dp" - android:text="@string/dialog_ok" - android:visibility="gone" - app:layout_constraintEnd_toStartOf="@id/bottom_sheet_cancel_button" - app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message" - tools:visibility="visible" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/decrypt_layout.xml b/app/src/main/res/layout/decrypt_layout.xml deleted file mode 100644 index 88c0fb1a..00000000 --- a/app/src/main/res/layout/decrypt_layout.xml +++ /dev/null @@ -1,65 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" - android:paddingTop="16dp" - tools:context="app.passwordstore.ui.crypto.DecryptActivity"> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/password_category" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_gravity="center_vertical" - android:layout_marginStart="32dp" - android:layout_marginEnd="16dp" - android:textIsSelectable="false" - android:textSize="18sp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="CATEGORY HERE" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/password_file" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="32dp" - android:layout_marginEnd="16dp" - android:textAppearance="@android:style/TextAppearance.DeviceDefault.Medium" - android:textColor="?attr/colorSecondary" - android:textSize="24sp" - android:textStyle="bold" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/password_category" - tools:text="PASSWORD FILE NAME HERE" /> - - <androidx.appcompat.widget.AppCompatImageView - android:id="@+id/divider" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:src="@drawable/divider" - app:layout_constraintTop_toBottomOf="@id/password_file" - tools:ignore="ContentDescription" /> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/recycler_view" - android:layout_width="match_parent" - android:layout_height="0dp" - android:paddingHorizontal="16dp" - app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/divider" - tools:listitem="@layout/item_field" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/dialog_password_entry.xml b/app/src/main/res/layout/dialog_password_entry.xml deleted file mode 100644 index bbfbc506..00000000 --- a/app/src/main/res/layout/dialog_password_entry.xml +++ /dev/null @@ -1,35 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<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="@dimen/activity_horizontal_margin"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/password_field" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/password" - app:endIconMode="password_toggle" - app:hintAnimationEnabled="true" - app:hintEnabled="true"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/password_edit_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textPassword" /> - <requestFocus /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.checkbox.MaterialCheckBox - android:id="@+id/auto_clear_cache" - android:layout_width="match_parent" - android:text="@string/clear_cached_password_on_screen_off" - android:layout_height="wrap_content" /> -</LinearLayout> diff --git a/app/src/main/res/layout/dialog_text_input.xml b/app/src/main/res/layout/dialog_text_input.xml deleted file mode 100644 index c8539273..00000000 --- a/app/src/main/res/layout/dialog_text_input.xml +++ /dev/null @@ -1,25 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" - android:padding="@dimen/activity_horizontal_margin"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/text_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/edit_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" /> - - <requestFocus /> - </com.google.android.material.textfield.TextInputLayout> - -</LinearLayout> diff --git a/app/src/main/res/layout/folder_dialog_fragment.xml b/app/src/main/res/layout/folder_dialog_fragment.xml deleted file mode 100644 index 7860c9b4..00000000 --- a/app/src/main/res/layout/folder_dialog_fragment.xml +++ /dev/null @@ -1,38 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout 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/folder_name_container" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/crypto_name_hint" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/folder_name_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textNoSuggestions|textVisiblePassword" /> - <requestFocus /> - </com.google.android.material.textfield.TextInputLayout> - - <!-- TODO(msfjarvis): Restore this functionality --> - <com.google.android.material.checkbox.MaterialCheckBox - android:id="@+id/set_gpg_key" - android:layout_width="0dp" - android:visibility="gone" - android:layout_height="wrap_content" - android:text="@string/new_folder_set_gpg_key" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/folder_name_container" /> -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/fragment_clone.xml b/app/src/main/res/layout/fragment_clone.xml deleted file mode 100644 index 9cb34761..00000000 --- a/app/src/main/res/layout/fragment_clone.xml +++ /dev/null @@ -1,96 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<ScrollView 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:background="?android:attr/colorBackground"> - - <androidx.constraintlayout.widget.ConstraintLayout - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <androidx.appcompat.widget.AppCompatImageView - android:id="@+id/app_icon" - android:layout_width="64dp" - android:layout_height="64dp" - android:layout_marginStart="32dp" - android:layout_marginTop="@dimen/onboarding_icon_margin_top" - android:contentDescription="@string/app_icon_hint" - android:src="@mipmap/ic_launcher" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/app_name" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_centerHorizontal="true" - android:layout_marginStart="@dimen/fab_compat_margin" - android:layout_marginEnd="@dimen/fab_compat_margin" - android:text="@string/app_name" - android:textAppearance="?attr/textAppearanceTitleLarge" - android:textStyle="bold" - app:layout_constraintBottom_toBottomOf="@id/app_icon" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@id/app_icon" - app:layout_constraintTop_toTopOf="@+id/app_icon" /> - - <TextView - android:id="@+id/repo_type" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="48dp" - android:layout_marginEnd="@dimen/fab_compat_margin" - android:text="@string/select_n_repository_type" - android:textAppearance="?attr/textAppearanceHeadlineSmall" - android:textStyle="bold" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="0" - app:layout_constraintStart_toStartOf="@id/app_icon" - app:layout_constraintTop_toBottomOf="@id/app_icon" /> - - <TextView - android:id="@+id/tv_repo_type_text" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/onboarding_desc_margin_top" - android:layout_marginEnd="@dimen/fab_compat_margin" - android:text="@string/select_repo_type_text" - android:textAppearance="?attr/textAppearanceTitleSmall" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@id/repo_type" - app:layout_constraintTop_toBottomOf="@id/repo_type" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/clone_remote" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="@dimen/onboarding_button_margin_top" - android:layout_marginEnd="16dp" - android:maxWidth="300dp" - android:minWidth="100dp" - android:text="@string/clone_remote_repo" - app:layout_constraintBottom_toTopOf="@id/create_local" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/tv_repo_type_text" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/create_local" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginBottom="128dp" - android:maxWidth="300dp" - android:minWidth="100dp" - android:text="@string/create_local_repo" - app:layout_constraintEnd_toEndOf="@id/clone_remote" - app:layout_constraintStart_toStartOf="@id/clone_remote" - app:layout_constraintTop_toBottomOf="@id/clone_remote" /> - - </androidx.constraintlayout.widget.ConstraintLayout> -</ScrollView> diff --git a/app/src/main/res/layout/fragment_key_selection.xml b/app/src/main/res/layout/fragment_key_selection.xml deleted file mode 100644 index b9141960..00000000 --- a/app/src/main/res/layout/fragment_key_selection.xml +++ /dev/null @@ -1,80 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout 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:background="?android:attr/colorBackground" - android:orientation="vertical"> - - <androidx.appcompat.widget.AppCompatImageView - android:id="@+id/app_icon" - android:layout_width="64dp" - android:layout_height="64dp" - android:layout_marginStart="32dp" - android:layout_marginTop="@dimen/onboarding_icon_margin_top" - android:contentDescription="@string/app_icon_hint" - android:src="@mipmap/ic_launcher" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/app_name" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_centerHorizontal="true" - android:layout_marginStart="@dimen/fab_compat_margin" - android:layout_marginEnd="@dimen/fab_compat_margin" - android:text="@string/app_name" - android:textAppearance="?attr/textAppearanceTitleLarge" - android:textStyle="bold" - app:layout_constraintBottom_toBottomOf="@id/app_icon" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@id/app_icon" - app:layout_constraintTop_toTopOf="@+id/app_icon" /> - - <TextView - android:id="@+id/gpg_key" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="48dp" - android:layout_marginEnd="@dimen/fab_compat_margin" - android:text="@string/select_gpg_key_title" - android:textAppearance="?attr/textAppearanceHeadlineSmall" - android:textStyle="bold" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="0" - app:layout_constraintStart_toStartOf="@id/app_icon" - app:layout_constraintTop_toBottomOf="@id/app_icon" /> - - <TextView - android:id="@+id/gpg_key_text" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/onboarding_desc_margin_top" - android:layout_marginEnd="16dp" - android:text="@string/select_gpg_key_message" - android:textAppearance="?attr/textAppearanceTitleSmall" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@id/gpg_key" - app:layout_constraintTop_toBottomOf="@id/gpg_key" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/select_key" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="48dp" - android:layout_marginEnd="16dp" - android:maxWidth="300dp" - android:minWidth="100dp" - android:text="@string/gpg_key_select" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/gpg_key_text" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/fragment_manual_otp_entry.xml b/app/src/main/res/layout/fragment_manual_otp_entry.xml deleted file mode 100644 index 7d7ea832..00000000 --- a/app/src/main/res/layout/fragment_manual_otp_entry.xml +++ /dev/null @@ -1,53 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout 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"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/secret_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/otp_import_manual_hint_secret" - android:paddingStart="16dp" - android:paddingTop="16dp" - android:paddingEnd="16dp" - app:hintEnabled="true" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/secret" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:inputType="textPassword" /> - - <requestFocus /> - - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/account_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/otp_import_manual_hint_account" - android:paddingStart="16dp" - android:paddingTop="16dp" - android:paddingEnd="16dp" - android:paddingBottom="16dp" - app:hintEnabled="true" - app:layout_constraintTop_toBottomOf="@id/secret_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/account" - android:layout_width="match_parent" - android:layout_height="match_parent" /> - - </com.google.android.material.textfield.TextInputLayout> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/fragment_pwgen.xml b/app/src/main/res/layout/fragment_pwgen.xml deleted file mode 100644 index 02eadff5..00000000 --- a/app/src/main/res/layout/fragment_pwgen.xml +++ /dev/null @@ -1,115 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:paddingLeft="24dp" - android:paddingTop="20dp" - android:paddingRight="24dp" - android:paddingBottom="20dp" - tools:context=".MainActivityFragment"> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/passwordText" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginBottom="8dp" - android:textAppearance="?android:attr/textAppearanceMedium" - android:textIsSelectable="true" /> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="match_parent" - android:baselineAligned="false" - android:orientation="horizontal" - android:weightSum="2"> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_weight="1" - android:orientation="vertical"> - - <androidx.appcompat.widget.AppCompatTextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginBottom="8dp" - android:text="@string/pwgen_include" - android:textAppearance="?android:attr/textAppearanceSmall" /> - - <CheckBox - android:id="@+id/numerals" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/pwgen_numerals" /> - - - <CheckBox - android:id="@+id/symbols" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/pwgen_symbols" /> - - <CheckBox - android:id="@+id/uppercase" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/pwgen_uppercase" /> - - <CheckBox - android:id="@+id/lowercase" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/pwgen_lowercase" /> - - </LinearLayout> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_weight="1" - android:orientation="vertical"> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/length" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginBottom="8dp" - android:labelFor="@id/lengthNumber" - android:text="@string/pwgen_length" - android:textAppearance="?android:attr/textAppearanceSmall" /> - - <EditText - android:id="@+id/lengthNumber" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginBottom="8dp" - android:autofillHints="notApplicable" - android:ems="10" - android:inputType="number" /> - - <CheckBox - android:id="@+id/pronounceable" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/pwgen_pronounceable" /> - - <CheckBox - android:id="@+id/ambiguous" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/pwgen_ambiguous" /> - - </LinearLayout> - </LinearLayout> - </LinearLayout> -</ScrollView> diff --git a/app/src/main/res/layout/fragment_pwgen_diceware.xml b/app/src/main/res/layout/fragment_pwgen_diceware.xml deleted file mode 100644 index b8627670..00000000 --- a/app/src/main/res/layout/fragment_pwgen_diceware.xml +++ /dev/null @@ -1,73 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout 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="wrap_content" - android:orientation="vertical" - android:paddingLeft="24dp" - android:paddingTop="20dp" - android:paddingRight="24dp" - android:paddingBottom="20dp"> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/password_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:textAppearance="?android:attr/textAppearanceMedium" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/password_length" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:layout_marginEnd="4dp" - android:hint="@string/pwgen_length" - android:labelFor="@id/password_length_text" - app:hintAnimationEnabled="true" - app:hintEnabled="true" - app:layout_constraintEnd_toStartOf="@id/password_separator" - app:layout_constraintHorizontal_weight="0.5" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/password_text"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/password_length_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="number" - android:maxLength="2" /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/password_separator" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="4dp" - android:layout_marginTop="8dp" - android:hint="@string/pwgen_separator" - android:labelFor="@id/password_separator_text" - app:hintAnimationEnabled="true" - app:hintEnabled="true" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_weight="0.5" - app:layout_constraintStart_toEndOf="@id/password_length" - app:layout_constraintTop_toBottomOf="@id/password_text"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/password_separator_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:autofillHints="" - android:importantForAutofill="no" - android:inputType="text" - android:maxLength="1" /> - </com.google.android.material.textfield.TextInputLayout> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/fragment_welcome.xml b/app/src/main/res/layout/fragment_welcome.xml deleted file mode 100644 index c68cd56a..00000000 --- a/app/src/main/res/layout/fragment_welcome.xml +++ /dev/null @@ -1,67 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout 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:background="?android:attr/colorBackground" - android:orientation="vertical"> - - <com.google.android.material.button.MaterialButton - android:id="@+id/settings_button" - style="?attr/borderlessButtonStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_alignParentTop="true" - android:layout_alignParentEnd="true" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:text="@string/action_settings" - android:textAllCaps="true" - android:textColor="?attr/colorOnBackground" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <androidx.appcompat.widget.AppCompatImageView - android:id="@+id/app_icon" - android:layout_width="100dp" - android:layout_height="100dp" - android:contentDescription="@string/app_icon_hint" - android:src="@mipmap/ic_launcher" - android:transitionName="transition_first_app_icon" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/app_name" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_centerHorizontal="true" - android:layout_marginTop="16dp" - android:text="@string/app_name" - android:textAppearance="?attr/textAppearanceTitleLarge" - android:textStyle="bold" - android:transitionName="transition_first_run_app_name" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/app_icon" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/lets_go" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="48dp" - android:layout_marginEnd="16dp" - android:maxWidth="300dp" - android:minWidth="100dp" - android:text="@string/let_s_go" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/app_name" /> -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/git_credential_layout.xml b/app/src/main/res/layout/git_credential_layout.xml index 240c2d7a..8fc527e9 100644 --- a/app/src/main/res/layout/git_credential_layout.xml +++ b/app/src/main/res/layout/git_credential_layout.xml @@ -3,10 +3,11 @@ ~ SPDX-License-Identifier: GPL-3.0-only --> -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" +<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 @@ -15,10 +16,7 @@ android:layout_height="wrap_content" app:endIconMode="password_toggle" app:errorEnabled="true" - app:hintEnabled="true" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> + app:hintEnabled="true"> <com.google.android.material.textfield.TextInputEditText android:id="@+id/git_auth_credential" @@ -35,8 +33,5 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/git_operation_remember_passphrase" - app:checkedState="checked" - app:layout_constraintRight_toRightOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/git_auth_passphrase_layout" /> -</androidx.constraintlayout.widget.ConstraintLayout> + app:checkedState="checked" /> +</LinearLayout> diff --git a/app/src/main/res/layout/git_log_row_layout.xml b/app/src/main/res/layout/git_log_row_layout.xml deleted file mode 100644 index e996f6af..00000000 --- a/app/src/main/res/layout/git_log_row_layout.xml +++ /dev/null @@ -1,45 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingHorizontal="@dimen/activity_vertical_margin" - android:paddingVertical="@dimen/activity_horizontal_margin"> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/git_log_row_message" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/normal_margin" - android:ellipsize="end" - android:maxLines="2" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/git_log_row_hash" - tools:text="Commit message" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/git_log_row_hash" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:textColor="?attr/colorSecondary" - android:textStyle="bold" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="Hash" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/git_log_row_time" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:alpha="0.5" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="Time" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/item_create_sheet.xml b/app/src/main/res/layout/item_create_sheet.xml deleted file mode 100644 index 10882a00..00000000 --- a/app/src/main/res/layout/item_create_sheet.xml +++ /dev/null @@ -1,42 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout 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"> - - <com.google.android.material.button.MaterialButton - android:id="@+id/create_folder" - style="@style/Widget.MaterialComponents.Button.TextButton" - android:layout_width="match_parent" - android:layout_height="@dimen/bottom_sheet_item_height" - android:layout_margin="@dimen/normal_margin" - android:text="@string/bottom_sheet_create_new_folder" - android:textAlignment="viewStart" - android:textColor="?attr/colorOnSurface" - app:icon="@drawable/ic_action_new_folder" - app:iconPadding="@dimen/normal_margin" - app:iconTint="?attr/colorSecondary" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:rippleColor="?attr/colorSecondary" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/create_password" - style="@style/Widget.MaterialComponents.Button.TextButton" - android:layout_width="match_parent" - android:layout_height="@dimen/bottom_sheet_item_height" - android:layout_margin="@dimen/normal_margin" - android:text="@string/bottom_sheet_create_new_password" - android:textAlignment="viewStart" - android:textColor="?attr/colorOnSurface" - app:icon="@drawable/ic_action_new_password" - app:iconPadding="@dimen/normal_margin" - app:iconTint="?attr/colorSecondary" - app:layout_constraintTop_toBottomOf="@id/create_folder" - app:rippleColor="?attr/colorSecondary" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/item_field.xml b/app/src/main/res/layout/item_field.xml deleted file mode 100644 index 79697a56..00000000 --- a/app/src/main/res/layout/item_field.xml +++ /dev/null @@ -1,30 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/item_text_container" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - tools:hint="@string/password" - tools:visibility="visible"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/item_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:editable="false" - android:textIsSelectable="true" - tools:text="p@55w0rd!" /> - - </com.google.android.material.textfield.TextInputLayout> - -</LinearLayout> diff --git a/app/src/main/res/layout/map_preference_widget_switch.xml b/app/src/main/res/layout/map_preference_widget_switch.xml deleted file mode 100644 index 566e72d4..00000000 --- a/app/src/main/res/layout/map_preference_widget_switch.xml +++ /dev/null @@ -1,13 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<com.google.android.material.materialswitch.MaterialSwitch xmlns:android="http://schemas.android.com/apk/res/android" - android:id="@android:id/checkbox" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:background="@null" - android:clickable="false" - android:focusable="false" /> diff --git a/app/src/main/res/layout/oreo_autofill_filter_row.xml b/app/src/main/res/layout/oreo_autofill_filter_row.xml deleted file mode 100644 index b4284966..00000000 --- a/app/src/main/res/layout/oreo_autofill_filter_row.xml +++ /dev/null @@ -1,69 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="@drawable/password_row_background" - android:paddingTop="8dp" - android:paddingBottom="8dp"> - - <ImageView - android:id="@+id/credentialIcon" - android:layout_width="wrap_content" - android:layout_height="0dp" - android:layout_marginStart="8dp" - android:alpha="0.5" - android:src="@drawable/ic_person_black_24dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@id/title" - app:layout_constraintHorizontal_bias="0.0" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:tint="?attr/colorControlNormal" /> - - <TextView - android:id="@+id/title" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:ellipsize="middle" - android:singleLine="true" - android:textSize="12sp" - app:layout_constraintBottom_toTopOf="@id/subtitle" - app:layout_constraintEnd_toStartOf="@id/continueIcon" - app:layout_constraintStart_toEndOf="@id/credentialIcon" - app:layout_constraintTop_toTopOf="parent" - tools:text="/example.com/" /> - - <TextView - android:id="@+id/subtitle" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:ellipsize="middle" - android:singleLine="true" - android:textSize="14sp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@+id/continueIcon" - app:layout_constraintStart_toEndOf="@id/credentialIcon" - app:layout_constraintTop_toBottomOf="@id/title" - tools:text="johndoe@example.com" /> - - <ImageView - android:id="@+id/continueIcon" - android:layout_width="wrap_content" - android:layout_height="0dp" - android:layout_marginEnd="8dp" - android:src="@drawable/ic_keyboard_arrow_right_24dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="1.0" - app:layout_constraintStart_toEndOf="@id/title" - app:layout_constraintTop_toTopOf="parent" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/oreo_autofill_instructions.xml b/app/src/main/res/layout/oreo_autofill_instructions.xml deleted file mode 100644 index 66881390..00000000 --- a/app/src/main/res/layout/oreo_autofill_instructions.xml +++ /dev/null @@ -1,51 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <LinearLayout - android:layout_width="fill_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:paddingLeft="24dp" - android:paddingTop="20dp" - android:paddingRight="24dp" - android:paddingBottom="20dp"> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/intro_text" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/oreo_autofill_enable_dialog_description" - android:textSize="16sp" /> - - <androidx.appcompat.widget.AppCompatTextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:text="@string/oreo_autofill_enable_dialog_instructions" - android:textSize="16sp" /> - - <androidx.appcompat.widget.AppCompatTextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:text="@string/oreo_autofill_enable_dialog_installed_browsers" - android:textSize="16sp" - android:textStyle="bold" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/supportedBrowsers" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:textSize="16sp" - tools:text="Firefox: Fill and save credentials" /> - - - </LinearLayout> -</ScrollView> diff --git a/app/src/main/res/layout/password_creation_activity.xml b/app/src/main/res/layout/password_creation_activity.xml deleted file mode 100644 index c0692753..00000000 --- a/app/src/main/res/layout/password_creation_activity.xml +++ /dev/null @@ -1,147 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - - -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:fillViewport="false" - tools:context="app.passwordstore.ui.crypto.PasswordCreationActivity"> - - <androidx.constraintlayout.widget.ConstraintLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:padding="@dimen/activity_horizontal_margin"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/directory_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_gravity="center_vertical" - android:layout_margin="8dp" - android:enabled="false" - android:hint="@string/directory_hint" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/directory" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:imeOptions="actionNext" - android:inputType="textNoSuggestions" - android:nextFocusForward="@id/password" - tools:text="CATEGORY HERE" /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/name_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_gravity="center_vertical" - android:layout_margin="8dp" - android:hint="@string/crypto_name_hint" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/directory_input_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/filename" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:imeOptions="actionNext" - android:inputType="textNoSuggestions" - android:nextFocusForward="@id/username" /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:visibility="gone" - tools:visibility="visible" - android:id="@+id/username_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:hint="@string/crypto_username_hint" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/name_input_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/username" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:imeOptions="actionNext" - android:inputType="textNoSuggestions" - android:nextFocusForward="@id/password" /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/password_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:hint="@string/crypto_pass_label" - app:endIconMode="password_toggle" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/username_input_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/password" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:imeOptions="actionDone" - android:inputType="textVisiblePassword" /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.button.MaterialButton - android:id="@+id/generate_password" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:text="@string/pwd_generate_button" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/password_input_layout" /> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/extra_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:hint="@string/crypto_extra_label" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/generate_password"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/extra_content" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textMultiLine|textVisiblePassword" /> - - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.button.MaterialButton - android:id="@+id/otp_import_button" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:text="@string/add_otp" - app:icon="@drawable/ic_qr_code_scanner" - app:iconTint="?attr/colorOnSecondary" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/extra_input_layout" /> - - <com.google.android.material.switchmaterial.SwitchMaterial - android:id="@+id/encrypt_username" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:text="@string/crypto_encrypt_username_label" - android:visibility="gone" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/otp_import_button" - tools:visibility="visible" /> - </androidx.constraintlayout.widget.ConstraintLayout> -</ScrollView> diff --git a/app/src/main/res/layout/password_recycler_view.xml b/app/src/main/res/layout/password_recycler_view.xml deleted file mode 100644 index 49b7ff82..00000000 --- a/app/src/main/res/layout/password_recycler_view.xml +++ /dev/null @@ -1,39 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:clipChildren="false" - android:clipToPadding="false" - android:orientation="vertical" - tools:context="app.passwordstore.ui.passwords.PasswordFragment"> - - <androidx.swiperefreshlayout.widget.SwipeRefreshLayout - android:id="@+id/swipe_refresher" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <me.zhanghai.android.fastscroll.FixOnItemTouchListenerRecyclerView - android:id="@+id/pass_recycler" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:scrollbars="none" - tools:itemCount="20" - tools:listitem="@layout/password_row_layout" /> - </androidx.swiperefreshlayout.widget.SwipeRefreshLayout> - - <com.google.android.material.floatingactionbutton.FloatingActionButton - android:id="@+id/fab" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_alignParentEnd="true" - android:layout_alignParentBottom="true" - android:layout_gravity="bottom|end" - android:layout_margin="@dimen/fab_compat_margin" - android:contentDescription="@string/password_list_fab_content_description" - android:src="@drawable/ic_add_48dp" /> -</androidx.coordinatorlayout.widget.CoordinatorLayout> diff --git a/app/src/main/res/layout/password_row_layout.xml b/app/src/main/res/layout/password_row_layout.xml deleted file mode 100644 index 5c042839..00000000 --- a/app/src/main/res/layout/password_row_layout.xml +++ /dev/null @@ -1,50 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="@drawable/password_row_background" - android:paddingTop="12dp" - android:paddingBottom="12dp"> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/label" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_gravity="bottom" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:textSize="18sp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@id/child_count" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="FILE_NAME" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/child_count" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginEnd="8dp" - android:textSize="18sp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@id/folder_indicator" - app:layout_constraintTop_toTopOf="parent" - tools:text="12" /> - - <androidx.appcompat.widget.AppCompatImageView - android:id="@+id/folder_indicator" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:contentDescription="@null" - android:src="@drawable/ic_keyboard_arrow_right_24dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/select_folder_layout.xml b/app/src/main/res/layout/select_folder_layout.xml deleted file mode 100644 index 78b03579..00000000 --- a/app/src/main/res/layout/select_folder_layout.xml +++ /dev/null @@ -1,16 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical"> - - <LinearLayout - android:id="@+id/pgp_handler_linearlayout" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" /> -</LinearLayout> diff --git a/app/src/main/res/menu/context_pass.xml b/app/src/main/res/menu/context_pass.xml deleted file mode 100644 index 608d7eef..00000000 --- a/app/src/main/res/menu/context_pass.xml +++ /dev/null @@ -1,34 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - tools:context=".pwdstore"> - - <item - android:id="@+id/menu_move_password" - android:icon="@drawable/ic_move_folder_24dp" - android:title="@string/move" - app:showAsAction="ifRoom" /> - - <item - android:id="@+id/menu_delete_password" - android:icon="@drawable/ic_delete_24dp" - android:title="@string/delete" - app:showAsAction="ifRoom" /> - - <item - android:id="@+id/menu_edit_password" - android:icon="@drawable/ic_edit_24dp" - android:title="@string/edit" - app:showAsAction="ifRoom" /> - - <item - android:id="@+id/menu_pin_password" - android:icon="@drawable/ic_push_pin_24dp" - android:title="@string/place_shortcut_on_home_screen" - app:showAsAction="ifRoom" /> -</menu> diff --git a/app/src/main/res/menu/main_menu_git.xml b/app/src/main/res/menu/main_menu_git.xml deleted file mode 100644 index 1b64c083..00000000 --- a/app/src/main/res/menu/main_menu_git.xml +++ /dev/null @@ -1,36 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto"> - - <item - android:id="@+id/action_search" - android:icon="@drawable/ic_search_24dp" - android:title="@string/action_search" - app:actionViewClass="androidx.appcompat.widget.SearchView" - app:showAsAction="always|collapseActionView" /> - - <item - android:id="@+id/git_sync" - android:title="@string/git_sync" /> - - <item - android:id="@+id/git_pull" - android:title="@string/git_pull" /> - - <item - android:id="@+id/git_push" - android:title="@string/git_push" /> - - <item - android:id="@+id/refresh" - android:title="@string/refresh_list" /> - - <item - android:id="@+id/user_pref" - android:orderInCategory="100" - android:title="@string/action_settings" /> -</menu> diff --git a/app/src/main/res/menu/main_menu_no_auth.xml b/app/src/main/res/menu/main_menu_no_auth.xml deleted file mode 100644 index 46f19e49..00000000 --- a/app/src/main/res/menu/main_menu_no_auth.xml +++ /dev/null @@ -1,28 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto"> - - <item - android:id="@+id/action_search" - android:icon="@drawable/ic_search_24dp" - android:title="@string/action_search" - app:actionViewClass="androidx.appcompat.widget.SearchView" - app:showAsAction="always|collapseActionView" /> - - <item - android:id="@+id/git_pull" - android:title="@string/git_pull" /> - - <item - android:id="@+id/refresh" - android:title="@string/refresh_list" /> - - <item - android:id="@+id/user_pref" - android:orderInCategory="100" - android:title="@string/action_settings" /> -</menu> diff --git a/app/src/main/res/menu/main_menu_non_git.xml b/app/src/main/res/menu/main_menu_non_git.xml deleted file mode 100644 index 5a7cf00b..00000000 --- a/app/src/main/res/menu/main_menu_non_git.xml +++ /dev/null @@ -1,24 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto"> - - <item - android:id="@+id/action_search" - android:icon="@drawable/ic_search_24dp" - android:title="@string/action_search" - app:actionViewClass="androidx.appcompat.widget.SearchView" - app:showAsAction="always|collapseActionView" /> - - <item - android:id="@+id/refresh" - android:title="@string/refresh_list" /> - - <item - android:id="@+id/user_pref" - android:orderInCategory="100" - android:title="@string/action_settings" /> -</menu> diff --git a/app/src/main/res/menu/pgp_handler.xml b/app/src/main/res/menu/pgp_handler.xml deleted file mode 100644 index a8b6a524..00000000 --- a/app/src/main/res/menu/pgp_handler.xml +++ /dev/null @@ -1,28 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:pwstore="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - tools:context=".ui.crypto.PgpActivity"> - <item - android:id="@+id/share_password_as_plaintext" - android:icon="@drawable/ic_share_24dp" - android:title="@string/share_as_plaintext" - android:visible="false" - pwstore:showAsAction="ifRoom" /> - <item - android:id="@+id/copy_password" - android:icon="@drawable/ic_content_copy" - android:title="@string/copy_password" - android:visible="false" - pwstore:showAsAction="ifRoom" /> - <item - android:id="@+id/edit_password" - android:icon="@drawable/ic_edit_24dp" - android:title="@string/edit_password" - android:visible="false" - pwstore:showAsAction="ifRoom" /> -</menu> diff --git a/app/src/main/res/menu/pgp_handler_new_password.xml b/app/src/main/res/menu/pgp_handler_new_password.xml deleted file mode 100644 index 6c61fb55..00000000 --- a/app/src/main/res/menu/pgp_handler_new_password.xml +++ /dev/null @@ -1,20 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:pwstore="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - tools:context=".ui.crypto.PgpActivity"> - <item - android:id="@+id/save_password" - android:icon="@drawable/ic_save_24dp" - android:title="@string/crypto_save" - pwstore:showAsAction="ifRoom" /> - <item - android:id="@+id/save_and_copy_password" - android:icon="@drawable/ic_save_copy_24dp" - android:title="@string/crypto_save_and_copy" - pwstore:showAsAction="ifRoom" /> -</menu> diff --git a/app/src/main/res/menu/pgp_handler_select_folder.xml b/app/src/main/res/menu/pgp_handler_select_folder.xml deleted file mode 100644 index d9505814..00000000 --- a/app/src/main/res/menu/pgp_handler_select_folder.xml +++ /dev/null @@ -1,15 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:pwstore="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - tools:context="app.passwordstore.SelectFolderActivity"> - <item - android:id="@+id/crypto_select" - android:icon="@drawable/ic_done_24dp" - android:title="@string/crypto_select" - pwstore:showAsAction="ifRoom" /> -</menu> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7499fe5e..a0a20dc7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -24,7 +24,6 @@ </plurals> <string name="delete_directory_progress_text">Wird gelöscht</string> <string name="move">Verschieben</string> - <string name="edit">Bearbeiten</string> <string name="delete">Löschen</string> <string name="password_exists_title">Passwort existiert bereits!</string> <string name="password_exists_message">Dies wird %1$s mit %2$s überschreiben.</string> @@ -40,7 +39,6 @@ <!-- PGPHandler --> <string name="clipboard_copied_text">In die Zwischenablage kopiert</string> <string name="file_toast_text">Bitte setzen Sie einen Dateinamen.</string> - <string name="path_toast_text">Bitte setzen Sie einen Pfad.</string> <string name="empty_toast_text">Sie können kein leeres Passwort oder leere Extra-Angaben setzen.</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Es ist ein Fehler während der Git-Operation aufgetreten.</string> @@ -64,7 +62,6 @@ <string name="crypto_name_hint">Name</string> <string name="crypto_pass_label">Passwort</string> <string name="crypto_extra_label">Zusätzliche Angaben</string> - <string name="crypto_encrypt_username_label">Benutzername verschlüsseln</string> <string name="crypto_select">Auswählen</string> <string name="crypto_save">Speichern</string> <string name="crypto_save_and_copy">Speichern und kopieren</string> @@ -99,8 +96,6 @@ <string name="pref_recursive_filter_summary">Findet Passwörter auch in Unterordnern.</string> <string name="pref_sort_order_title">Passwortsortierung</string> <string name="pref_folder_first_sort_order">Ordner zuerst</string> - <string name="pref_file_first_sort_order">Dateien zuerst</string> - <string name="pref_type_independent_sort_order">typunabhängig</string> <string name="pref_recently_used_sort_order">Zuletzt verwendet</string> <string name="pref_category_autofill_title">Automatisch ausfüllen</string> <string name="pref_autofill_enable_title">Autofill aktivieren</string> @@ -126,7 +121,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Passwort erstellen</string> <string name="pwgen_generate">Erstellen</string> - <string name="pwgen_include">Einschließen</string> <string name="pwgen_length">Länge</string> <string name="pwgen_numerals">Nummern</string> <string name="pwgen_symbols">Symbole</string> @@ -167,7 +161,6 @@ <string name="dialog_yes">Ja</string> <string name="dialog_no">Nein</string> <string name="dialog_cancel">Abbruch</string> - <string name="dialog_skip">Überspringen</string> <string name="git_sync">Synchronisiere Repository</string> <string name="git_pull">Git Pull</string> <string name="git_push">Git Push</string> @@ -175,10 +168,7 @@ <string name="git_log">Commit-Log anzeigen</string> <string name="show_password_pref_title">Zeige das Passwort.</string> <string name="show_password_pref_summary">Soll das entschlüsselte Passwort sichtbar sein? Dies deaktiviert nicht das Kopieren.</string> - <string name="pwd_generate_button">Erstellen</string> - <string name="refresh_list">Liste aktualisieren</string> <string name="send_plaintext_password_to">Passwort unverschlüsselt senden an…</string> - <string name="app_icon_hint">App-Icon</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Eintrag zum Ausfüllen auswählen</string> <string name="oreo_autofill_strict_domain_search">Phishing-resistente Suche</string> @@ -205,12 +195,11 @@ <string name="oreo_autofill_password_fill_support">Passwörter ausfüllen</string> <string name="oreo_autofill_flaky_fill_support">Passwörter ausfüllen (manchmal kann ein Neustart des Browsers erforderlich sein)</string> <string name="oreo_autofill_no_support">Kein Support</string> - <string name="oreo_autofill_enable_dialog_description">Password Store kann das Ausfüllen von Anmeldeformularen und sogar das Speichern von Anmeldedaten in Apps oder auf Webseiten übernehmen.</string> - <string name="oreo_autofill_enable_dialog_instructions">Tippen Sie auf OK, um diese Funktion zu aktivieren und um zu den Autofill-Einstellungen zu gelangen. Dort wählen Sie \"Password Store\" aus der Liste und bestätigen mit \"OK\".</string> + <string name="oreo_autofill_enable_dialog_description">HardenedPass kann das Ausfüllen von Anmeldeformularen und sogar das Speichern von Anmeldedaten in Apps oder auf Webseiten übernehmen.</string> + <string name="oreo_autofill_enable_dialog_instructions">Tippen Sie auf OK, um diese Funktion zu aktivieren und um zu den Autofill-Einstellungen zu gelangen. Dort wählen Sie \"HardenedPass\" aus der Liste und bestätigen mit \"OK\".</string> <string name="oreo_autofill_enable_dialog_installed_browsers">Autofill-Unterstützung mit installierten Browsern:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">Der private SSH-Schlüssel konnte nicht geöffnet werden. Bitte überprüfen Sie, ob die Datei existiert.</string> - <string name="new_password_title">Neues Passwort</string> <string name="clear_saved_passphrase_ssh">Gespeicherte Passphrase für lokalen SSH-Schlüssel löschen</string> <string name="clear_saved_passphrase_https">Gespeichertes HTTPS-Passwort löschen</string> <string name="git_operation_remember_passphrase">Schlüsselpasswort merken</string> @@ -226,7 +215,6 @@ <string name="pref_biometric_auth_title">Biometrische Authentifizierung</string> <string name="pref_biometric_auth_summary">Falls aktiviert, werden Sie beim Starten der App nach Ihrem Fingerabdruck gefragt.</string> <string name="pref_biometric_auth_summary_error">Fingerabdrucksensor fehlt oder ist nicht ansprechbar.</string> - <string name="pref_title_openkeystore_clear_keyid">Lösche gespeicherte OpenKeystore SSH-Schlüssel-ID.</string> <string name="your_public_key">Ihr öffentlicher Schlüssel</string> <string name="error_generate_ssh_key">Fehler beim Erstellen des SSH-Schlüssels</string> <string name="pref_show_hidden_title">Alle Dateien und Ordner anzeigen</string> @@ -234,7 +222,6 @@ <string name="title_create_folder">Ordner erstellen</string> <string name="title_rename_folder">Ordner umbenennen</string> <string name="message_category_error_empty_field">Der Name der Kategorie darf nicht leer sein.</string> - <string name="message_category_error_category_exists">Der Kategoriename existiert bereits.</string> <string name="message_error_destination_outside_repo">Ziel muss innerhalb des Repositorys sein.</string> <string name="message_rename_folder">Ziel für %1$s angeben</string> <string name="button_create">Erstellen</string> @@ -269,8 +256,8 @@ <string name="preference_custom_public_suffixes_hint">company.com\npersonal.com</string> <!-- Password creation failure --> <string name="password_creation_file_fail_title">Fehler</string> - <string name="password_creation_file_write_fail_message">Fehler beim Speichern der Passwortdatei im Password Store, bitte versuchen Sie es erneut.</string> - <string name="password_creation_file_delete_fail_message">Fehler beim Löschen der Passwortdatei %1$s aus dem Password Store, bitte löschen Sie die Datei von Hand.</string> + <string name="password_creation_file_write_fail_message">Fehler beim Speichern der Passwortdatei im Repository, bitte versuchen Sie es erneut.</string> + <string name="password_creation_file_delete_fail_message">Fehler beim Löschen der Passwortdatei %1$s aus dem Repository, bitte löschen Sie die Datei von Hand.</string> <string name="password_creation_duplicate_error">Datei existiert bereits, bitte benutzen Sie einen anderen Dateinamen.</string> <string name="add_otp">OTP hinzufügen</string> <string name="otp_import_success">TOTP-Konfiguration erfolgreich importiert</string> @@ -280,11 +267,9 @@ <string name="invalid_gpg_id">.gpg-id wurde gefunden, enthält jedoch ungültige Schlüssel-ID, Fingerabdruck oder Benutzer-ID.</string> <string name="short_gpg_id">.gpg-id wurde gefunden, enthält jedoch eine kurze Hex-ID, die nicht unterstützt wird.</string> <string name="invalid_filename_text">Dateiname darf kein \'/\' enthalten; Verzeichnis oben setzen</string> - <string name="directory_hint">Ordner</string> - <string name="new_folder_set_gpg_key">PGP-Schlüssel für Ordner festlegen</string> <!-- GitException messages --> <string name="git_unknown_error">Unbekannter Fehler</string> - <string name="git_pull_rebase_fail_error">Pull fehlgeschlagen. Sie befinden sich in einem abgelösten Head. Speichern Sie die Änderungen in \"Einstellungen > Lokale Git-Konfiguration & Dienstprogramme\" auf das Remote-Repository in einem neuen Branch und lösen Sie den Konflikt auf Ihrem Computer auf.</string> + <string name="git_pull_rebase_fail_error">Pull fehlgeschlagen. Sie befinden sich in einem abgelösten Head. Speichern Sie die Änderungen in \"Einstellungen > Repository > Lokale Git-Konfiguration & Dienstprogramme\" auf das Remote-Repository in einem neuen Branch und lösen Sie den Konflikt auf Ihrem Computer auf.</string> <string name="git_pull_merge_fail_error">Merge fehlgeschlagen, Sie befinden sich in einem Konflikt.</string> <string name="git_push_nff_error">Push wurde vom Remote abgelehnt. Führen Sie den Pull vor dem Push aus. Sie können \"Synchronisieren\" statt Pull/Push nutzen, da es beides implementiert</string> <string name="git_push_generic_error">Push wurde von Remote abgelehnt, Grund: %1$s</string> @@ -294,16 +279,12 @@ <string name="git_break_out_of_detached_success">Es gab einen Konflikt während des Rebase-Prozesses. Ihr lokaler Branch %1$s wurde auf einen anderen Branch namens %2$s gepusht.\nNutzen Sie diesen Branch, um den Konflikt auf dem Server zu beheben</string> <string name="git_break_out_of_detached_unneeded">Das Repository befindet sich nicht im Rebase-Prozess; es ist nicht nötig, den Zustand auf einen weiteren Branch zu pushen.</string> <!-- GPG key selection in folder creation --> - <string name="gpg_key_select_mandatory">Um fortzufahren, muss ein PGP-Schlüssel ausgewählt werden.</string> <string name="folder_creation_err_file_exists">Dateiname bereits vergeben</string> <string name="folder_creation_err_folder_exists">Ein Ordner mit diesem Namen existiert bereits.</string> <!-- Onboarding flow --> - <string name="let_s_go">Los geht\'s</string> - <string name="select_n_repository_type">Wählen Sie einen \nRepository-Typ.</string> <string name="select_repo_type_text">Wählen Sie aus, ob Sie ein lokales Repository anlegen, oder ein Remote-Repository klonen möchten.</string> <string name="clone_remote_repo">Remote-Repository klonen</string> <string name="create_local_repo">Lokales Repository anlegen</string> - <string name="select_gpg_key_title">Wählen Sie einen\nPGP-Schlüssel.</string> <string name="select_gpg_key_message">Wählen einen PGP-Schlüssel, mit dem der Store initialisiert werden soll.</string> <string name="gpg_key_select">Schlüssel auswählen</string> <!-- SSH port validation --> @@ -324,11 +305,9 @@ <string name="clear_saved_host_key_success">Host-Schlüssel erfolgreich gelöscht!</string> <string name="otp_import_qr_code">QR-Code scannen</string> <string name="otp_import_from_file">Wählen Sie ein Bild.</string> - <string name="otp_import_manual_entry">Manuell eingeben</string> <string name="otp_import_manual_hint_secret">Schlüssel</string> <string name="otp_import_manual_hint_account">Benutzer-Konto</string> <string name="place_shortcut_on_home_screen">Verknüpfung auf dem Home-Screen erstellen</string> - <string name="password_list_fab_content_description">Neues Passwort oder neuen Ordner erstellen</string> <string name="pgp_key_import_failed">Fehler beim Import des PGP-Schlüssels</string> <string name="pgp_key_import_failed_replace_message">Ein bestehender Schlüssel mit dieser ID wurde gefunden. Möchten Sie ihn ersetzen?</string> <string name="pgp_key_import_succeeded">PGP-Schlüssel erfolgreich importiert</string> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6c8ac709..d4d4d424 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -24,7 +24,6 @@ </plurals> <string name="delete_directory_progress_text">Suppression…</string> <string name="move">Déplacer</string> - <string name="edit">Éditer</string> <string name="delete">Supprimer</string> <string name="password_exists_title">Le mot de passe existe!</string> <string name="password_exists_message">Cela écrasera %1$s avec %2$s.</string> @@ -39,7 +38,6 @@ <!-- PGPHandler --> <string name="clipboard_copied_text">Copié dans le presse-papiers</string> <string name="file_toast_text">Veuillez fournir un nom de fichier</string> - <string name="path_toast_text">Veuillez fournir un chemin d\'accès au fichier</string> <string name="empty_toast_text">Vous ne pouvez pas utiliser un mot de passe vide ou des données supplémentaires vide</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Une erreur c\'est produite lors d\'une opération Git</string> @@ -63,7 +61,6 @@ <string name="crypto_name_hint">Nom</string> <string name="crypto_pass_label">Mot de passe</string> <string name="crypto_extra_label">Extra</string> - <string name="crypto_encrypt_username_label">Chiffrer le nom d\'utilisateur</string> <string name="crypto_select">Sélectionner</string> <string name="crypto_save">Sauvegarder</string> <string name="crypto_save_and_copy">Enregistrer et copier</string> @@ -97,8 +94,6 @@ <string name="pref_recursive_filter_summary">Cherche le mot de passe dans tous les sous-répertoires du répertoire actuel.</string> <string name="pref_sort_order_title">Ordre de tri des mots de passe</string> <string name="pref_folder_first_sort_order">Dossiers en premier</string> - <string name="pref_file_first_sort_order">Fichiers en premier</string> - <string name="pref_type_independent_sort_order">Indifférent au type d\'entrée</string> <string name="pref_recently_used_sort_order">Récemment utilisé</string> <string name="pref_category_autofill_title">Saisie automatique</string> <string name="pref_autofill_enable_title">Saisie automatique</string> @@ -114,7 +109,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Générer un mot de passe</string> <string name="pwgen_generate">Générer</string> - <string name="pwgen_include">Inclure</string> <string name="pwgen_length">Longueur</string> <string name="pwgen_numerals">Chiffres</string> <string name="pwgen_symbols">Symboles</string> @@ -155,7 +149,6 @@ <string name="dialog_yes">Oui</string> <string name="dialog_no">Non</string> <string name="dialog_cancel">Annuler</string> - <string name="dialog_skip">Ignorer</string> <string name="git_sync">Synchronisation du dépôt</string> <string name="git_pull">Importer du serveur</string> <string name="git_push">Pousser sur le serveur</string> @@ -163,10 +156,7 @@ <string name="git_log">Afficher l\'historique des commits</string> <string name="show_password_pref_title">Montrer le mot de passe</string> <string name="show_password_pref_summary">Contrôle la visibilité des mots de passe une fois déchifrées, ceci n\'empêche pas de copier le mot de passe</string> - <string name="pwd_generate_button">Générer</string> - <string name="refresh_list">Rafraichir la liste</string> <string name="send_plaintext_password_to">Envoyer le mot de passe en clair via…</string> - <string name="app_icon_hint">Icône de l\'application</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Sélectionnez l\'entrée à remplir</string> <string name="oreo_autofill_strict_domain_search">Recherche protégée contre le hameçonnage</string> @@ -193,12 +183,11 @@ <string name="oreo_autofill_password_fill_support">Remplir le mot de passe</string> <string name="oreo_autofill_flaky_fill_support">Remplir les mots de passe (peut nécessiter un redémarrage du navigateur de temps à autre)</string> <string name="oreo_autofill_no_support">Pas de support</string> - <string name="oreo_autofill_enable_dialog_description">Password Store vous permet de remplir des formulaires de connexion ainsi que d\'enregistrer vos identifiants d\'applications ou de sites Web.</string> - <string name="oreo_autofill_enable_dialog_instructions">Pour activer cette fonctionnalité, appuyez sur OK pour aller dans les paramètres de saisie automatique, sélectionnez Password Store dans la liste puis confirmez avec OK.</string> + <string name="oreo_autofill_enable_dialog_description">HardenedPass vous permet de remplir des formulaires de connexion ainsi que d\'enregistrer vos identifiants d\'applications ou de sites Web.</string> + <string name="oreo_autofill_enable_dialog_instructions">Pour activer cette fonctionnalité, appuyez sur OK pour aller dans les paramètres de saisie automatique, sélectionnez HardenedPass dans la liste puis confirmez avec OK.</string> <string name="oreo_autofill_enable_dialog_installed_browsers">Prise en charge du remplissage automatique avec les navigateurs installés:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">Impossible d\'ouvrir la clef ssh, merci de vérifier que le ficher existe</string> - <string name="new_password_title">Nouveau mot de passe</string> <string name="clear_saved_passphrase_ssh">Effacer le mot de passe enregistrée pour la clé SSH locale</string> <string name="clear_saved_passphrase_https">Effacer le mot de passe HTTPS enregistré</string> <string name="git_operation_remember_passphrase">Se rappeler de la phrase secrète dans la configuration de l\'application (peu sûr)</string> @@ -212,9 +201,8 @@ <string name="biometric_prompt_title">Identification biométrique</string> <string name="biometric_auth_error_reason">Échec de l\'authentification : %s</string> <string name="pref_biometric_auth_title">Activer l\'authentification biométrique</string> - <string name="pref_biometric_auth_summary">Lorsque cette option est activée, Password Store vous demandera votre empreinte digitale au lancement</string> + <string name="pref_biometric_auth_summary">Lorsque cette option est activée, HardenedPass vous demandera votre empreinte digitale au lancement</string> <string name="pref_biometric_auth_summary_error">Lecteur d\'empreinte digitale non accessible ou manquant</string> - <string name="pref_title_openkeystore_clear_keyid">Effacer la clé d\'identification SSH d\'OpenKeystore mémorisée</string> <string name="your_public_key">Votre clé publique</string> <string name="error_generate_ssh_key">Une erreur est survenue pendant la génération de la clé ssh</string> <string name="pref_show_hidden_title">Afficher tous les fichiers et dossiers</string> @@ -222,7 +210,6 @@ <string name="title_create_folder">Créer un dossier</string> <string name="title_rename_folder">Renommer le dossier</string> <string name="message_category_error_empty_field">Le nom de la catégorie ne peut pas être vide</string> - <string name="message_category_error_category_exists">Une catégorie du même nom existe déjà</string> <string name="message_error_destination_outside_repo">La destination doit être à l\'intérieur du dépôt</string> <string name="message_rename_folder">Entrez le chemin pour %1$s</string> <string name="button_create">Créer</string> @@ -265,11 +252,9 @@ <string name="otp_import_failure_generic">Impossible d\'importer la configuration TOTP</string> <string name="exporting_passwords">Exportation des mots de passe…</string> <string name="invalid_filename_text">Le nom du fichier ne doit pas contenir \'/\', définir le répertoire parent</string> - <string name="directory_hint">Dossier</string> - <string name="new_folder_set_gpg_key">Définir la clé GPG pour le dossier</string> <!-- GitException messages --> <string name="git_unknown_error">Erreur inconnue</string> - <string name="git_pull_rebase_fail_error">Tirer a échoué, vous êtes dans un \'head\' détaché. En utilisant \"settings > git utils\", enregistrez vos modifications sur le dépôt distant dans une nouvelle branche et résolvez le conflit sur votre ordinateur.</string> + <string name="git_pull_rebase_fail_error">Tirer a échoué, vous êtes dans un \'head\' détaché. En utilisant \"Paramètres > Dépôt > Configuration locale de Git & utilitaires\", enregistrez vos modifications sur le dépôt distant dans une nouvelle branche et résolvez le conflit sur votre ordinateur.</string> <string name="git_pull_merge_fail_error">La fusion a échoué, vous êtes dans un état de conflit.</string> <string name="git_push_nff_error">La poussée a été rejetée par le dépôt distant, exécutez une tirée avant de pousser à nouveau. Vous pouvez utiliser Synchroniser plutôt que de tirer/pousser car il implémente les deux.</string> <string name="git_push_generic_error">L\'envoi a été rejeté par le dépôt distant, raison : %1$s</string> @@ -282,12 +267,9 @@ <string name="folder_creation_err_file_exists">Un fichier portant ce nom existe déjà</string> <string name="folder_creation_err_folder_exists">Un dossier portant ce nom existe déjà</string> <!-- Onboarding flow --> - <string name="let_s_go">Allons-y</string> - <string name="select_n_repository_type">Sélectionnez le type de dépôt \n</string> <string name="select_repo_type_text">Sélectionnez si vous voulez créer un dépôt local ou cloner un dépôt distant.</string> <string name="clone_remote_repo">Cloner le dépôt distant</string> <string name="create_local_repo">Créer un dépôt local</string> - <string name="select_gpg_key_title">Sélectionnez\nla clé GPG</string> <string name="select_gpg_key_message">Sélectionnez une clé GPG pour initialiser votre magasin</string> <string name="gpg_key_select">Sélectionner la clé</string> <!-- SSH port validation --> @@ -305,10 +287,8 @@ <string name="clear_saved_host_key_success">La clé d\'hôte a été effacée avec succès!</string> <string name="otp_import_qr_code">Scanner le code QR</string> <string name="otp_import_from_file">Choisissez une image</string> - <string name="otp_import_manual_entry">Saisir manuellement</string> <string name="otp_import_manual_hint_secret">Secret</string> <string name="otp_import_manual_hint_account">Compte</string> - <string name="password_list_fab_content_description">Créer un nouveau mot de passe ou dossier</string> <string name="pgp_key_import_failed">Impossible d\'importer la clé PGP</string> <string name="pgp_key_import_succeeded">Clé PGP importée avec succès</string> <string name="pgp_key_import_succeeded_message">La clé d\'identification de la clé importée est donné ci-dessous, veuillez la vérifier pour qu\'elle soit correcte :\n%1$s</string> diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 4ca39712..f966230c 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -24,7 +24,6 @@ </plurals> <string name="delete_directory_progress_text">Eliminando…</string> <string name="move">Mover</string> - <string name="edit">Editar</string> <string name="delete">Eliminar</string> <string name="password_exists_title">O contrasinal xa existe!</string> <string name="password_exists_message">Vas sobrescribir %1$s con %2$s.</string> @@ -40,7 +39,6 @@ <!-- PGPHandler --> <string name="clipboard_copied_text">Copiado ao portapapeis</string> <string name="file_toast_text">Debes proporcionar un nome de ficheiro</string> - <string name="path_toast_text">Por favor indica a ruta ao ficheiro</string> <string name="empty_toast_text">Non podes deixar baleiro o contrasinal ou o contido extra</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Algo fallou na operación de Git</string> @@ -64,7 +62,6 @@ <string name="crypto_name_hint">Nome</string> <string name="crypto_pass_label">Contrasinal</string> <string name="crypto_extra_label">Contido extra</string> - <string name="crypto_encrypt_username_label">Cifrar identificador</string> <string name="crypto_select">Seleccionar</string> <string name="crypto_save">Gardar</string> <string name="crypto_save_and_copy">Gardar e copiar</string> @@ -100,8 +97,6 @@ <string name="pref_directory_structure">Organización dos contrasinais</string> <string name="pref_sort_order_title">Orde para mostrar contrasinais</string> <string name="pref_folder_first_sort_order">Primeiro cartafoles</string> - <string name="pref_file_first_sort_order">Primeiro ficheiros</string> - <string name="pref_type_independent_sort_order">Independentemente do tipo</string> <string name="pref_recently_used_sort_order">Usadas recentemente</string> <string name="pref_category_autofill_title">Completado automático</string> <string name="pref_autofill_enable_title">Activar completado automático</string> @@ -127,7 +122,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Crear contrasinal</string> <string name="pwgen_generate">Crear</string> - <string name="pwgen_include">Incluír</string> <string name="pwgen_length">Lonxitude</string> <string name="pwgen_numerals">Números</string> <string name="pwgen_symbols">Símbolos</string> @@ -168,7 +162,6 @@ <string name="dialog_yes">Si</string> <string name="dialog_no">Non</string> <string name="dialog_cancel">Cancelar</string> - <string name="dialog_skip">Omitir</string> <string name="git_sync">Sincronizar repositorio</string> <string name="git_pull">Traer desde remoto</string> <string name="git_push">Enviar a remoto</string> @@ -176,10 +169,7 @@ <string name="git_log">Mostrar rexistro dos cambios</string> <string name="show_password_pref_title">Mostrar contrasinal</string> <string name="show_password_pref_summary">Controlar a visibilidade do contrasinal unha vez descifrado, esto non desactiva o copiado do contrasinal</string> - <string name="pwd_generate_button">Crear</string> - <string name="refresh_list">Actualizar lista</string> <string name="send_plaintext_password_to">Enviar contrasinal como texto plano usando…</string> - <string name="app_icon_hint">Icona da app</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Elixe o elemento a completar</string> <string name="oreo_autofill_strict_domain_search">Busca resistente ao phishing</string> @@ -192,7 +182,7 @@ <string name="oreo_autofill_save_passwords_dont_match">Non concordan os contrasinais</string> <string name="oreo_autofill_generate_password">Crear elemento</string> <string name="oreo_autofill_max_matches_reached">Acadouse o máximo número de coincidencias (%1$d); eliminar as coincidencias antes de engadir máis.</string> - <string name="oreo_autofill_warning_publisher_header">A autoría desta app cambiou desde que rexistraches a entrada Password Store con ela:</string> + <string name="oreo_autofill_warning_publisher_header">A autoría desta app cambiou desde que rexistraches a entrada HardenedPass con ela:</string> <string name="oreo_autofill_warning_publisher_footer"><b>A aplicación actualmente instalada podería intentar roubar as túas credenciais pretendendo ser unha app de confianza.</b>\n\nIntenta desinstalar e reinstalar a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid ou a tenda do fabricante do teu móbil.</string> <string name="oreo_autofill_warning_publisher_install_time">Instalada: %1$s</string> @@ -207,12 +197,11 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid <string name="oreo_autofill_password_fill_support">Completar contrasinais</string> <string name="oreo_autofill_flaky_fill_support">Completa as credenciais (poderías ter que reiniciar o navegador de cando en vez)</string> <string name="oreo_autofill_no_support">Sen soporte</string> - <string name="oreo_autofill_enable_dialog_description">Password Store pode ofrecerche completar formularios e incluso gardar contrasinais que escribes en apps e sitios web.</string> - <string name="oreo_autofill_enable_dialog_instructions">Para habilitar esta característica toca en OK e vaite ós axustes de autocompletado. Alí, escolle Password Store da lista e confirma a solicitude premendo no OK.</string> + <string name="oreo_autofill_enable_dialog_description">HardenedPass pode ofrecerche completar formularios e incluso gardar contrasinais que escribes en apps e sitios web.</string> + <string name="oreo_autofill_enable_dialog_instructions">Para habilitar esta característica toca en OK e vaite ós axustes de autocompletado. Alí, escolle HardenedPass da lista e confirma a solicitude premendo no OK.</string> <string name="oreo_autofill_enable_dialog_installed_browsers">Soporte de autocompletado cos navegadores:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">Non se pode abrir a chave privada ssh, comproba que existe tal ficheiro</string> - <string name="new_password_title">Novo contrasinal</string> <string name="clear_saved_passphrase_ssh">Baleirar a frase de paso gardada para a chave SSH local</string> <string name="clear_saved_passphrase_https">Baleirar contrasinal HTTPS gardado</string> <string name="git_operation_remember_passphrase">Lembrar frase de paso da chave</string> @@ -226,9 +215,8 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid <string name="biometric_prompt_title">Petición biométrica</string> <string name="biometric_auth_error_reason">Fallou a autenticación: %s</string> <string name="pref_biometric_auth_title">Activar autenticación biométrica</string> - <string name="pref_biometric_auth_summary">Ao activala, Password Store vaiche pedir a túa pegada dactilar ao iniciar a app</string> + <string name="pref_biometric_auth_summary">Ao activala, HardenedPass vaiche pedir a túa pegada dactilar ao iniciar a app</string> <string name="pref_biometric_auth_summary_error">O hardware de pegada dixital non é accesible ou existente</string> - <string name="pref_title_openkeystore_clear_keyid">Eliminar o ID lembrado da chave SSH en OpenKeystore</string> <string name="your_public_key">A túa chave pública</string> <string name="error_generate_ssh_key">Algo fallou ao intentar crear a chave-ssh</string> <string name="pref_show_hidden_title">Mostar cartafoles ocultos</string> @@ -236,7 +224,6 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid <string name="title_create_folder">Crear cartafol</string> <string name="title_rename_folder">Renomear cartafol</string> <string name="message_category_error_empty_field">O nome da categoría non pode estar baleiro</string> - <string name="message_category_error_category_exists">Xa existe o nome da categoría</string> <string name="message_error_destination_outside_repo">O destino debe estar dentro do repositorio</string> <string name="message_rename_folder">Escribe o destino para %1$s</string> <string name="button_create">Crear</string> @@ -282,11 +269,9 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid <string name="invalid_gpg_id">Atopouse .gpg-id, pero contén un ID de chave, impresión dixital ou ID de usuaria non válidos</string> <string name="short_gpg_id">Atopouse .gpg-id, pero contén un ID hex curto, ao que non damos soporte</string> <string name="invalid_filename_text">O nome do ficheiro non pode conter \'/\', establece un directorio superior</string> - <string name="directory_hint">Directorio</string> - <string name="new_folder_set_gpg_key">Establece chave GPG para o directorio</string> <!-- GitException messages --> <string name="git_unknown_error">Fallo descoñecido</string> - <string name="git_pull_rebase_fail_error">Fallou a acción pull, estás nun head diferente. Utiliza \"axustes > utilidades git\", garda os cambios no remoto nunha nova rama e resolve o conflicto nun ordenador.</string> + <string name="git_pull_rebase_fail_error">Fallou a acción pull, estás nun head diferente. Utiliza \"Axustes > Repositorio > Configuración e Utilidades locais Git\", garda os cambios no remoto nunha nova rama e resolve o conflicto nun ordenador.</string> <string name="git_pull_merge_fail_error">Fallou o merge, hai un conflito nos datos. TAREFA: engadir método de recuperación.</string> <string name="git_push_nff_error">O push foi rexeitado polo remoto, executa pull antes de voltar a subilos de novo. Podes usar Sincronizar mellor que push/pull xa que inclú ambos.</string> <string name="git_push_generic_error">O push foi rexeitado polo remoto, razón: %1$s</string> @@ -296,16 +281,12 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid <string name="git_break_out_of_detached_success">Hai un conflito ó realizar rebase. A túa póla local %1$s fixo push a outra póla chamada %2$s\nUsa esta póla para resolver o conflito na túa computadora</string> <string name="git_break_out_of_detached_unneeded">O repositorio non cambiou de base, non é preciso cambiar a outra póla</string> <!-- GPG key selection in folder creation --> - <string name="gpg_key_select_mandatory">Hai que elexir unha chave GPG para continuar</string> <string name="folder_creation_err_file_exists">Xa existe un ficheiro con ese nome</string> <string name="folder_creation_err_folder_exists">Xa existe un cartafol con ese nome</string> <!-- Onboarding flow --> - <string name="let_s_go">Imos alá</string> - <string name="select_n_repository_type">Escolle \nTipo de repositorio</string> <string name="select_repo_type_text">Escolle se queres crear un repositorio local ou clonar un remoto.</string> <string name="clone_remote_repo">Clonar repositorio remoto</string> <string name="create_local_repo">Crear repositorio local</string> - <string name="select_gpg_key_title">Elixe\nChave\nGPG</string> <string name="select_gpg_key_message">Elixe a chave GPG coa que queres iniciar a almacenaxe</string> <string name="gpg_key_select">Elixe chave</string> <!-- SSH port validation --> @@ -326,11 +307,9 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid <string name="clear_saved_host_key_success">Eliminouse correctamente a chave gardada do host!</string> <string name="otp_import_qr_code">Escanear código QR</string> <string name="otp_import_from_file">Elixe unha imaxe</string> - <string name="otp_import_manual_entry">Escribir manualmente</string> <string name="otp_import_manual_hint_secret">Segredo</string> <string name="otp_import_manual_hint_account">Conta</string> <string name="place_shortcut_on_home_screen">Crear atallo na pantalla de inicio</string> - <string name="password_list_fab_content_description">Crear novo contrasinal ou cartafol</string> <string name="pgp_key_import_failed">Fallou a importación da chave PGP</string> <string name="pgp_key_import_failed_replace_message">Atopouse unha chave existente con este ID, queres substituíla?</string> <string name="pgp_key_import_succeeded">Chave PGP importada correctamente</string> diff --git a/app/src/main/res/values-hdpi/dimens.xml b/app/src/main/res/values-hdpi/dimens.xml deleted file mode 100644 index f9a452aa..00000000 --- a/app/src/main/res/values-hdpi/dimens.xml +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<resources> - <dimen name="onboarding_icon_margin_top">50dp</dimen> - <dimen name="onboarding_button_margin_top">70dp</dimen> - <dimen name="onboarding_desc_margin_top">48dp</dimen> -</resources> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index dfb18d52..e7c05909 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -24,7 +24,6 @@ </plurals> <string name="delete_directory_progress_text">Eliminazione…</string> <string name="move">Sposta</string> - <string name="edit">Modifica</string> <string name="delete">Elimina</string> <string name="password_exists_title">La password esiste già!</string> <string name="password_exists_message">Questo sovrascriverà %1$s con %2$s.</string> @@ -39,7 +38,6 @@ <!-- PGPHandler --> <string name="clipboard_copied_text">Copiato negli appunti</string> <string name="file_toast_text">Inserisci un nome per il file</string> - <string name="path_toast_text">Inserisci il percorso del file</string> <string name="empty_toast_text">Non puoi usare una password vuota o dei contenuti extra vuoti</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Si è verificato un errore durante un\'operazione Git</string> @@ -63,7 +61,6 @@ <string name="crypto_name_hint">Nome</string> <string name="crypto_pass_label">Password</string> <string name="crypto_extra_label">Contenuto extra</string> - <string name="crypto_encrypt_username_label">Crittografa nome utente</string> <string name="crypto_select">Seleziona</string> <string name="crypto_save">Salva</string> <string name="crypto_save_and_copy">Salva e Copia</string> @@ -97,8 +94,6 @@ <string name="pref_recursive_filter_summary">Trova ricorsivamente le password della directory corrente.</string> <string name="pref_sort_order_title">Ordine password</string> <string name="pref_folder_first_sort_order">Prima le cartelle</string> - <string name="pref_file_first_sort_order">Prima i file</string> - <string name="pref_type_independent_sort_order">Indipendente dal tipo</string> <string name="pref_recently_used_sort_order">Usato di recente</string> <string name="pref_category_autofill_title">Compilazione automatica</string> <string name="pref_autofill_enable_title">Attiva la compilazione automatica</string> @@ -109,7 +104,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Genera Password</string> <string name="pwgen_generate">Genera</string> - <string name="pwgen_include">Includi</string> <string name="pwgen_length">Lunghezza</string> <string name="pwgen_numerals">Numeri</string> <string name="pwgen_symbols">Simboli</string> @@ -150,7 +144,6 @@ <string name="dialog_yes">Sì</string> <string name="dialog_no">No</string> <string name="dialog_cancel">Annulla</string> - <string name="dialog_skip">Salta</string> <string name="git_sync">Sincronizza repository</string> <string name="git_pull">Pull dal remote</string> <string name="git_push">Push sul remote</string> @@ -158,10 +151,7 @@ <string name="git_log">Mostra registro dei commit</string> <string name="show_password_pref_title">Mostra la password</string> <string name="show_password_pref_summary">Controlla la visibilità delle password una volta decrittografate. Questo non disabilita la copia negli appunti.</string> - <string name="pwd_generate_button">Genera</string> - <string name="refresh_list">Aggiorna elenco</string> <string name="send_plaintext_password_to">Invia password come testo semplice usando…</string> - <string name="app_icon_hint">Icona app</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Seleziona la voce da compilare</string> <string name="oreo_autofill_strict_domain_search">Ricerca resistente al phishing</string> @@ -193,7 +183,6 @@ <string name="oreo_autofill_enable_dialog_installed_browsers">Supporto alla compilazione automatica nei browser installati:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">Impossibile aprire la chiave privata SSH, controlla che il file esista</string> - <string name="new_password_title">Nuova password</string> <string name="clear_saved_passphrase_ssh">Cancella la frase segreta salvata per la chiave SSH locale</string> <string name="clear_saved_passphrase_https">Cancella la password HTTPS salvata</string> <string name="git_operation_remember_passphrase">Ricorda la frase segreta della chiave</string> @@ -209,7 +198,6 @@ <string name="pref_biometric_auth_title">Abilita autenticazione biometrica</string> <string name="pref_biometric_auth_summary">Se attiva, HardenedPass chiede l\'impronta digitale all\'avvio dell\'app</string> <string name="pref_biometric_auth_summary_error">L\'hardware delle impronte digitali non è accessibile o mancante</string> - <string name="pref_title_openkeystore_clear_keyid">Elimina l\'ID della Chiave SSH di OpenKeystore memorizzato</string> <string name="your_public_key">La tua chiave pubblica</string> <string name="error_generate_ssh_key">Errore durante la generazione della chiave SSH</string> <string name="pref_show_hidden_title">Mostra tutti i file e cartelle</string> @@ -217,7 +205,6 @@ <string name="title_create_folder">Crea cartella</string> <string name="title_rename_folder">Rinomina cartella</string> <string name="message_category_error_empty_field">Il nome della categoria non può essere vuoto</string> - <string name="message_category_error_category_exists">Il nome della categoria esiste già</string> <string name="message_error_destination_outside_repo">La destinazione deve essere nel repository</string> <string name="message_rename_folder">Inserisci la destinazione per %1$s</string> <string name="button_create">Crea</string> @@ -260,11 +247,9 @@ <string name="otp_import_failure_generic">Impossibile importare la configurazione di TOTP</string> <string name="exporting_passwords">Esportazione delle password…</string> <string name="invalid_filename_text">Il nome del file non deve contenere \'/\', imposta la directory qui sopra</string> - <string name="directory_hint">Directory</string> - <string name="new_folder_set_gpg_key">Imposta la chiave GPG per la directory</string> <!-- GitException messages --> <string name="git_unknown_error">Errore sconosciuto</string> - <string name="git_pull_rebase_fail_error">Pull non riuscito, sei in detached HEAD. Da \"Impostazioni > Utilità Git\" esegui il push delle modifiche sul remote in un nuovo branch e risolvi il conflitto dal computer.</string> + <string name="git_pull_rebase_fail_error">Pull non riuscito, sei in detached HEAD. Da \"Impostazioni > Repository > Configurazione Git locale e utilità\" esegui il push delle modifiche sul remote in un nuovo branch e risolvi il conflitto dal computer.</string> <string name="git_push_nff_error">Push rifiutato dal remote: esegui un pull prima di riprovare. Puoi usare Sincronizza, che esegue sia il pull sia il push</string> <string name="git_push_generic_error">Push rifiutato dal remote, motivo: %1$s</string> <string name="git_push_other_error">Il remote ha rifiutato un push non fast-forward. Controlla la variabile receive.denyNonFastForwards nella configurazione del repository di destinazione.</string> @@ -276,12 +261,9 @@ <string name="folder_creation_err_file_exists">Un file con quel nome esiste già</string> <string name="folder_creation_err_folder_exists">Una cartella con quel nome esiste già</string> <!-- Onboarding flow --> - <string name="let_s_go">Andiamo</string> - <string name="select_n_repository_type">Seleziona \nTipo di repository</string> <string name="select_repo_type_text">Seleziona se vuoi creare un repo locale o clonarne uno remoto.</string> <string name="clone_remote_repo">Clona repo remoto</string> <string name="create_local_repo">Crea repo locale</string> - <string name="select_gpg_key_title">Seleziona\nChiave\nGPG</string> <string name="select_gpg_key_message">Seleziona una chiave GPG con cui inizializzare il tuo archivio</string> <string name="gpg_key_select">Seleziona chiave</string> <!-- SSH port validation --> @@ -298,7 +280,6 @@ <string name="clear_saved_host_key">Cancella la chiave host salvata</string> <string name="clear_saved_host_key_success">Chiave host cancellata con successo!</string> <string name="otp_import_from_file">Scegli un\'immagine</string> - <string name="password_list_fab_content_description">Crea nuova password o cartella</string> <string name="git_commit_gpg_id">Inizializza gli ID GPG in %1$s.</string> <string name="crypto_username_hint">Nome utente</string> <string name="otp_label_format">OTP (scade tra %ds)</string> @@ -321,18 +302,15 @@ <string name="pref_passphrase_cache_auto_clear_authenticate_disable">Autenticati per disattivare la cancellazione automatica</string> <string name="pref_passphrase_cache_auto_clear_title">Cancella automaticamente le frasi segrete memorizzate</string> <string name="pref_passphrase_cache_auto_clear_summary">Cancella le frasi segrete memorizzate allo spegnimento dello schermo</string> - <string name="pref_biometric_auth_summary_disabled_platform">Funzione non disponibile a causa di un bug della piattaforma</string> <string name="password_creation_encrypt_fail_message">Impossibile cifrare la password, non è stato salvato nulla: %1$s</string> <string name="otp_import_failure_no_selection">Nessuna immagine selezionata</string> <string name="invalid_gpg_id">Trovato .gpg-id, ma contiene un ID chiave, un\'impronta o un ID utente non valido</string> <string name="short_gpg_id">Trovato .gpg-id, ma contiene un ID esadecimale corto, che non è supportato</string> <string name="git_pull_merge_fail_error">Merge non riuscito, il repository è in conflitto.</string> - <string name="gpg_key_select_mandatory">Per proseguire devi selezionare una chiave GPG</string> <string name="git_scheme_disallowed_title">Il protocollo git:// è sconsigliato</string> <string name="git_scheme_disallowed_message">Il protocollo git di git-daemon non cifra il trasporto e non è adatto a operazioni sicure.</string> <string name="activity_proxy_label">Impostazioni proxy</string> <string name="otp_import_qr_code">Scansiona codice QR</string> - <string name="otp_import_manual_entry">Inserisci manualmente</string> <string name="otp_import_manual_hint_secret">Segreto</string> <string name="otp_import_manual_hint_account">Account</string> <string name="place_shortcut_on_home_screen">Aggiungi scorciatoia alla schermata Home</string> @@ -351,4 +329,56 @@ <string name="no_keys_imported_dialog_message">Nell\'app non è ancora stata importata nessuna chiave PGP: premi il pulsante qui sotto per scegliere un file di chiave</string> <string name="biometric_prompt_title_gpg_passphrase_cache">Sblocca le frasi segrete memorizzate</string> <string name="clear_cached_password_on_screen_off">Cancella la password memorizzata allo spegnimento dello schermo</string> + + <!-- 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> + + <!-- 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> + + <!-- Compose folder picker --> + <string name="folder_picker_title">Scegli una cartella</string> + <string name="folder_creation_err_failed">Impossibile creare la cartella</string> + <string name="folder_picker_new">Nuova cartella</string> + + <!-- 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> + + <!-- 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> + <string name="connection_mode_none">Nessuna</string> + <string name="ssh_key_manage">Gestisci</string> + <string name="ssh_key_public_hint">Fornisci questa chiave pubblica al tuo server Git.</string> + <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> + <string name="store_not_empty">la cartella contiene già un archivio</string> + <string name="git_clone_delete_failed">Impossibile svuotare la cartella dell\'archivio, la clonazione non è partita.</string> + <string name="pgp_key_import_too_large">Il file supera 1 MiB: non è una chiave PGP.</string> + <string name="pgp_key_import_downgrade">Una chiave privata con questo ID è già importata. La sola chiave pubblica non può sostituirla: elimina prima la chiave privata, se vuoi davvero farlo.</string> + + <!-- 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="invalid_proxy_port">Inserisci una porta da 1 a 65535, oppure niente</string> + <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> + <string name="store_delete_confirm">Eliminare l\'archivio locale? Le voci di cui non è stato fatto il push sul remote vanno perse.</string> </resources> diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 432b6fbe..add3de87 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -22,7 +22,6 @@ </plurals> <string name="delete_directory_progress_text">삭제중…</string> <string name="move">이동</string> - <string name="edit">수정</string> <string name="delete">삭제</string> <string name="password_exists_title">이미 추가된 비밀번호입니다!</string> <string name="password_exists_message">%1$s 내용이 %2$s 로 덮어씌워집니다.</string> @@ -38,7 +37,6 @@ <!-- PGPHandler --> <string name="clipboard_copied_text">클립보드로 복사됨</string> <string name="file_toast_text">파일 이름이 필요합니다</string> - <string name="path_toast_text">파일 경로가 필요합니다</string> <string name="empty_toast_text">비어있는 비밀번호나 추가 정보를 사용할 수 없습니다</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Git 동작 중 문제가 발생하였습니다</string> @@ -62,7 +60,6 @@ <string name="crypto_name_hint">이름</string> <string name="crypto_pass_label">비밀번호</string> <string name="crypto_extra_label">추가 내용</string> - <string name="crypto_encrypt_username_label">암호화 계정 이름</string> <string name="crypto_select">선택</string> <string name="crypto_save">저장</string> <string name="crypto_save_and_copy">저장 및 복사</string> @@ -98,8 +95,6 @@ <string name="pref_directory_structure">암허 파일 정리</string> <string name="pref_sort_order_title">암호 정렬 순서</string> <string name="pref_folder_first_sort_order">폴더 우선</string> - <string name="pref_file_first_sort_order">파일 우선</string> - <string name="pref_type_independent_sort_order">타입순</string> <string name="pref_recently_used_sort_order">최신순</string> <string name="pref_category_autofill_title">자동완성</string> <string name="pref_autofill_enable_title">자동완성 활성화</string> @@ -122,7 +117,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">암호 생성</string> <string name="pwgen_generate">생성</string> - <string name="pwgen_include">포함</string> <string name="pwgen_length">길이</string> <string name="pwgen_numerals">숫자만</string> <string name="pwgen_symbols">특수문자</string> @@ -163,7 +157,6 @@ <string name="dialog_yes">예</string> <string name="dialog_no">아니오</string> <string name="dialog_cancel">취소</string> - <string name="dialog_skip">건너뛰기</string> <string name="git_sync">저장소 동기화</string> <string name="git_pull">원격에서 pull</string> <string name="git_push">원격으로 푸시</string> @@ -171,10 +164,7 @@ <string name="git_log">커밋 로그 보기</string> <string name="show_password_pref_title">비밀번호 표시하기</string> <string name="show_password_pref_summary">암호가 해독된 후 비밀번호의 공개 여부를 제어합니다. 그렇다고 해서 클립보드 복사가 비활성화되지는 않습니다.</string> - <string name="pwd_generate_button">생성</string> - <string name="refresh_list">목록 새로 고침</string> <string name="send_plaintext_password_to">비밀번호를 일반 텍스트로 보내는 중…</string> - <string name="app_icon_hint">앱 아이콘</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">입력할 항목 선택</string> <string name="oreo_autofill_strict_domain_search">피싱 방지 검색</string> @@ -201,12 +191,11 @@ <string name="oreo_autofill_password_fill_support">암호 입력</string> <string name="oreo_autofill_flaky_fill_support">비밀번호 채우기 (때때로 브라우저를 다시 시작해야 할 수 있음)</string> <string name="oreo_autofill_no_support">지원하지 않음</string> - <string name="oreo_autofill_enable_dialog_description">Password Store는 로그인 양식을 작성하고 앱이나 웹사이트에 입력한 자격 증명을 저장하는 기능도 제공합니다.</string> + <string name="oreo_autofill_enable_dialog_description">HardenedPass는 로그인 양식을 작성하고 앱이나 웹사이트에 입력한 자격 증명을 저장하는 기능도 제공합니다.</string> <string name="oreo_autofill_enable_dialog_instructions">이 기능을 사용하려면 확인을 탭하여 자동 완성 설정으로 이동합니다. 목록에서 암호 저장을 선택하고 확인으로 확인 메시지를 확인합니다.</string> <string name="oreo_autofill_enable_dialog_installed_browsers">자동 완성 지원 하는 설치된 브라우저:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">SSH 개인 키를 열 수 없습니다. 파일이 존재하는지 확인하세요</string> - <string name="new_password_title">새 암호</string> <string name="clear_saved_passphrase_ssh">로컬 SSH 키의 저장된 비밀번호 지우기</string> <string name="clear_saved_passphrase_https">저장된 HTTPS 비밀번호 지우기</string> <string name="git_operation_remember_passphrase">키 암호 기억하기</string> @@ -222,7 +211,6 @@ <string name="pref_biometric_auth_title">생체 인증 사용</string> <string name="pref_biometric_auth_summary">비밀번호 저장소를 활성화하면 앱을 시작할 때 지문을 입력하라는 메시지가 표시됩니다</string> <string name="pref_biometric_auth_summary_error">지문 하드웨어에 액세스할 수 없거나 누락된 경우</string> - <string name="pref_title_openkeystore_clear_keyid">저장된 OpenKeyStore SSH 키 ID 지우기</string> <string name="your_public_key">공개 키</string> <string name="error_generate_ssh_key">Ssh 키를 생성하는 동안 오류가 발생했습니다.</string> <string name="pref_show_hidden_title">모든 파일 및 폴더 표시</string> @@ -230,7 +218,6 @@ <string name="title_create_folder">폴더 생성</string> <string name="title_rename_folder">폴더 이름 변경</string> <string name="message_category_error_empty_field">카테고리 이름은 비워 둘 수 없습니다</string> - <string name="message_category_error_category_exists">카테고리가 이미 있습니다</string> <string name="message_error_destination_outside_repo">대상은 리포지토리 내에 있어야 합니다</string> <string name="message_rename_folder">%1$s의 목적지 입력</string> <string name="button_create">생성</string> @@ -278,11 +265,9 @@ username@example.com:…</string> <string name="invalid_gpg_id">.gpg-id를 찾았지만 잘못된 키 ID, 지문 또는 사용자 ID가 포함되어 있습니다</string> <string name="short_gpg_id">.gpg-id를 찾았지만, 지원되지 않는 짧은 16진수 ID가 포함되어 있습니다.</string> <string name="invalid_filename_text">파일 이름에 \'/\'가 포함되어서는 안됩니다. 위의 디렉터리를 설정하세요</string> - <string name="directory_hint">디렉토리</string> - <string name="new_folder_set_gpg_key">디렉토리의 GPG 키 설정</string> <!-- GitException messages --> <string name="git_unknown_error">알 수 없는 오류</string> - <string name="git_pull_rebase_fail_error">풀이 실패하여 head가 분리된 상태입니다. \"설정 > git 기능\"을 사용하여 원격 저장소에 변경 내용을 새 브랜치에 저장하고 컴퓨터에서 충돌을 해결하세요.</string> + <string name="git_pull_rebase_fail_error">풀이 실패하여 head가 분리된 상태입니다. \"설정 > 저장소 > 로컬 Git 설정 & 툴\"을 사용하여 원격 저장소에 변경 내용을 새 브랜치에 저장하고 컴퓨터에서 충돌을 해결하세요.</string> <string name="git_pull_merge_fail_error">병합에 실패하여 충돌 상태 입니다. 할 일: 복구 방법을 추가하세요.</string> <string name="git_push_nff_error">원격에서 푸시가 거부된 경우 다시 푸시하기 전에 풀을 실행합니다. 풀/푸시 대신 동기화를 사용하면 두 가지를 모두 구현할 수 있습니다</string> <string name="git_push_generic_error">원격에서 푸시를 거부했습니다. 원인: %1$s</string> @@ -292,16 +277,12 @@ username@example.com:…</string> <string name="git_break_out_of_detached_success">리베이스하는 동안 충돌이 발생했습니다. 로컬 %1$s 브랜치가 %2$s이라는 다른 브랜치로 푸시되었습니다.\n이 브랜치를 사용하여 컴퓨터에서 충돌을 해결하세요.</string> <string name="git_break_out_of_detached_unneeded">이 저장소는 리베이스가 아니므로 다른 브랜치로 푸시할 필요가 없습니다.</string> <!-- GPG key selection in folder creation --> - <string name="gpg_key_select_mandatory">계속하려면 GPG 키를 선택해야 합니다</string> <string name="folder_creation_err_file_exists">동일한 이름의 파일이 이미 있습니다</string> <string name="folder_creation_err_folder_exists">이 이름의 폴더가 이미 존재합니다</string> <!-- Onboarding flow --> - <string name="let_s_go">시작합니다!</string> - <string name="select_n_repository_type">저장소 유형 \n선택</string> <string name="select_repo_type_text">로컬 리포지토리를 만들 것인지, 원격 리포지토리를 복제할 것인지 선택합니다.</string> <string name="clone_remote_repo">원격 저장소 복제</string> <string name="create_local_repo">로컬 저장소 생성</string> - <string name="select_gpg_key_title">GPG 키\n선택</string> <string name="select_gpg_key_message">스토어를 초기화할 GPG 키를 선택합니다</string> <string name="gpg_key_select">키 선택</string> <!-- SSH port validation --> @@ -322,11 +303,9 @@ username@example.com:…</string> <string name="clear_saved_host_key_success">저장된 호스트 키를 성공적으로 지웠습니다!</string> <string name="otp_import_qr_code">QR코드 스캔</string> <string name="otp_import_from_file">이미지 선택</string> - <string name="otp_import_manual_entry">직접 입력</string> <string name="otp_import_manual_hint_secret">비밀</string> <string name="otp_import_manual_hint_account">계정</string> <string name="place_shortcut_on_home_screen">홈 화면에 추가</string> - <string name="password_list_fab_content_description">새 비밀번호 또는 폴더 만들기</string> <string name="pgp_key_import_failed">PGP 키를 가져오지 못했습니다</string> <string name="pgp_key_import_failed_replace_message">이 ID를 가진 기존 키가 발견되었습니다. 교체하시겠습니까?</string> <string name="pgp_key_import_succeeded">PGP 키를 성공적으로 가져왔습니다</string> diff --git a/app/src/main/res/values-night/colors_material3.xml b/app/src/main/res/values-night/colors_material3.xml index 22e4e8f0..c26d7ec6 100644 --- a/app/src/main/res/values-night/colors_material3.xml +++ b/app/src/main/res/values-night/colors_material3.xml @@ -4,5 +4,4 @@ --> <resources> - <color name="list_multiselect_background">#66eeeeee</color> </resources> diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index a915f67d..4d41b2f6 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -23,7 +23,6 @@ <string name="creation_dialog_text">Zanim dodasz hasło lub rozpoczniesz synchronizację, stwórz albo sklonuj poniżej nowe repozytorium.</string> <string name="delete_directory_progress_text">Usuwanie…</string> <string name="move">Przenieś</string> - <string name="edit">Edytuj</string> <string name="delete">Usuń</string> <string name="password_exists_title">Hasło już istnieje!</string> <string name="password_exists_message">%1$s zostanie nadpisane przez %2$s.</string> @@ -38,7 +37,6 @@ <!-- PGPHandler --> <string name="clipboard_copied_text">Skopiowano do schowka</string> <string name="file_toast_text">Podaj nazwę pliku</string> - <string name="path_toast_text">Podaj ścieżkę do pliku</string> <string name="empty_toast_text">Nie możesz użyć pustego hasła lub pustej dodatkowej zawartości</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Wystąpił błąd podczas operacji Git</string> @@ -62,7 +60,6 @@ <string name="crypto_name_hint">Nazwa</string> <string name="crypto_pass_label">Hasło</string> <string name="crypto_extra_label">Treść dodatkowa</string> - <string name="crypto_encrypt_username_label">Szyfruj nazwę użytkownika</string> <string name="crypto_select">Wybierz</string> <string name="crypto_save">Zapisz</string> <string name="crypto_save_and_copy">Zapisz i skopiuj</string> @@ -97,8 +94,6 @@ <string name="pref_directory_structure">Organizacja plików haseł</string> <string name="pref_sort_order_title">Kolejność sortowania haseł</string> <string name="pref_folder_first_sort_order">Najpierw foldery</string> - <string name="pref_file_first_sort_order">Najpierw pliki</string> - <string name="pref_type_independent_sort_order">Niezależnie od typu</string> <string name="pref_recently_used_sort_order">Ostatnio używane</string> <string name="pref_category_autofill_title">Autouzupełnianie</string> <string name="pref_autofill_enable_title">Włącz autouzupełnianie</string> @@ -123,7 +118,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Generuj hasło</string> <string name="pwgen_generate">Wygeneruj</string> - <string name="pwgen_include">Załącz</string> <string name="pwgen_length">Długość</string> <string name="pwgen_numerals">Cyfry</string> <string name="pwgen_symbols">Symbole</string> @@ -164,7 +158,6 @@ <string name="dialog_yes">Tak</string> <string name="dialog_no">Nie</string> <string name="dialog_cancel">Anuluj</string> - <string name="dialog_skip">Pomiń</string> <string name="git_sync">Synchronizuj repozytorium</string> <string name="git_pull">Pobierz ze zdalnego repozytorium</string> <string name="git_push">Wypchnij do zdalnego repozytorium</string> @@ -172,10 +165,7 @@ <string name="git_log">Pokaż dziennik commitów</string> <string name="show_password_pref_title">Pokaż hasło</string> <string name="show_password_pref_summary">Kontroluj widoczność haseł po odszyfrowaniu. To nie wyłącza kopiowania do schowka.</string> - <string name="pwd_generate_button">Generuj</string> - <string name="refresh_list">Odśwież listę</string> <string name="send_plaintext_password_to">Wyślij niezaszyfrowane hasło, używając…</string> - <string name="app_icon_hint">Ikona aplikacji</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Wybierz wpis do wypełnienia</string> <string name="oreo_autofill_strict_domain_search">Wyszukiwanie odporne na wyłudzenie</string> @@ -200,12 +190,11 @@ <string name="oreo_autofill_password_fill_support">Wypełnij hasła</string> <string name="oreo_autofill_flaky_fill_support">Wypełnij hasła (może wymagać ponownego uruchomienia przeglądarki od czasu do czasu)</string> <string name="oreo_autofill_no_support">Brak wsparcia</string> - <string name="oreo_autofill_enable_dialog_description">Password Store może zaoferować wypełnianie formularzy logowania, a nawet zapisywanie danych logowania, które wpisujesz w aplikacjach lub na stronach internetowych.</string> - <string name="oreo_autofill_enable_dialog_instructions">Aby włączyć tę funkcję, naciśnij przycisk OK, aby przejść do ustawień autouzupełniania. Następnie wybierz Password Store z listy i potwierdź przyciskiem OK.</string> + <string name="oreo_autofill_enable_dialog_description">HardenedPass może zaoferować wypełnianie formularzy logowania, a nawet zapisywanie danych logowania, które wpisujesz w aplikacjach lub na stronach internetowych.</string> + <string name="oreo_autofill_enable_dialog_instructions">Aby włączyć tę funkcję, naciśnij przycisk OK, aby przejść do ustawień autouzupełniania. Następnie wybierz HardenedPass z listy i potwierdź przyciskiem OK.</string> <string name="oreo_autofill_enable_dialog_installed_browsers">Obsługa autouzupełniania w zainstalowanych przeglądarkach:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">Nie można otworzyć klucza prywatnego ssh, sprawdź czy plik istnieje</string> - <string name="new_password_title">Nowe hasło</string> <string name="clear_saved_passphrase_ssh">Wyczyść zapisane hasło dla lokalnego klucza SSH</string> <string name="clear_saved_passphrase_https">Wyczyść zapisane hasło HTTPS</string> <string name="git_operation_remember_passphrase">Zapamiętaj hasło klucza</string> @@ -219,9 +208,8 @@ <string name="biometric_prompt_title">Weryfikacja biometryczna</string> <string name="biometric_auth_error_reason">Błąd uwierzytelnienia: %s</string> <string name="pref_biometric_auth_title">Włącz uwierzytelnianie biometryczne</string> - <string name="pref_biometric_auth_summary">Gdy włączone, Password Store zapyta Cię o odcisk palca podczas uruchamiania aplikacji</string> + <string name="pref_biometric_auth_summary">Gdy włączone, HardenedPass zapyta Cię o odcisk palca podczas uruchamiania aplikacji</string> <string name="pref_biometric_auth_summary_error">Czytnik linii papilarnych jest niedostępny</string> - <string name="pref_title_openkeystore_clear_keyid">Wyczyść zapamiętany identyfikator klucza SSH OpenKeystore</string> <string name="your_public_key">Twój klucz publiczny</string> <string name="error_generate_ssh_key">Błąd podczas generowania klucza SSH</string> <string name="pref_show_hidden_title">Pokaż ukryte pliki i foldery</string> @@ -229,7 +217,6 @@ <string name="title_create_folder">Utwórz katalog</string> <string name="title_rename_folder">Zmień nazwę katalogu</string> <string name="message_category_error_empty_field">Nazwa kategorii nie może być pusta</string> - <string name="message_category_error_category_exists">Kategoria już istnieje</string> <string name="message_error_destination_outside_repo">Miejsce docelowe musi znajdować się w repozytorium</string> <string name="message_rename_folder">Wprowadź miejsce docelowe dla %1$s</string> <string name="button_create">Utwórz</string> @@ -272,11 +259,9 @@ <string name="invalid_gpg_id">Znaleziono .gpg-id, ale zawiera nieprawidłowy identyfikator klucza, odcisk palca lub identyfikator użytkownika</string> <string name="short_gpg_id">Znaleziono .gpg-id, ale zawarty w nim skrócony identyfikator nie jest obsługiwany</string> <string name="invalid_filename_text">Nazwa pliku nie może zawierać \'/\', ustaw katalog powyżej</string> - <string name="directory_hint">Katalog</string> - <string name="new_folder_set_gpg_key">Ustaw klucz GPG dla katalogu</string> <!-- GitException messages --> <string name="git_unknown_error">Nieznany błąd</string> - <string name="git_pull_rebase_fail_error">Zaciąganie zmian nie powiodło się, nie jesteś na żadnej gałęzi (detached head). Używając \"Ustawienia > narzędzia Git\", wyślij lokalne zmiany do zdalnego repozytorium na nowej gałęzi i rozwiąż konflikt na komputerze.</string> + <string name="git_pull_rebase_fail_error">Zaciąganie zmian nie powiodło się, nie jesteś na żadnej gałęzi (detached head). Używając \"Ustawienia > Repozytorium > Konfiguracja lokalnego repo Git & narzędzia\", wyślij lokalne zmiany do zdalnego repozytorium na nowej gałęzi i rozwiąż konflikt na komputerze.</string> <string name="git_pull_merge_fail_error">Scalanie nie powiodło się, jesteś w stanie konfliktu.</string> <string name="git_push_nff_error">Operacja wypchnięcia zmian została odrzucona przez zdalne repozytorium. Zaciągnij zmiany przed ponownym wysłaniem. Możesz użyć synchronizacji zamiast pull/push ponieważ zawiera obu te kroki</string> <string name="git_push_generic_error">Operacja wypchnięcia zmian została odrzucona przez zdalne repozytorium, powód: %1$s</string> @@ -289,12 +274,9 @@ <string name="folder_creation_err_file_exists">Plik o tej nazwie już istnieje</string> <string name="folder_creation_err_folder_exists">Folder o tej nazwie już istnieje</string> <!-- Onboarding flow --> - <string name="let_s_go">Zaczynajmy</string> - <string name="select_n_repository_type">Wybierz \ntyp repozytorium</string> <string name="select_repo_type_text">Zaznacz, czy chcesz utworzyć lokalne repozytorium, czy sklonować zdalne repozytorium.</string> <string name="clone_remote_repo">Klonuj zdalne repozytorium</string> <string name="create_local_repo">Utwórz lokalne repozytorium</string> - <string name="select_gpg_key_title">Wybierz\nklucz GPG</string> <string name="select_gpg_key_message">Wybierz klucz GPG, którym zainicjować swoje repozytorium</string> <string name="gpg_key_select">Wybierz klucz</string> <!-- SSH port validation --> @@ -314,11 +296,9 @@ <string name="clear_saved_host_key_success">Pomyślnie wyczyszczono zapisany klucz hosta!</string> <string name="otp_import_qr_code">Skanuj kod QR</string> <string name="otp_import_from_file">Wybierz obraz</string> - <string name="otp_import_manual_entry">Wprowadź ręcznie</string> <string name="otp_import_manual_hint_secret">Sekret</string> <string name="otp_import_manual_hint_account">Konto</string> <string name="place_shortcut_on_home_screen">Utwórz skrót na pulpicie</string> - <string name="password_list_fab_content_description">Utwórz nowe hasło lub folder</string> <string name="pgp_key_import_failed">Nie udało się zaimportować klucza PGP</string> <string name="pgp_key_import_failed_replace_message">Znaleziono istniejący klucz z tym identyfikatorem, czy chcesz go zastąpić?</string> <string name="pgp_key_import_succeeded">Pomyślnie zaimportowano klucz PGP</string> diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 1d95286d..14e862c5 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -24,7 +24,6 @@ </plurals> <string name="delete_directory_progress_text">Excluindo…</string> <string name="move">Mover</string> - <string name="edit">Editar</string> <string name="delete">Excluir</string> <string name="password_exists_title">Senha já existe!</string> <string name="password_exists_message">Isso irá sobrescrever %1$s com %2$s.</string> @@ -40,7 +39,6 @@ <!-- PGPHandler --> <string name="clipboard_copied_text">Copiado para a área de transferência</string> <string name="file_toast_text">Por favor, informe um nome de arquivo</string> - <string name="path_toast_text">Por favor, forneça o caminho do arquivo</string> <string name="empty_toast_text">Você não pode usar uma senha vazia ou conteúdo extra vazio</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Ocorreu um erro durante uma operação do Git</string> @@ -64,7 +62,6 @@ <string name="crypto_name_hint">Nome</string> <string name="crypto_pass_label">Senha</string> <string name="crypto_extra_label">Conteúdo extra</string> - <string name="crypto_encrypt_username_label">Criptografar nome de usuário</string> <string name="crypto_select">Selecionar</string> <string name="crypto_save">Salvar</string> <string name="crypto_save_and_copy">Salvar e Copiar</string> @@ -99,8 +96,6 @@ <string name="pref_recursive_filter_summary">Encontrar senhas do diretório corrente recursivamente.</string> <string name="pref_sort_order_title">Ordenação da Senha</string> <string name="pref_folder_first_sort_order">Pastas primeiro</string> - <string name="pref_file_first_sort_order">Arquivos primeiro</string> - <string name="pref_type_independent_sort_order">Tipo independente</string> <string name="pref_recently_used_sort_order">Usado recentemente</string> <string name="pref_category_autofill_title">Preenchimento Automático</string> <string name="pref_autofill_enable_title">Ativar preenchimento automático</string> @@ -126,7 +121,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Gerar Senha</string> <string name="pwgen_generate">Gerar</string> - <string name="pwgen_include">Incluir</string> <string name="pwgen_length">Tamanho</string> <string name="pwgen_numerals">Numerais</string> <string name="pwgen_symbols">Símbolos</string> @@ -167,7 +161,6 @@ <string name="dialog_yes">Sim</string> <string name="dialog_no">Não</string> <string name="dialog_cancel">Cancelar</string> - <string name="dialog_skip">Pular</string> <string name="git_sync">Sincronizar repositório</string> <string name="git_pull">Pull do remoto</string> <string name="git_push">Push do remoto</string> @@ -175,10 +168,7 @@ <string name="git_log">Mostrar log de commit</string> <string name="show_password_pref_title">Mostrar a senha</string> <string name="show_password_pref_summary">Controle a visibilidade das senhas quando descriptografadas. Isso não desativa a cópia para a área de transferência.</string> - <string name="pwd_generate_button">Gerar</string> - <string name="refresh_list">Atualizar lista</string> <string name="send_plaintext_password_to">Enviar senha como texto simples usando…</string> - <string name="app_icon_hint">Ícone do aplicativo</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Selecione a entrada para preencher</string> <string name="oreo_autofill_strict_domain_search">Busca resistente a phishing</string> @@ -191,7 +181,7 @@ <string name="oreo_autofill_save_passwords_dont_match">As senhas não coincidem</string> <string name="oreo_autofill_generate_password">Gerar senha…</string> <string name="oreo_autofill_max_matches_reached">Número máximo de correspondências (%1$d) atingidas; limpar correspondências antes de adicionar novas.</string> - <string name="oreo_autofill_warning_publisher_header">O editor deste aplicativo mudou desde a primeira vez que você associou uma entrada no Password Store:</string> + <string name="oreo_autofill_warning_publisher_header">O editor deste aplicativo mudou desde a primeira vez que você associou uma entrada no HardenedPass:</string> <string name="oreo_autofill_warning_publisher_footer"><b>O aplicativo atualmente instalado pode estar tentando roubar suas credenciais fingindo ser um aplicativo confiável.</b>\n\nTente desinstalar e reinstalar o aplicativo de uma fonte confiável, como a Play Store, Amazon Appstore, F-Droid ou a loja do fabricante do seu telefone.</string> <string name="oreo_autofill_warning_publisher_install_time">Instalado: %1$s</string> <string name="oreo_autofill_warning_publisher_advanced_info_button">Informações avançadas</string> @@ -205,12 +195,11 @@ <string name="oreo_autofill_password_fill_support">Preencher as senhas</string> <string name="oreo_autofill_flaky_fill_support">Preencher senhas (pode ser necessário reiniciar o navegador de vez em quando)</string> <string name="oreo_autofill_no_support">Sem suporte</string> - <string name="oreo_autofill_enable_dialog_description">O Password Store pode oferecer para preencher formulários de login e até mesmo salvar credenciais inseridas em aplicativos ou em sites.</string> - <string name="oreo_autofill_enable_dialog_instructions">Para ativar esse recurso, toque em OK para ir para as configurações de preenchimento automático. Lá, selecione Password Store na lista e confirme na tela confirmação com OK.</string> + <string name="oreo_autofill_enable_dialog_description">O HardenedPass pode oferecer para preencher formulários de login e até mesmo salvar credenciais inseridas em aplicativos ou em sites.</string> + <string name="oreo_autofill_enable_dialog_instructions">Para ativar esse recurso, toque em OK para ir para as configurações de preenchimento automático. Lá, selecione HardenedPass na lista e confirme na tela confirmação com OK.</string> <string name="oreo_autofill_enable_dialog_installed_browsers">Suporte ao preenchimento automático com navegadores instalados:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">Não foi possível abrir a chave privada ssh, por favor verifique se o arquivo existe</string> - <string name="new_password_title">Nova senha</string> <string name="clear_saved_passphrase_ssh">Limpar a frase secreta salva para chave SSH local</string> <string name="clear_saved_passphrase_https">Limpar senha HTTPS salva</string> <string name="git_operation_remember_passphrase">Lembrar senha da chave</string> @@ -224,9 +213,8 @@ <string name="biometric_prompt_title">Confirmação Biométrica</string> <string name="biometric_auth_error_reason">Falha de autenticação: %s</string> <string name="pref_biometric_auth_title">Ativar autenticação biométrica</string> - <string name="pref_biometric_auth_summary">Quando ativado, o Password Store irá pedir a sua impressão digital ao iniciar o aplicativo</string> + <string name="pref_biometric_auth_summary">Quando ativado, o HardenedPass irá pedir a sua impressão digital ao iniciar o aplicativo</string> <string name="pref_biometric_auth_summary_error">Hardware de impressão digital não acessível ou ausente</string> - <string name="pref_title_openkeystore_clear_keyid">Limpar ID de chave SSH lembrada do OpenKeystore</string> <string name="your_public_key">Sua chave pública</string> <string name="error_generate_ssh_key">Erro ao tentar gerar a chave SSH</string> <string name="pref_show_hidden_title">Mostrar todos os arquivos e pastas</string> @@ -234,7 +222,6 @@ <string name="title_create_folder">Criar pasta</string> <string name="title_rename_folder">Renomear pasta</string> <string name="message_category_error_empty_field">O nome da categoria não pode ser vazio</string> - <string name="message_category_error_category_exists">Nome da categoria já existe</string> <string name="message_error_destination_outside_repo">O destino deve estar dentro do repositório</string> <string name="message_rename_folder">Insira o destino para %1$s</string> <string name="button_create">Criar</string> @@ -280,11 +267,9 @@ <string name="invalid_gpg_id">Encontrado .gpg-id, mas contém um ID de chave, fingerprint ou ID de usuário inválidos</string> <string name="short_gpg_id">Encontrado .gpg-id, mas contém uma ID hexadecimal curta, que não é suportada</string> <string name="invalid_filename_text">Nome do arquivo não deve conter \'/\', defina o diretório acima</string> - <string name="directory_hint">Diretório</string> - <string name="new_folder_set_gpg_key">Definir chave GPG para diretório</string> <!-- GitException messages --> <string name="git_unknown_error">Erro desconhecido</string> - <string name="git_pull_rebase_fail_error">O pull falhou, você está em uma Head avulsa. Usando \"configurações > utils\" do git, salve suas alterações no remoto em uma nova branch e resolva o conflito no seu computador.</string> + <string name="git_pull_rebase_fail_error">O pull falhou, você está em uma Head avulsa. Usando \"Configurações > Repositório > Configuração local do Git & utilitários\", salve suas alterações no remoto em uma nova branch e resolva o conflito no seu computador.</string> <string name="git_pull_merge_fail_error">Falha ao realizar o merge, você está em estado de conflito.</string> <string name="git_push_nff_error">Push rejeitado pelo remoto, execute o pull antes de fazer push novamente. Você pode usar Sincronização em vez de pull/push conforme implementa ambos</string> <string name="git_push_generic_error">Push rejeitado pelo remoto, razão: %1$s</string> @@ -294,16 +279,12 @@ <string name="git_break_out_of_detached_success">Houve um conflito ao executar o rebase. Houve um push de branch %1$s local para outra branch chamada %2$s\n Use esta branch para resolver o conflito no seu computador</string> <string name="git_break_out_of_detached_unneeded">O repositório não está sendo rebased, não há necessidade de fazer push para outro branch</string> <!-- GPG key selection in folder creation --> - <string name="gpg_key_select_mandatory">A seleção de uma chave GPG é necessária para prosseguir</string> <string name="folder_creation_err_file_exists">Já existe um arquivo com esse nome</string> <string name="folder_creation_err_folder_exists">Já existe uma pasta com esse nome</string> <!-- Onboarding flow --> - <string name="let_s_go">Vamos lá</string> - <string name="select_n_repository_type">Selecione \nTipo de Repositório</string> <string name="select_repo_type_text">Selecione se você quer criar um repositório local ou clonar um repositório remoto.</string> <string name="clone_remote_repo">Clonar repositório remoto</string> <string name="create_local_repo">Criar repositório local</string> - <string name="select_gpg_key_title">Selecione\nChave GPG</string> <string name="select_gpg_key_message">Selecione uma chave GPG para inicializar o armazenamento com</string> <string name="gpg_key_select">Selecione a chave</string> <!-- SSH port validation --> @@ -324,11 +305,9 @@ <string name="clear_saved_host_key_success">Chave de host limpa com sucesso!</string> <string name="otp_import_qr_code">Escanear QR code</string> <string name="otp_import_from_file">Selecione uma imagem</string> - <string name="otp_import_manual_entry">Inserir manualmente</string> <string name="otp_import_manual_hint_secret">Segredo</string> <string name="otp_import_manual_hint_account">Conta</string> <string name="place_shortcut_on_home_screen">Criar atalho na tela inicial</string> - <string name="password_list_fab_content_description">Criar nova senha ou pasta</string> <string name="pgp_key_import_failed">Falha ao importar a chave PGP</string> <string name="pgp_key_import_failed_replace_message">Uma chave existente com esse ID foi encontrada, você deseja substituí-lo?</string> <string name="pgp_key_import_succeeded">Chave PGP importada com sucesso</string> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c10bdcd8..ddc33aa3 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -28,7 +28,6 @@ </plurals> <string name="delete_directory_progress_text">Удаляется…</string> <string name="move">Переместить</string> - <string name="edit">Редактировать</string> <string name="delete">Удалить</string> <string name="password_exists_title">Пароль уже существует!</string> <string name="password_exists_message">Это перезапишет %1$s на %2$s</string> @@ -44,7 +43,6 @@ <!-- PGPHandler --> <string name="clipboard_copied_text">Скопировано в буфер обмена</string> <string name="file_toast_text">Пожалуйста, укажите имя файла</string> - <string name="path_toast_text">Пожалуйста, задайте путь к файлу</string> <string name="empty_toast_text">Вы не можете использовать пустой пароль или пустое поле информации</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Произошла ошибка выполнения операции Git</string> @@ -68,7 +66,6 @@ <string name="crypto_name_hint">название</string> <string name="crypto_pass_label">Пароль</string> <string name="crypto_extra_label">Дополнительно</string> - <string name="crypto_encrypt_username_label">Зашифровать имя пользователя</string> <string name="crypto_select">Выбрать</string> <string name="crypto_save">Сохранить</string> <string name="crypto_save_and_copy">Сохранить и скопировать</string> @@ -102,8 +99,6 @@ <string name="pref_recursive_filter_summary">Рекурсивный поиск паролей в текущей директории</string> <string name="pref_sort_order_title">Порядок сортировки паролей</string> <string name="pref_folder_first_sort_order">Сначала папки</string> - <string name="pref_file_first_sort_order">Сначала файлы</string> - <string name="pref_type_independent_sort_order">Типонезависимый</string> <string name="pref_recently_used_sort_order">Недавно использованные</string> <string name="pref_category_autofill_title">Автозаполнение</string> <string name="pref_autofill_enable_title">Включить автозаполнение</string> @@ -119,7 +114,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Сгенерировать пароль</string> <string name="pwgen_generate">Сгенерировать</string> - <string name="pwgen_include">Включая</string> <string name="pwgen_length">Длина</string> <string name="pwgen_numerals">Цифры</string> <string name="pwgen_symbols">Символы</string> @@ -160,7 +154,6 @@ <string name="dialog_yes">Да</string> <string name="dialog_no">Нет</string> <string name="dialog_cancel">Отмена</string> - <string name="dialog_skip">Пропустить</string> <string name="git_sync">Синхронизировать репозиторий</string> <string name="git_pull">Пулл с удаленного сервера</string> <string name="git_push">Пуш на удаленный сервер</string> @@ -168,10 +161,7 @@ <string name="git_log">Показать журнал изменений</string> <string name="show_password_pref_title">Показывать пароли</string> <string name="show_password_pref_summary">Видимость расшифрованных паролей, не влияет на копирование в буфер.</string> - <string name="pwd_generate_button">Сгенерировать</string> - <string name="refresh_list">Обновить список</string> <string name="send_plaintext_password_to">Поделиться паролем в открытом виде с помощью</string> - <string name="app_icon_hint">Иконка приложения</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Выберите запись для заполнения</string> <string name="oreo_autofill_strict_domain_search">Устойчивый к фишингу поиск</string> @@ -199,11 +189,10 @@ <string name="oreo_autofill_flaky_fill_support">Заполнить учетные данные (время от времени может требоваться перезапуск браузера)</string> <string name="oreo_autofill_no_support">Не поддерживается</string> <string name="oreo_autofill_enable_dialog_description">Хранилище паролей может предложить заполнить формы входа и даже сохранить учетные данные в приложениях или на веб-сайтах.</string> - <string name="oreo_autofill_enable_dialog_instructions">Чтобы включить эту функцию, нажмите ОК, чтобы перейти к настройкам автозаполнения. Там выберите Password Store из списка и подтвердите запрос подтверждения, нажав ОК.</string> + <string name="oreo_autofill_enable_dialog_instructions">Чтобы включить эту функцию, нажмите ОК, чтобы перейти к настройкам автозаполнения. Там выберите HardenedPass из списка и подтвердите запрос подтверждения, нажав ОК.</string> <string name="oreo_autofill_enable_dialog_installed_browsers">Поддержка автозаполнения установленными браузерами:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">Невозможно открыть приватный ключ ssh, пожалуйста проверьте, что файл существует</string> - <string name="new_password_title">Новый пароль</string> <string name="clear_saved_passphrase_ssh">Очистить сохраненную кодовую фразу для локального SSH ключа</string> <string name="clear_saved_passphrase_https">Удалить сохраненный пароль HTTPS</string> <string name="git_operation_remember_passphrase">Запомнить кодовую фразу</string> @@ -217,9 +206,8 @@ <string name="biometric_prompt_title">Запрос биометрии</string> <string name="biometric_auth_error_reason">Ошибка аутентификации: %s</string> <string name="pref_biometric_auth_title">Включить биометрическую аутентификацию</string> - <string name="pref_biometric_auth_summary">Когда ключено, Password Store будет запрашивать ваш опечаток пальца при каждом запуске приложения</string> + <string name="pref_biometric_auth_summary">Когда ключено, HardenedPass будет запрашивать ваш опечаток пальца при каждом запуске приложения</string> <string name="pref_biometric_auth_summary_error">Сенсор отпечатка пальца не доступен или отсутствует</string> - <string name="pref_title_openkeystore_clear_keyid">Очистить сохраненный SSH Key идентификатор OpenKystortore</string> <string name="your_public_key">Ваш публичный ключ</string> <string name="error_generate_ssh_key">Возникла ошибка при попытке генерации ssh ключа</string> <string name="pref_show_hidden_title">Показать все файлы и директории</string> @@ -227,7 +215,6 @@ <string name="title_create_folder">Создать папку</string> <string name="title_rename_folder">Переименовать папку</string> <string name="message_category_error_empty_field">Название категории не может быть пустым</string> - <string name="message_category_error_category_exists">Название категории уже существует</string> <string name="message_error_destination_outside_repo">Путь должен указывать на область внутри репозитория</string> <string name="message_rename_folder">Введите адрес назначения для %1$s</string> <string name="button_create">Создать</string> @@ -272,11 +259,9 @@ <string name="exporting_passwords">Экспорт паролей…</string> <string name="invalid_gpg_id">Найден .gpg-id, но он содержит неверный ID ключа, fingerprint или ID пользователя</string> <string name="invalid_filename_text">Имя файла не должно содержать \'/\', укажите директорию выше</string> - <string name="directory_hint">Директория</string> - <string name="new_folder_set_gpg_key">Установить GPG ключ для каталога</string> <!-- GitException messages --> <string name="git_unknown_error">Неизвестная ошибка</string> - <string name="git_pull_rebase_fail_error">Не удалось получить изменения, вы находитесь в состоянии \"оторванной головы\". Используйте \"настройки > утилиты git\", сохраните ваши изменения в новую удаленную ветку и разрешите конфликты на своем компьютере.</string> + <string name="git_pull_rebase_fail_error">Не удалось получить изменения, вы находитесь в состоянии \"оторванной головы\". Используйте \"Настройки > Репозиторий > Конфигурация локального Git\", сохраните ваши изменения в новую удаленную ветку и разрешите конфликты на своем компьютере.</string> <string name="git_pull_merge_fail_error">Слияние не удалось, вы находитесь в конфликтующем состоянии. ВТО: Добавьте метод восстановления.</string> <string name="git_push_nff_error">Запись изменений была отклонена удаленным репозиторием, сначала пполучите изменения перед повторной записью. Вы можете использовать Синхронизацию вместо получения/записи изменений, т.к. она реализует оба подхда.</string> <string name="git_push_generic_error">Запись изменений была отклонена удаленным репозиторием, причина: %1$s</string> @@ -286,16 +271,12 @@ <string name="git_break_out_of_detached_success">При попытке rebase\'а возник конфликт. Ваша локальная ветка %1$s была запушена %2$s\n Используйте эту ветку для разрешения конфликтов с помощью компьютера</string> <string name="git_break_out_of_detached_unneeded">Не нужно пушить в другую ветку - rebase не был запущен</string> <!-- GPG key selection in folder creation --> - <string name="gpg_key_select_mandatory">Для продолжения необходимо выбрать GPG-ключ</string> <string name="folder_creation_err_file_exists">Файл с таким именем уже существует</string> <string name="folder_creation_err_folder_exists">Папка с таким именем уже существует</string> <!-- Onboarding flow --> - <string name="let_s_go">Поехали</string> - <string name="select_n_repository_type">Выберите \nтип репозитория</string> <string name="select_repo_type_text">Создать локальное хранилище, или клонировать удаленный репозиторий.</string> <string name="clone_remote_repo">Клонировать удаленный репозиторий</string> <string name="create_local_repo">Создать локальное хранилище</string> - <string name="select_gpg_key_title">Выберите\nGPG ключ</string> <string name="select_gpg_key_message">Выберите GPG ключ для инициализации хранилища</string> <string name="gpg_key_select">Выберите ключ</string> <!-- SSH port validation --> @@ -316,10 +297,8 @@ <string name="clear_saved_host_key_success">Сохраненный ключ хоста был успешно удален!</string> <string name="otp_import_qr_code">Отсканировать QR-код</string> <string name="otp_import_from_file">Выберите изображение</string> - <string name="otp_import_manual_entry">Ввести вручную</string> <string name="otp_import_manual_hint_account">Учетная запись</string> <string name="place_shortcut_on_home_screen">Разместить ярлык на рабочем столе</string> - <string name="password_list_fab_content_description">Создать новый пароль или папку</string> <string name="pgp_key_import_failed">Не удалось импортировать PGP-ключ</string> <string name="pgp_key_import_succeeded">PGP ключ успешно импортирован</string> </resources> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b20749b3..3f6abeb3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -23,7 +23,6 @@ </plurals> <string name="delete_directory_progress_text">正在删除…</string> <string name="move">更多</string> - <string name="edit">编辑</string> <string name="delete">删除</string> <string name="password_exists_title">密码已经存在!</string> <string name="password_exists_message">这将用%2$s覆盖%1$s</string> @@ -39,7 +38,6 @@ <!-- PGPHandler --> <string name="clipboard_copied_text">复制到剪切板</string> <string name="file_toast_text">请提供文件名</string> - <string name="path_toast_text">请提供文件路径</string> <string name="empty_toast_text">您不能使用空密码或空额外内容</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Git操作过程中发生错误</string> @@ -63,7 +61,6 @@ <string name="crypto_name_hint">名称</string> <string name="crypto_pass_label">密码</string> <string name="crypto_extra_label">附加内容</string> - <string name="crypto_encrypt_username_label">加密用户名</string> <string name="crypto_select">选择</string> <string name="crypto_save">保存</string> <string name="crypto_save_and_copy">保存并复制</string> @@ -98,8 +95,6 @@ <string name="pref_recursive_filter_summary">递归式寻找当前目录的密码</string> <string name="pref_sort_order_title">密码排序顺序</string> <string name="pref_folder_first_sort_order">目录优先</string> - <string name="pref_file_first_sort_order">文件优先</string> - <string name="pref_type_independent_sort_order">类型无关</string> <string name="pref_recently_used_sort_order">最近使用</string> <string name="pref_category_autofill_title">自动填充</string> <string name="pref_autofill_enable_title">启用自动填充</string> @@ -122,7 +117,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">生成密码</string> <string name="pwgen_generate">生成</string> - <string name="pwgen_include">包含</string> <string name="pwgen_length">长度</string> <string name="pwgen_numerals">数字</string> <string name="pwgen_symbols">标识</string> @@ -168,7 +162,6 @@ <string name="dialog_yes">同意</string> <string name="dialog_no">否定</string> <string name="dialog_cancel">取消</string> - <string name="dialog_skip">跳过</string> <string name="git_sync">同步存储库</string> <string name="git_pull">拉取远程数据</string> <string name="git_push">推送数据到远程</string> @@ -176,10 +169,7 @@ <string name="git_log">显示提交日志</string> <string name="show_password_pref_title">显示密码</string> <string name="show_password_pref_summary">控制密码解密后的可见性,这不会禁用复制到剪贴板</string> - <string name="pwd_generate_button">生成</string> - <string name="refresh_list">刷新列表</string> <string name="send_plaintext_password_to">使用明文发送密码…</string> - <string name="app_icon_hint">应用图标</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">选择要填写的条目</string> <string name="oreo_autofill_strict_domain_search">防钓鱼搜索</string> @@ -212,7 +202,6 @@ <string name="oreo_autofill_enable_dialog_installed_browsers">已安装支持自动填充的浏览器:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">无法打开SSH私钥,请检查文件是否存在</string> - <string name="new_password_title">新密码</string> <string name="clear_saved_passphrase_ssh">清除本地已保存的SSH密钥密码</string> <string name="clear_saved_passphrase_https">清除保存的HTTPS密码</string> <string name="git_operation_remember_passphrase">记住密钥密码</string> @@ -225,9 +214,8 @@ <string name="biometric_prompt_title">生物识别提示</string> <string name="biometric_auth_error_reason">验证失败: %s</string> <string name="pref_biometric_auth_title">启用生物识别认证</string> - <string name="pref_biometric_auth_summary">如果启用,Password Store 将在启动应用时提示您使用指纹进行身份验证</string> + <string name="pref_biometric_auth_summary">如果启用,HardenedPass 将在启动应用时提示您使用指纹进行身份验证</string> <string name="pref_biometric_auth_summary_error">指纹硬件无法访问或丢失</string> - <string name="pref_title_openkeystore_clear_keyid">清除记住的 OpenKeystore SSH 密钥ID</string> <string name="your_public_key">你的公开密钥</string> <string name="error_generate_ssh_key">尝试生成SSH密钥时出错</string> <string name="pref_show_hidden_title">显示所有文件和目录</string> @@ -235,7 +223,6 @@ <string name="title_create_folder">清除目录</string> <string name="title_rename_folder">重命名目录</string> <string name="message_category_error_empty_field">类别名称不能为空</string> - <string name="message_category_error_category_exists">类别名称已存在</string> <string name="message_error_destination_outside_repo">目标必须在存储库中</string> <string name="message_rename_folder">输入%1$s的目标</string> <string name="button_create">创建</string> @@ -282,8 +269,6 @@ personal.com</string> <string name="invalid_gpg_id">找到了 .gpg-id,但它包含无效的密钥ID、指纹或用户ID</string> <string name="short_gpg_id">找到 .gpg-id 但它包含一个不受支持的短十六进制ID。</string> <string name="invalid_filename_text">文件名不能包含\'/\'</string> - <string name="directory_hint">目录</string> - <string name="new_folder_set_gpg_key">为目录设置GPG密钥</string> <!-- GitException messages --> <string name="git_unknown_error">未知错误</string> <string name="git_pull_merge_fail_error">合并失败,你处于冲突状态。</string> @@ -296,18 +281,12 @@ personal.com</string> 使用此分支解决计算机上的冲突</string> <string name="git_break_out_of_detached_unneeded">存储库没有变更,无需推送到另一个分支</string> <!-- GPG key selection in folder creation --> - <string name="gpg_key_select_mandatory">选择一个 GPG 密钥才能继续</string> <string name="folder_creation_err_file_exists">该名称的文件已存在</string> <string name="folder_creation_err_folder_exists">该名称的文件夹已存在</string> <!-- Onboarding flow --> - <string name="let_s_go">开始配置</string> - <string name="select_n_repository_type">选择仓库模式</string> <string name="select_repo_type_text">你是要同步仓库还是现在创建一个仓库呢?</string> <string name="clone_remote_repo">同步远程仓库</string> <string name="create_local_repo">创建本地仓库</string> - <string name="select_gpg_key_title">选择 -GPG -密钥</string> <string name="select_gpg_key_message">选择一个GPG密钥来初始化您的仓库</string> <string name="gpg_key_select">选择密钥</string> <!-- SSH port validation --> @@ -329,11 +308,9 @@ GPG <string name="clear_saved_host_key_success">已成功清除已保存的主机密钥!</string> <string name="otp_import_qr_code">扫描二维码</string> <string name="otp_import_from_file">选择图像</string> - <string name="otp_import_manual_entry">手动输入</string> <string name="otp_import_manual_hint_secret">安全码 (Secret)</string> <string name="otp_import_manual_hint_account">账户</string> <string name="place_shortcut_on_home_screen">在主屏幕上放置快捷方式</string> - <string name="password_list_fab_content_description">创建新密码或文件夹</string> <string name="pgp_key_import_failed">导入 PGP 密钥失败</string> <string name="pgp_key_import_failed_replace_message">发现了 ID 相同的现有密钥,您要替换它吗?</string> <string name="pgp_key_import_succeeded">成功导入 PGP 密钥</string> diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 9f44710b..2891987d 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -6,14 +6,10 @@ <resources> <string-array name="sort_order_entries"> <item>@string/pref_folder_first_sort_order</item> - <item>@string/pref_file_first_sort_order</item> - <item>@string/pref_type_independent_sort_order</item> <item>@string/pref_recently_used_sort_order</item> </string-array> <string-array name="sort_order_values"> <item>FOLDER_FIRST</item> - <item>FILE_FIRST</item> - <item>INDEPENDENT</item> <item>RECENTLY_USED</item> </string-array> <string-array name="pwgen_provider_labels"> diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml deleted file mode 100644 index a1d3c974..00000000 --- a/app/src/main/res/values/attrs.xml +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<resources> - <declare-styleable name="Multiselected"> - <attr name="state_multiselected" format="boolean" /> - </declare-styleable> -</resources> diff --git a/app/src/main/res/values/colors_material3.xml b/app/src/main/res/values/colors_material3.xml index 3a7341b5..ecd34921 100644 --- a/app/src/main/res/values/colors_material3.xml +++ b/app/src/main/res/values/colors_material3.xml @@ -60,5 +60,4 @@ <color name="error">#BA1B1B</color> <!-- Multiselect color --> - <color name="list_multiselect_background">#668eacbb</color> </resources> diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml deleted file mode 100644 index d6939e63..00000000 --- a/app/src/main/res/values/dimens.xml +++ /dev/null @@ -1,16 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<resources> - <!-- Default screen margins, per the Android Design guidelines. --> - <dimen name="activity_horizontal_margin">16dp</dimen> - <dimen name="activity_vertical_margin">16dp</dimen> - <dimen name="fab_compat_margin">16dp</dimen> - <dimen name="normal_margin">8dp</dimen> - <dimen name="bottom_sheet_item_height">56dp</dimen> - <dimen name="onboarding_icon_margin_top">100dp</dimen> - <dimen name="onboarding_button_margin_top">140dp</dimen> - <dimen name="onboarding_desc_margin_top">48dp</dimen> -</resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 07f24aa5..f1a858fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,7 +19,6 @@ <string name="dialog_delete">Delete directory</string> <string name="dialog_do_not_delete">Cancel</string> <string name="title_activity_git_clone">Repository information</string> - <string name="title_activity_git_config" translatable="false">Git configuration</string> <string name="title_activity_git_log">Commit log</string> <!-- Password Store --> @@ -30,7 +29,6 @@ </plurals> <string name="delete_directory_progress_text">Deleting…</string> <string name="move">Move</string> - <string name="edit">Edit</string> <string name="delete">Delete</string> <string name="password_exists_title">Password already exists!</string> <string name="password_exists_message">This will overwrite %1$s with %2$s.</string> @@ -48,7 +46,6 @@ <!-- PGPHandler --> <string name="clipboard_copied_text">Copied to clipboard</string> <string name="file_toast_text">Please provide a file name</string> - <string name="path_toast_text">Please provide a file path</string> <string name="empty_toast_text">You cannot use an empty password or empty extra content</string> <!-- Git Async Task --> @@ -80,7 +77,6 @@ <string name="crypto_username_hint">Username</string> <string name="crypto_pass_label">Password</string> <string name="crypto_extra_label">Extra content</string> - <string name="crypto_encrypt_username_label">Encrypt username</string> <string name="crypto_select">Select</string> <string name="crypto_save">Save</string> <string name="crypto_save_and_copy">Save and Copy</string> @@ -119,8 +115,6 @@ <string name="pref_directory_structure">Password file organization</string> <string name="pref_sort_order_title">Password sort order</string> <string name="pref_folder_first_sort_order">Folders first</string> - <string name="pref_file_first_sort_order">Files first</string> - <string name="pref_type_independent_sort_order">Type independent</string> <string name="pref_recently_used_sort_order">Recently used</string> <string name="pref_category_autofill_title">Autofill</string> <string name="pref_autofill_enable_title">Enable Autofill</string> @@ -149,7 +143,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Generate Password</string> <string name="pwgen_generate">Generate</string> - <string name="pwgen_include">Include</string> <string name="pwgen_length">Length</string> <string name="pwgen_numerals">Numerals</string> <string name="pwgen_symbols">Symbols</string> @@ -195,7 +188,6 @@ <string name="dialog_yes">Yes</string> <string name="dialog_no">No</string> <string name="dialog_cancel">Cancel</string> - <string name="dialog_skip">Skip</string> <string name="git_sync">Synchronize repository</string> <string name="git_pull">Pull from remote</string> <string name="git_push">Push to remote</string> @@ -203,10 +195,7 @@ <string name="git_log">Show commit log</string> <string name="show_password_pref_title">Show the password</string> <string name="show_password_pref_summary">Control the visibility of the passwords once decrypted. This does not disable copying to clipboard.</string> - <string name="pwd_generate_button">Generate</string> - <string name="refresh_list">Refresh list</string> <string name="send_plaintext_password_to">Send password as plaintext using…</string> - <string name="app_icon_hint">App icon</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Select entry to fill into</string> @@ -223,7 +212,7 @@ <string name="oreo_autofill_warning_publisher_header">This app\'s publisher has changed since you first associated a HardenedPass entry with it:</string> <string name="oreo_autofill_warning_publisher_footer"><b>The currently installed app may be trying to steal your credentials by pretending to be a trusted app.</b>\n\nTry to uninstall and reinstall the app from a trusted source, such as the Play Store, Amazon Appstore, F-Droid, or your phone manufacturer\'s store.</string> <string name="oreo_autofill_warning_publisher_install_time">Installed: %1$s</string> - <string name="oreo_autofill_warning_publisher_app_name" translatable="false">“%1$s“</string> + <string name="oreo_autofill_warning_publisher_app_name" translatable="false">“%1$s”</string> <string name="oreo_autofill_warning_publisher_advanced_info_template" translatable="false">Package:\n%1$s\n\nHash:\n%2$s</string> <string name="oreo_autofill_warning_publisher_advanced_info_button">Advanced information</string> <string name="oreo_autofill_warning_publisher_changed_disable_autofill_button">Keep Autofill disabled</string> @@ -242,7 +231,6 @@ <!-- Autofill --> <string name="ssh_key_does_not_exist">Unable to open the ssh private key, please check that the file exists</string> - <string name="new_password_title">New password</string> <string name="clear_saved_passphrase_ssh">Clear saved passphrase for local SSH key</string> <string name="clear_saved_passphrase_https">Clear saved HTTPS password</string> <string name="git_operation_remember_passphrase">Remember key passphrase</string> @@ -258,8 +246,6 @@ <string name="pref_biometric_auth_title">Enable biometric authentication</string> <string name="pref_biometric_auth_summary">When enabled, HardenedPass will prompt you for your fingerprint when launching the app</string> <string name="pref_biometric_auth_summary_error">Fingerprint hardware not accessible or missing</string> - <string name="pref_biometric_auth_summary_disabled_platform">Feature unavailable due to a platform bug</string> - <string name="pref_title_openkeystore_clear_keyid">Clear remembered OpenKeystore SSH Key ID</string> <string name="your_public_key">Your public key</string> <string name="error_generate_ssh_key">Error while trying to generate the ssh-key</string> <string name="pref_show_hidden_title">Show all files and folders</string> @@ -267,7 +253,6 @@ <string name="title_create_folder">Create folder</string> <string name="title_rename_folder">Rename folder</string> <string name="message_category_error_empty_field">Category name can\'t be empty</string> - <string name="message_category_error_category_exists">Category name already exists</string> <string name="message_error_destination_outside_repo">Destination must be within the repository</string> <string name="message_rename_folder">Enter destination for %1$s</string> <string name="button_create">Create</string> @@ -315,12 +300,10 @@ <string name="invalid_gpg_id">Found .gpg-id, but it contains an invalid key ID, fingerprint or user ID</string> <string name="short_gpg_id">Found .gpg-id, but it contains a short hex ID, which is not supported</string> <string name="invalid_filename_text">File name must not contain \'/\', set directory above</string> - <string name="directory_hint">Directory</string> - <string name="new_folder_set_gpg_key">Set GPG key for directory</string> <!-- GitException messages --> <string name="git_unknown_error">Unknown error</string> - <string name="git_pull_rebase_fail_error">Pull has failed, you\'re in a detached head. Using "settings > git utils", save your changes to the remote in a new branch and resolve the conflict on your computer.</string> + <string name="git_pull_rebase_fail_error">Pull has failed, you\'re in a detached head. Using \"Settings > Repository > Local Git config & utilities\", save your changes to the remote in a new branch and resolve the conflict on your computer.</string> <string name="git_pull_merge_fail_error">Merge has failed, you\'re in a conflicting state.</string> <string name="git_push_nff_error">Push was rejected by remote, run pull before pushing again. You can use Synchronize rather than pull/push as it implements both</string> @@ -332,17 +315,13 @@ <string name="git_break_out_of_detached_unneeded">The repository is not rebasing, no need to push to another branch</string> <!-- GPG key selection in folder creation --> - <string name="gpg_key_select_mandatory">Selecting a GPG key is necessary to proceed</string> <string name="folder_creation_err_file_exists">A file by that name already exists</string> <string name="folder_creation_err_folder_exists">A folder by that name already exists</string> <!-- Onboarding flow --> - <string name="let_s_go">Let\'s Go</string> - <string name="select_n_repository_type">Select \nRepository Type</string> <string name="select_repo_type_text">Select if you want to create a local repo or clone a remote repo.</string> <string name="clone_remote_repo">Clone Remote Repo</string> <string name="create_local_repo">Create Local Repo</string> - <string name="select_gpg_key_title">Select\nGPG Key</string> <string name="select_gpg_key_message">Select a GPG key to initialize your store with</string> <string name="gpg_key_select">Select key</string> @@ -365,11 +344,9 @@ <string name="clear_saved_host_key_success">Successfully cleared saved host key!</string> <string name="otp_import_qr_code">Scan QR code</string> <string name="otp_import_from_file">Choose an image</string> - <string name="otp_import_manual_entry">Enter manually</string> <string name="otp_import_manual_hint_secret">Secret</string> <string name="otp_import_manual_hint_account">Account</string> <string name="place_shortcut_on_home_screen">Place shortcut on home screen</string> - <string name="password_list_fab_content_description">Create new password or folder</string> <string name="pgp_key_import_failed">Failed to import PGP key</string> <string name="pgp_key_import_failed_replace_message">An existing key with this ID was found, do you want to replace it?</string> <string name="pgp_key_import_succeeded">Successfully imported PGP key</string> @@ -385,4 +362,57 @@ <string name="no_keys_imported_dialog_message">There are no PGP keys imported in the app yet, press the button below to pick a key file</string> <string name="biometric_prompt_title_gpg_passphrase_cache">Unlock passphrase cache</string> <string name="clear_cached_password_on_screen_off">Clear cached password on screen-off</string> + + <!-- 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> + + <!-- 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> + + <!-- Compose folder picker --> + <string name="folder_picker_title">Choose a folder</string> + <string name="folder_creation_err_failed">Could not create the folder</string> + <string name="folder_picker_new">New folder</string> + + <!-- 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> + + <!-- 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> + <string name="connection_mode_none">None</string> + <string name="ssh_key_manage">Manage</string> + <string name="ssh_key_public_hint">Provide this public key to your Git server.</string> + <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> + <string name="store_not_empty">the folder already holds a store</string> + <string name="git_clone_delete_failed">Could not empty the store folder, so the clone did not start.</string> + <string name="pgp_key_import_too_large">The file is larger than 1 MiB: not a PGP key.</string> + <string name="pgp_key_import_downgrade">A private key with this ID is already imported. Its public key alone cannot replace it: delete the private key first if you really want to.</string> + + <!-- 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="invalid_proxy_port">Enter a port from 1 to 65535, or nothing</string> + <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> + <string name="store_delete_confirm">Delete the local store? Entries not pushed to the remote are lost.</string> </resources> diff --git a/app/src/main/res/values/styles_material3.xml b/app/src/main/res/values/styles_material3.xml deleted file mode 100644 index 329905e1..00000000 --- a/app/src/main/res/values/styles_material3.xml +++ /dev/null @@ -1,12 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<resources> - - <style name="APSThemeM3.Toolbar" parent="Widget.Material3.Toolbar"> - <item name="android:background">?android:colorBackground</item> - </style> - -</resources> diff --git a/app/src/main/res/values/themes_material3.xml b/app/src/main/res/values/themes_material3.xml index 7e34b472..0ec74a45 100644 --- a/app/src/main/res/values/themes_material3.xml +++ b/app/src/main/res/values/themes_material3.xml @@ -10,8 +10,12 @@ <item name="android:windowLightStatusBar">@bool/light_status_bar</item> <item name="android:statusBarColor">?android:colorBackground</item> <item name="android:navigationBarColor">?android:colorBackground</item> - <item name="toolbarStyle">@style/APSThemeM3.Toolbar</item> - <item name="bottomSheetDialogTheme">@style/APSThemeM3.BottomSheetDialog</item> + </style> + + <!-- AppCompat activities whose whole UI is Compose --> + <style name="NoActionBarThemeM3" parent="AppThemeM3"> + <item name="windowActionBar">false</item> + <item name="windowNoTitle">true</item> </style> <style name="NoBackgroundThemeM3" parent="@style/AppThemeM3"> @@ -37,12 +41,6 @@ <item name="windowNoTitle">true</item> </style> - <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> - <style name="APSThemeM3" parent="Theme.Material3.DayNight"> <item name="colorPrimary">@color/md_theme_light_primary</item> <item name="colorOnPrimary">@color/md_theme_light_onPrimary</item> diff --git a/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt b/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt new file mode 100644 index 00000000..39f7f358 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.autofill + +import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult +import app.passwordstore.util.settings.DirectoryStructure +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +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 }, + ) + } + + @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))) + } +} diff --git a/app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt b/app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt new file mode 100644 index 00000000..fee6bf72 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Danilo M. <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")) + } +} diff --git a/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt b/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt new file mode 100644 index 00000000..160434c9 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt @@ -0,0 +1,156 @@ +/* + * 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"))) + assertEquals( + File(root, "b/mail.gpg"), + entryFile(root, EditorForm(name = "mail", dir = "./a/../b")), + ) + } + + @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, + ) + } +} diff --git a/app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt b/app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt new file mode 100644 index 00000000..290c51a9 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt @@ -0,0 +1,57 @@ +/* + * 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()) + } +} diff --git a/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt new file mode 100644 index 00000000..cf12deef --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt @@ -0,0 +1,105 @@ +/* + * 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 }) + } + + @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)) + } +} diff --git a/app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt b/app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt new file mode 100644 index 00000000..8a1ae1d9 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt @@ -0,0 +1,29 @@ +/* + * 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.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class StoreActionsTest { + + @Test + fun deleteDoesNotFollowSymlinks() { + val root = Files.createTempDirectory("aps").toFile() + val outsideDir = File(root, "outside").apply { mkdirs() } + val outsideFile = File(outsideDir, "secret.gpg").apply { writeText("x") } + val folder = File(root, "store/folder").apply { mkdirs() } + File(folder, "login.gpg").writeText("x") + Files.createSymbolicLink(File(folder, "link").toPath(), outsideDir.toPath()) + + assertTrue(folder.deleteWithoutFollowingLinks()) + + assertFalse(folder.exists()) + assertTrue(outsideFile.exists()) + } +} diff --git a/app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt b/app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt new file mode 100644 index 00000000..693e07dd --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt @@ -0,0 +1,90 @@ +/* + * 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) + } + } +} diff --git a/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt b/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt new file mode 100644 index 00000000..9ccddbd3 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt @@ -0,0 +1,150 @@ +/* + * 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 storeRecipientsDropCommentsAndExactSubkeyMarks() { + val ids = + storeRecipients( + "# store key\nF8993A789CDFB014! # laptop\n79E8208280490C77!\nalice@example.org#work\n # \n" + ) + assertEquals( + listOf("f8993a789cdfb014", "79e8208280490c77", "alice@example.org"), + ids.map(PGPIdentifier::toString), + ) + } + + @Test + fun readAtMostRefusesMoreThanTheLimit() { + assertEquals("abc", readAtMost("abc".byteInputStream(), 3)?.decodeToString()) + assertEquals("", readAtMost("".byteInputStream(), 3)?.decodeToString()) + assertNull(readAtMost("abcd".byteInputStream(), 3)) + } + + @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)) + } + + @Test + fun createdForMatchesOnlyTheChosenKeysGpgId() { + val key = PGPIdentifier.fromString("0xf8993a789cdfb014") as PGPIdentifier.KeyId + assertEquals(false, createdFor(root, key)) + File(root, ".gpg-id").writeText(gpgIdText(listOf(key))) + assertEquals(true, createdFor(root, key)) + File(root, ".gpg-id").writeText("F8993A789CDFB014\nB950AE2813841585\n") + assertEquals(false, createdFor(root, key)) + } +} diff --git a/app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt b/app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt new file mode 100644 index 00000000..1a9914fb --- /dev/null +++ b/app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt @@ -0,0 +1,33 @@ +/* + * 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))) + } +} diff --git a/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt new file mode 100644 index 00000000..6b30c248 --- /dev/null +++ b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt @@ -0,0 +1,27 @@ +/* + * 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)) + } +} diff --git a/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt b/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt index 62109563..44bd12ba 100644 --- a/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt +++ b/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt @@ -184,4 +184,29 @@ class MigrationsTest { assertFalse { sharedPrefs.contains(PreferenceKeys.GIT_EXTERNAL_REPO) } assertTrue { sharedPrefs.getBoolean(PreferenceKeys.GIT_EXTERNAL_MIGRATED, false) } } + + @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)) + } } diff --git a/app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt b/app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt new file mode 100644 index 00000000..34348692 --- /dev/null +++ b/app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt @@ -0,0 +1,26 @@ +/* + * 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)) + } +} diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/ApplicationPlugin.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/ApplicationPlugin.kt index 6d38d1fc..051645d1 100644 --- a/build-logic/src/main/kotlin/app/passwordstore/gradle/ApplicationPlugin.kt +++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/ApplicationPlugin.kt @@ -28,10 +28,7 @@ class ApplicationPlugin : Plugin<Project> { includeInApk = false } - buildFeatures { - viewBinding = true - buildConfig = true - } + buildFeatures { buildConfig = true } buildTypes { named("release") { diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/LintConfig.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/LintConfig.kt index ba3c32e8..6f12065a 100644 --- a/build-logic/src/main/kotlin/app/passwordstore/gradle/LintConfig.kt +++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/LintConfig.kt @@ -33,8 +33,6 @@ object LintConfig { disable += "SyntheticAccessor" // Noisy, not particularly actionable disable += "TypographyQuotes" - // False-positives abound due to use of ViewBinding - disable += "UnusedIds" // False-positive, not relevant on the API levels we support disable += "TrulyRandom" // I can't do anything about this @@ -45,8 +43,6 @@ object LintConfig { error += "ComposeM2Api" // slack-lint-checks' checker for deprecations is useless to me disable += "DeprecatedCall" - // Hilt only does field injection for fragments - disable += "FragmentFieldInjection" // Too pedantic disable += "ArgInFormattedQuantityStringRes" } diff --git a/crypto/common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt b/crypto/common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt index af6145d0..9589ceec 100644 --- a/crypto/common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt +++ b/crypto/common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt @@ -39,6 +39,10 @@ public class KeyNotFoundException(keyId: String) : public class KeyAlreadyExistsException(keyId: String) : KeyManagerException("Pre-existing key was found for $keyId") +/** Attempting to replace a stored secret key with a public-only key of the same `keyId`. */ +public class KeyDowngradeException(keyId: String) : + KeyManagerException("A secret key is stored for $keyId; a public key cannot replace it") + /** Sealed exception types for [app.passwordstore.crypto.CryptoHandler]. */ public sealed class CryptoHandlerException(message: String? = null, cause: Throwable? = null) : CryptoException(message, cause) diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt index f85fe92c..9acd0489 100644 --- a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt @@ -10,6 +10,7 @@ import app.passwordstore.crypto.PGPIdentifier.UserId import com.github.michaelbull.result.get import com.github.michaelbull.result.runCatching import org.bouncycastle.openpgp.PGPKeyRing +import org.bouncycastle.openpgp.PGPSecretKeyRing import org.pgpainless.PGPainless import org.pgpainless.key.parsing.KeyRingReader @@ -35,9 +36,15 @@ public object KeyUtils { */ public fun tryGetEmail(key: PGPKey): UserId? { val keyRing = tryParseKeyring(key) ?: return null - return UserId(keyRing.publicKey.userIDs.next()) + // 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. */ + // ponytail: GnuPG stub and smartcard keyrings (no usable secret material) count as secret too. + // Upgrade path: check for a secret subkey usable for decryption. + public fun isSecretKey(key: PGPKey): Boolean = tryParseKeyring(key) is PGPSecretKeyRing + /** * Tests if the given [key] can be used for encryption, which is a bare minimum necessity for the * app. diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt index 6f852af4..33d61343 100644 --- a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt @@ -14,6 +14,7 @@ import app.passwordstore.crypto.errors.InvalidKeyException import app.passwordstore.crypto.errors.KeyAlreadyExistsException import app.passwordstore.crypto.errors.KeyDeletionFailedException import app.passwordstore.crypto.errors.KeyDirectoryUnavailableException +import app.passwordstore.crypto.errors.KeyDowngradeException import app.passwordstore.crypto.errors.KeyNotFoundException import app.passwordstore.crypto.errors.NoKeysAvailableException import app.passwordstore.crypto.errors.UnusableKeyException @@ -21,6 +22,8 @@ import com.github.michaelbull.result.Result import com.github.michaelbull.result.coroutines.runSuspendCatching import com.github.michaelbull.result.unwrap import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext @@ -50,25 +53,27 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) : tryParseKeyring(PGPKey(existingKeyBytes)) ?: throw InvalidKeyException when { existingKeyRing is PGPPublicKeyRing && incomingKeyRing is PGPSecretKeyRing -> { - keyFile.writeBytes(key.contents) + writeAtomically(keyFile, key.contents) return@runSuspendCatching key } existingKeyRing is PGPPublicKeyRing && incomingKeyRing is PGPPublicKeyRing -> { val updatedPublicKey = PGPainless.mergeCertificate(existingKeyRing, incomingKeyRing) val keyBytes = PGPainless.asciiArmor(updatedPublicKey).encodeToByteArray() - keyFile.writeBytes(keyBytes) + writeAtomically(keyFile, keyBytes) return@runSuspendCatching key } + // Never with replace either: it would delete the only copy of the private key. + existingKeyRing is PGPSecretKeyRing && incomingKeyRing is PGPPublicKeyRing -> + throw KeyDowngradeException(tryGetId(key)?.toString() ?: "Failed to retrieve key ID") } // Check for replace flag first and if it is false, throw an error if (!replace) throw KeyAlreadyExistsException( tryGetId(key)?.toString() ?: "Failed to retrieve key ID" ) - if (!keyFile.delete()) throw KeyDeletionFailedException } - keyFile.writeBytes(key.contents) + writeAtomically(keyFile, key.contents) key } @@ -92,7 +97,7 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) : withContext(dispatcher) { runSuspendCatching { if (!keyDirExists()) throw KeyDirectoryUnavailableException - val keyFiles = keyDir.listFiles() + val keyFiles = keyDir.listFiles { file -> file.extension == KEY_EXTENSION } if (keyFiles.isNullOrEmpty()) throw NoKeysAvailableException val keys = keyFiles.map { file -> PGPKey(file.readBytes()) } @@ -130,7 +135,7 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) : withContext(dispatcher) { runSuspendCatching { if (!keyDirExists()) throw KeyDirectoryUnavailableException - val keyFiles = keyDir.listFiles() + val keyFiles = keyDir.listFiles { file -> file.extension == KEY_EXTENSION } if (keyFiles.isNullOrEmpty()) return@runSuspendCatching emptyList() keyFiles.map { keyFile -> PGPKey(keyFile.readBytes()) }.toList() } @@ -139,6 +144,25 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) : /** @see KeyManager.getKeyById */ override suspend fun getKeyId(key: PGPKey): PGPIdentifier? = tryGetId(key) + /** + * Writes [bytes] to a temporary file next to [file] and moves it over [file] in one step, so a + * failed write keeps the key that was there. + */ + private fun writeAtomically(file: File, bytes: ByteArray) { + val temp = File(file.parentFile, ".${file.name}.tmp") + try { + temp.writeBytes(bytes) + Files.move( + temp.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } finally { + temp.delete() + } + } + /** Checks if [keyDir] exists and attempts to create it if not. */ private fun keyDirExists(): Boolean { return keyDir.exists() || keyDir.mkdirs() diff --git a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt index e6e3d7d1..3281c334 100644 --- a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt +++ b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt @@ -6,14 +6,18 @@ package app.passwordstore.crypto import app.passwordstore.crypto.KeyUtils.isKeyUsable +import app.passwordstore.crypto.KeyUtils.isSecretKey +import app.passwordstore.crypto.KeyUtils.tryGetEmail import app.passwordstore.crypto.KeyUtils.tryGetId import app.passwordstore.crypto.KeyUtils.tryParseKeyring import app.passwordstore.crypto.TestUtils.AllKeys import app.passwordstore.crypto.TestUtils.getArmoredSecretKeyWithMultipleIdentities import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull +import kotlin.test.assertTrue import org.bouncycastle.openpgp.PGPSecretKeyRing class KeyUtilsTest { @@ -37,4 +41,19 @@ class KeyUtilsTest { assertEquals(isUsable, isKeyUsable(key), "${allKeys.name} failed expectation:") } } + + @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()))) + } } diff --git a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt index 0525c28a..587f7239 100644 --- a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt +++ b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt @@ -9,6 +9,7 @@ import app.passwordstore.crypto.KeyUtils.tryGetId import app.passwordstore.crypto.PGPIdentifier.KeyId import app.passwordstore.crypto.PGPIdentifier.UserId import app.passwordstore.crypto.errors.KeyAlreadyExistsException +import app.passwordstore.crypto.errors.KeyDowngradeException import app.passwordstore.crypto.errors.KeyNotFoundException import app.passwordstore.crypto.errors.NoKeysAvailableException import app.passwordstore.crypto.errors.UnusableKeyException @@ -71,6 +72,11 @@ class PGPKeyManagerTest { val keyId = keyManager.getKeyId(keyManager.addKey(secretKey, true).unwrap()) assertEquals(KeyId(CryptoConstants.KEY_ID), keyId) + // Replaced through a temporary file that does not stay behind. + assertEquals(listOf("$keyId.${PGPKeyManager.KEY_EXTENSION}"), keysDir.list()?.toList()) + // A stray temporary file left by a crash is not listed as a key. + File(keysDir, ".$keyId.${PGPKeyManager.KEY_EXTENSION}.tmp").writeText("partial") + assertEquals(1, keyManager.getAllKeys().unwrap().size) } @Test @@ -177,6 +183,16 @@ class PGPKeyManagerTest { } @Test + fun replaceSecretKeyWithPublicKeyIsRefusedEvenWithReplace() = + runTest(dispatcher) { + keyManager.addKey(secretKey).unwrap() + assertIs<KeyDowngradeException>(keyManager.addKey(publicKey, replace = true).unwrapError()) + assertIs<KeyDowngradeException>(keyManager.addKey(publicKey, replace = false).unwrapError()) + // The private key is still the stored one. + assertContentEquals(secretKey.contents, keyManager.getAllKeys().unwrap().single().contents) + } + + @Test fun replacePublicKeyWithSecretKey() = runTest(dispatcher) { assertTrue(keyManager.addKey(publicKey).isOk) 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&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. diff --git a/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt b/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt index f0009dcf..1995342e 100644 --- a/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt +++ b/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt @@ -224,7 +224,7 @@ constructor( digits, issuer, ) - .mapBoth({ code -> Ok(Totp(code, remainingTime)) }, ::Err) + .mapBoth({ code -> Ok(Totp(code, remainingTime, totpPeriod.seconds)) }, ::Err) } @AssistedFactory diff --git a/format/common/src/main/kotlin/app/passwordstore/data/passfile/Totp.kt b/format/common/src/main/kotlin/app/passwordstore/data/passfile/Totp.kt index 5e98ecd4..a26f03d9 100644 --- a/format/common/src/main/kotlin/app/passwordstore/data/passfile/Totp.kt +++ b/format/common/src/main/kotlin/app/passwordstore/data/passfile/Totp.kt @@ -7,5 +7,9 @@ package app.passwordstore.data.passfile import kotlin.time.Duration -/** Holder for a TOTP secret and the duration for which it is valid. */ -public data class Totp(public val value: String, public val remainingTime: 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, +) diff --git a/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt b/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt index 4efb140b..b7d41fd1 100644 --- a/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt +++ b/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt @@ -134,6 +134,7 @@ class PasswordEntryTest { val otp = expectMostRecentItem() assertEquals("818800", otp.value) assertEquals(30.seconds, otp.remainingTime) + assertEquals(30.seconds, otp.period) cancelAndIgnoreRemainingEvents() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c3cdcf35..c0a37319 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,6 @@ agp = "9.5.0" androidxActivity = "1.9.2" bouncycastle = "1.86" coroutines = "1.9.0" -flowbinding = "1.2.0" hilt = "2.60.1" kotlin = "2.4.21" kotlinResult = "2.0.0" @@ -18,19 +17,17 @@ androidx-annotation = "androidx.annotation:annotation:1.8.2" androidx-appcompat = "androidx.appcompat:appcompat:1.7.0" androidx-autofill = "androidx.autofill:autofill:1.3.0" androidx-biometricKtx = "androidx.biometric:biometric-ktx:1.2.0-alpha05" -androidx-constraintlayout = "androidx.constraintlayout:constraintlayout:2.2.2" androidx-core-ktx = "androidx.core:core-ktx:1.13.1" androidx-documentfile = "androidx.documentfile:documentfile:1.1.0" androidx-fragment-ktx = "androidx.fragment:fragment-ktx:1.8.4" +androidx-hilt-navigation-compose = "androidx.hilt:hilt-navigation-compose:1.2.0" androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "lifecycle" } androidx-lifecycle-livedataKtx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodelKtx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +androidx-navigation-compose = "androidx.navigation:navigation-compose:2.8.9" androidx-material = "com.google.android.material:material:1.12.0" -androidx-preference = "androidx.preference:preference:1.2.1" -androidx-recyclerview = "androidx.recyclerview:recyclerview:1.4.0" -androidx-recyclerviewSelection = "androidx.recyclerview:recyclerview-selection:1.2.0" -androidx-swiperefreshlayout = "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0" aps-sublimeFuzzy = "com.github.android-password-store:sublime-fuzzy:2.3.4" aps-zxingAndroidEmbedded = "com.github.android-password-store:zxing-android-embedded:4.2.1" build-agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } @@ -60,6 +57,7 @@ kotlinx-collections-immutable = "org.jetbrains.kotlinx:kotlinx-collections-immut kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-core = "org.jetbrains.kotlinx:kotlinx-serialization-core:1.12.0" testing-junit = "junit:junit:4.13.2" testing-kotlintest-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } testing-robolectric = "org.robolectric:robolectric:4.13" @@ -72,8 +70,6 @@ thirdparty-bouncycastle-bcutil = { module = "org.bouncycastle:bcutil-jdk18on", v # noinspection GradleDependency thirdparty-commons_codec = "commons-codec:commons-codec:1.14" thirdparty-compose-lints = "com.slack.lint.compose:compose-lint-checks:1.4.1" -thirdparty-fastscroll = "me.zhanghai.android.fastscroll:library:1.3.0" -thirdparty-flowbinding-android = { module = "io.github.reactivecircus.flowbinding:flowbinding-android", version.ref = "flowbinding" } # JGit upgrades also raise its minimum Java runtime requirements that we cannot satisfy on Android # noinspection GradleDependency thirdparty-jgit = "org.eclipse.jgit:org.eclipse.jgit:5.13.5.202508271544-r" @@ -81,7 +77,6 @@ thirdparty-kotlinResult = { module = "com.michael-bull.kotlin-result:kotlin-resu thirdparty-kotlinResult-coroutines = { module = "com.michael-bull.kotlin-result:kotlin-result-coroutines", version.ref = "kotlinResult" } thirdparty-leakcanary-plumber = "com.squareup.leakcanary:plumber-android-startup:2.14" thirdparty-logcat = "com.squareup.logcat:logcat:0.1" -thirdparty-modernAndroidPrefs = "de.maxr1998:modernandroidpreferences:2.4.0-beta2" thirdparty-pgpainless = "org.pgpainless:pgpainless-core:1.7.8" thirdparty-slack-lints = "com.slack.lint:slack-lint-checks:0.8.0" thirdparty-slf4j-api = { module = "org.slf4j:slf4j-api", version = { strictly = "[1.7, 1.8[", prefer = "1.7.36" } } @@ -103,3 +98,4 @@ testDependencies = [ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = "com.google.devtools.ksp:2.3.13" kotlin-composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/sentry.properties b/sentry.properties deleted file mode 100644 index 47a8fadc..00000000 --- a/sentry.properties +++ /dev/null @@ -1,2 +0,0 @@ -defaults.project=password-store-google-play -defaults.org=password-store diff --git a/ui/compose/build.gradle.kts b/ui/compose/build.gradle.kts index b4f195cf..81d1e4cd 100644 --- a/ui/compose/build.gradle.kts +++ b/ui/compose/build.gradle.kts @@ -22,4 +22,5 @@ dependencies { api(libs.compose.ui.core) implementation(libs.androidx.core.ktx) implementation(libs.compose.ui.tooling) + testImplementation(libs.bundles.testDependencies) } diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.kt deleted file mode 100644 index 7ac99023..00000000 --- a/ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui - -import android.annotation.SuppressLint -import androidx.compose.foundation.layout.RowScope -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import app.passwordstore.ui.compose.preview.DevicePreviews -import app.passwordstore.ui.compose.preview.ThemePreviews -import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.ui.compose.theme.SpacingMedium - -@SuppressLint("ComposableLambdaParameterNaming") // The lint doesn't really apply to `actions` -@Composable -@OptIn(ExperimentalMaterial3Api::class) -public fun APSAppBar( - title: String, - backgroundColor: Color, - navigationIcon: Painter?, - modifier: Modifier = Modifier, - onNavigationIconClick: (() -> Unit) = {}, - actions: @Composable RowScope.() -> Unit = {}, -) { - TopAppBar( - title = { Text(text = title) }, - navigationIcon = { - if (navigationIcon != null) { - IconButton(onClick = onNavigationIconClick) { - Icon(painter = navigationIcon, contentDescription = "Back navigation button") - } - } - }, - actions = actions, - colors = TopAppBarDefaults.topAppBarColors(containerColor = backgroundColor), - modifier = modifier.shadow(SpacingMedium), - ) -} - -@ThemePreviews -@DevicePreviews -@Composable -private fun APSAppBarPreview() { - APSTheme { - APSAppBar( - title = "Preview", - backgroundColor = MaterialTheme.colorScheme.surface, - navigationIcon = rememberVectorPainter(Icons.AutoMirrored.Filled.ArrowBack), - actions = { - IconButton(onClick = {}) { - Icon( - painter = rememberVectorPainter(Icons.Filled.Search), - contentDescription = "Search items", - ) - } - }, - ) - } -} diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt new file mode 100644 index 00000000..3e51d1b4 --- /dev/null +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/Breadcrumbs.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.compose + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.passwordstore.ui.compose.preview.ThemePreviews +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingSmall + +/** One breadcrumb chip. The root crumb has an empty [label] and [path]. */ +public data class Crumb(val label: String, val path: String) + +/** Crumbs for a folder at [relativePath] inside the store: root first, then each level. */ +public fun crumbsFor(relativePath: String): List<Crumb> { + val parts = relativePath.split('/').filter { it.isNotEmpty() } + return listOf(Crumb("", "")) + + parts.indices.map { i -> Crumb(parts[i], parts.subList(0, i + 1).joinToString("/")) } +} + +/** + * Folder location as chips. The last crumb is highlighted; tapping any crumb reports its path. + * [rootDescription] is the content description of the root (home) chip. + */ +@Composable +@Suppress("ComposeUnstableCollections") +public fun BreadcrumbBar( + crumbs: List<Crumb>, + rootDescription: String, + onCrumbClick: (path: String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.horizontalScroll(rememberScrollState()).padding(horizontal = SpacingLarge), + horizontalArrangement = Arrangement.spacedBy(SpacingSmall), + verticalAlignment = Alignment.CenterVertically, + ) { + crumbs.forEachIndexed { i, crumb -> + if (i > 0) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + val current = i == crumbs.lastIndex + AssistChip( + onClick = { onCrumbClick(crumb.path) }, + label = { + if (crumb.path.isEmpty()) Icon(Icons.Filled.Home, contentDescription = rootDescription) + else Text(crumb.label) + }, + colors = + if (current) + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + else AssistChipDefaults.assistChipColors(), + border = null, + ) + } + } +} + +@ThemePreviews +@Composable +private fun BreadcrumbBarPreview() { + APSTheme { BreadcrumbBar(crumbsFor("work/servers"), "Store root", onCrumbClick = {}) } +} diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt new file mode 100644 index 00000000..2cabaefd --- /dev/null +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/EntryRows.kt @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.compose + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import app.passwordstore.ui.compose.preview.ThemePreviews +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium + +/** Circle with the first letter of [name]; the color pair is picked from the name's hash. */ +@Composable +public fun LetterAvatar(name: String, modifier: Modifier = Modifier) { + val scheme = MaterialTheme.colorScheme + val pairs = + listOf( + scheme.primaryContainer to scheme.onPrimaryContainer, + scheme.secondaryContainer to scheme.onSecondaryContainer, + scheme.tertiaryContainer to scheme.onTertiaryContainer, + ) + val (bg, fg) = pairs[Math.floorMod(name.hashCode(), pairs.size)] + Box( + modifier.size(36.dp).background(bg, CircleShape).clearAndSetSemantics {}, + contentAlignment = Alignment.Center, + ) { + Text(name.take(1).uppercase(), color = fg, style = MaterialTheme.typography.titleMedium) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun RowShell( + onClick: () -> Unit, + onLongClick: (() -> Unit)?, + selected: Boolean, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .background( + if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent + ) + .semantics { this.selected = selected } + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = SpacingLarge, vertical = SpacingMedium + 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + content() + } +} + +/** A folder row: icon, name, entry count. */ +@Composable +public fun FolderRow( + name: String, + count: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false, + onLongClick: (() -> Unit)? = null, +) { + RowShell(onClick, onLongClick, selected, modifier) { + Box( + Modifier.size(36.dp) + .background(MaterialTheme.colorScheme.secondaryContainer, CircleShape) + .clearAndSetSemantics {}, + contentAlignment = Alignment.Center, + ) { + Icon( + painterResource(R.drawable.ic_folder_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + Text(name, Modifier.weight(1f).padding(start = SpacingLarge)) + Text( + count.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** An entry row: letter avatar, name, optional subtitle (username, "OTP"). */ +@Composable +public fun EntryRow( + name: String, + subtitle: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false, + onLongClick: (() -> Unit)? = null, +) { + RowShell(onClick, onLongClick, selected, modifier) { + LetterAvatar(name) + Column(Modifier.weight(1f).padding(start = SpacingLarge)) { + Text(name) + if (subtitle != null) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@ThemePreviews +@Composable +private fun RowsPreview() { + APSTheme { + GroupedSurface { + FolderRow("legacy", 2, onClick = {}) + EntryRow("git.example.org", "you", onClick = {}) + EntryRow("vpn", "you · OTP", onClick = {}, selected = true) + } + } +} diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt new file mode 100644 index 00000000..e632acc1 --- /dev/null +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt @@ -0,0 +1,102 @@ +/* + * 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 = {}) + } + } +} diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/GroupedSurface.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/GroupedSurface.kt new file mode 100644 index 00000000..5a86c549 --- /dev/null +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/GroupedSurface.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 Danilo M. <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.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.passwordstore.ui.compose.theme.SpacingMedium + +/** Rounded container that groups related rows (folders, entries, fields). */ +@Composable +public fun GroupedSurface( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = modifier.padding(horizontal = SpacingMedium, vertical = SpacingMedium / 2), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column(content = content) + } +} diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt deleted file mode 100644 index 066a839c..00000000 --- a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.compose - -import androidx.annotation.StringRes -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -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.platform.LocalClipboardManager -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation - -@Composable -public fun PasswordField( - value: String, - label: String, - initialVisibility: Boolean, - modifier: Modifier = Modifier, - readOnly: Boolean = false, -) { - var visible by remember { mutableStateOf(initialVisibility) } - TextField( - value = value, - onValueChange = {}, - readOnly = readOnly, - label = { Text(label) }, - visualTransformation = - if (visible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - ToggleButton( - visible = visible, - contentDescription = "Toggle password visibility", - onButtonClick = { visible = !visible }, - ) - }, - modifier = modifier, - ) -} - -@Composable -private fun ToggleButton( - visible: Boolean, - contentDescription: String, - onButtonClick: () -> Unit, - modifier: Modifier = Modifier, -) { - IconButton(onClick = onButtonClick, modifier = modifier) { - val icon = - if (visible) painterResource(id = R.drawable.baseline_visibility_off_24) - else painterResource(id = R.drawable.baseline_visibility_24) - Icon(painter = icon, contentDescription = contentDescription) - } -} - -@Composable -public fun CopyButton( - textToCopy: String, - @StringRes buttonLabelRes: Int, - modifier: Modifier = Modifier, -) { - val clipboard = LocalClipboardManager.current - IconButton(onClick = { clipboard.setText(AnnotatedString(textToCopy)) }, modifier = modifier) { - Icon( - painter = painterResource(R.drawable.ic_content_copy), - contentDescription = stringResource(buttonLabelRes), - ) - } -} diff --git a/ui/compose/src/main/res/drawable/ic_folder_24.xml b/ui/compose/src/main/res/drawable/ic_folder_24.xml new file mode 100644 index 00000000..747e21a6 --- /dev/null +++ b/ui/compose/src/main/res/drawable/ic_folder_24.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z" /> +</vector> diff --git a/ui/compose/src/test/kotlin/app/passwordstore/ui/compose/BreadcrumbsTest.kt b/ui/compose/src/test/kotlin/app/passwordstore/ui/compose/BreadcrumbsTest.kt new file mode 100644 index 00000000..2eb4c3fc --- /dev/null +++ b/ui/compose/src/test/kotlin/app/passwordstore/ui/compose/BreadcrumbsTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.compose + +import kotlin.test.Test +import kotlin.test.assertEquals + +class BreadcrumbsTest { + + @Test + fun rootOnly() { + assertEquals(listOf(Crumb("", "")), crumbsFor("")) + } + + @Test + fun nestedPath() { + assertEquals( + listOf(Crumb("", ""), Crumb("work", "work"), Crumb("servers", "work/servers")), + crumbsFor("work/servers"), + ) + } + + @Test + fun ignoresStraySlashes() { + assertEquals( + listOf(Crumb("", ""), Crumb("work", "work"), Crumb("servers", "work/servers")), + crumbsFor("/work//servers/"), + ) + } +} |
