aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/ISSUE_TEMPLATE/bug_report.yaml8
-rw-r--r--.github/ISSUE_TEMPLATE/feature_request.yaml2
-rw-r--r--.github/workflows/deploy_github_releases.yml7
-rw-r--r--AGENTS.md57
-rw-r--r--CHANGELOG.md49
-rw-r--r--README.md24
-rw-r--r--TODO.md25
-rw-r--r--app/build.gradle.kts6
-rw-r--r--app/lint-baseline.xml14
-rw-r--r--app/lint.xml1
-rw-r--r--app/src/debug/ic_launcher-playstore.pngbin20129 -> 0 bytes
-rw-r--r--app/src/debug/res/drawable/ic_launcher_background.xml2
-rw-r--r--app/src/debug/res/drawable/ic_launcher_foreground.xml63
-rw-r--r--app/src/debug/res/mipmap-anydpi/ic_launcher.xml10
-rw-r--r--app/src/debug/res/mipmap-hdpi/ic_launcher.pngbin4212 -> 0 bytes
-rw-r--r--app/src/debug/res/mipmap-mdpi/ic_launcher.pngbin2635 -> 0 bytes
-rw-r--r--app/src/debug/res/mipmap-xhdpi/ic_launcher.pngbin5946 -> 0 bytes
-rw-r--r--app/src/debug/res/mipmap-xxhdpi/ic_launcher.pngbin9368 -> 0 bytes
-rw-r--r--app/src/debug/res/mipmap-xxxhdpi/ic_launcher.pngbin13251 -> 0 bytes
-rw-r--r--app/src/main/AndroidManifest.xml8
-rw-r--r--app/src/main/ic_launcher-playstore.pngbin20843 -> 0 bytes
-rw-r--r--app/src/main/java/app/passwordstore/Application.kt59
-rw-r--r--app/src/main/java/app/passwordstore/injection/AutofillResponseBuilderModule.kt11
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt11
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt12
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt12
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt6
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt23
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt13
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt10
-rw-r--r--app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt27
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/ListDestination.kt2
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/ListScreen.kt11
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/StoreActions.kt72
-rw-r--r--app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt4
-rw-r--r--app/src/main/java/app/passwordstore/ui/main/MainActivity.kt10
-rw-r--r--app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt293
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt231
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt4
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt6
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt13
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt9
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt15
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt17
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt30
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt4
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt4
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt2
-rw-r--r--app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt9
-rw-r--r--app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt173
-rw-r--r--app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt1
-rw-r--r--app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt2
-rw-r--r--app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt11
-rw-r--r--app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt11
-rw-r--r--app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt21
-rw-r--r--app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt23
-rw-r--r--app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt9
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt22
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt4
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt56
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt4
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt47
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt4
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt4
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt4
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt4
-rw-r--r--app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt21
-rw-r--r--app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt32
-rw-r--r--app/src/main/java/app/passwordstore/util/log/LogcatLogger.kt9
-rw-r--r--app/src/main/java/app/passwordstore/util/services/ClipboardService.kt7
-rw-r--r--app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt17
-rw-r--r--app/src/main/java/app/passwordstore/util/settings/Constants.kt2
-rw-r--r--app/src/main/java/app/passwordstore/util/settings/GitSettings.kt6
-rw-r--r--app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt1
-rw-r--r--app/src/main/res/drawable-nodpi/ic_kofi_symbol.webpbin0 -> 4100 bytes
-rw-r--r--app/src/main/res/drawable/ic_action_new_folder.xml2
-rw-r--r--app/src/main/res/drawable/ic_action_new_password.xml2
-rw-r--r--app/src/main/res/drawable/ic_action_secure_24dp.xml2
-rw-r--r--app/src/main/res/drawable/ic_delete_24dp.xml2
-rw-r--r--app/src/main/res/drawable/ic_edit_24dp.xml2
-rw-r--r--app/src/main/res/drawable/ic_launcher_background.xml2
-rw-r--r--app/src/main/res/drawable/ic_launcher_foreground.xml61
-rw-r--r--app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml20
-rw-r--r--app/src/main/res/drawable/ic_move_folder_24dp.xml2
-rw-r--r--app/src/main/res/drawable/ic_open_in_new_24dp.xml12
-rw-r--r--app/src/main/res/drawable/ic_push_pin_24dp.xml2
-rw-r--r--app/src/main/res/drawable/ic_round_import_export.xml2
-rw-r--r--app/src/main/res/drawable/ic_search_24dp.xml2
-rw-r--r--app/src/main/res/drawable/ic_share_24dp.xml2
-rw-r--r--app/src/main/res/layout/git_credential_layout.xml37
-rw-r--r--app/src/main/res/mipmap-hdpi/ic_launcher.pngbin4260 -> 0 bytes
-rw-r--r--app/src/main/res/mipmap-mdpi/ic_launcher.pngbin2670 -> 0 bytes
-rw-r--r--app/src/main/res/mipmap-xhdpi/ic_launcher.pngbin5999 -> 0 bytes
-rw-r--r--app/src/main/res/mipmap-xxhdpi/ic_launcher.pngbin9402 -> 0 bytes
-rw-r--r--app/src/main/res/mipmap-xxxhdpi/ic_launcher.pngbin13343 -> 0 bytes
-rw-r--r--app/src/main/res/values-de/strings.xml2
-rw-r--r--app/src/main/res/values-fr/strings.xml2
-rw-r--r--app/src/main/res/values-gl/strings.xml2
-rw-r--r--app/src/main/res/values-it/strings.xml25
-rw-r--r--app/src/main/res/values-ko-rKR/strings.xml2
-rw-r--r--app/src/main/res/values-night/themes_material3.xml29
-rw-r--r--app/src/main/res/values-pl-rPL/strings.xml2
-rw-r--r--app/src/main/res/values-pt-rBR/strings.xml2
-rw-r--r--app/src/main/res/values-ru/strings.xml2
-rw-r--r--app/src/main/res/values-v29/arrays.xml17
-rw-r--r--app/src/main/res/values-v29/prefs.xml8
-rw-r--r--app/src/main/res/values-zh-rCN/strings.xml2
-rw-r--r--app/src/main/res/values/arrays.xml4
-rw-r--r--app/src/main/res/values/colors_material3.xml54
-rw-r--r--app/src/main/res/values/prefs.xml2
-rw-r--r--app/src/main/res/values/strings.xml25
-rw-r--r--app/src/main/res/values/themes_material3.xml42
-rw-r--r--app/src/test/java/app/passwordstore/ui/prompts/PromptsTest.kt102
-rw-r--r--app/src/test/java/app/passwordstore/ui/settings/AboutTest.kt39
-rw-r--r--app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt17
-rw-r--r--app/src/test/java/app/passwordstore/util/viewmodel/StrictDomainRegexTest.kt2
-rw-r--r--app/version.properties4
-rw-r--r--autofill-parser/api.txt22
-rw-r--r--autofill-parser/build.gradle.kts5
-rw-r--r--autofill-parser/src/main/assets/publicsuffixesbin147935 -> 148065 bytes
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt3
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt64
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt11
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt3
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt8
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt20
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt13
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt11
-rw-r--r--autofill-parser/src/test/kotlin/com/github/androidpasswordstore/autofillparser/FormFieldLogTest.kt19
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/AndroidCommon.kt2
-rw-r--r--build-logic/src/main/kotlin/app/passwordstore/gradle/ApplicationPlugin.kt20
-rw-r--r--docs/superpowers/plans/2026-11-05-git-dialogs-compose.md905
-rw-r--r--docs/superpowers/specs/2026-11-05-git-dialogs-compose-design.md145
-rw-r--r--gradle/libs.versions.toml3
-rw-r--r--media/banner.pngbin0 -> 47050 bytes
-rw-r--r--media/kofi-button.pngbin0 -> 8935 bytes
-rw-r--r--media/play_store_banner.pngbin373934 -> 0 bytes
-rwxr-xr-xscripts/boot-emulator.sh6
-rwxr-xr-xscripts/setup-environment.sh8
-rw-r--r--settings.gradle.kts1
-rw-r--r--ui/compose/build.gradle.kts3
-rw-r--r--ui/compose/src/main/kotlin/app/passwordstore/ui/compose/theme/Theme.kt19
142 files changed, 2436 insertions, 1084 deletions
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 0954037d..76d59e7b 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -7,10 +7,10 @@ body:
attributes:
value: |
Thanks for taking the time to file this issue! Here are a few things to check before clicking the submit button :)
- 1. Make sure you're on the latest version of the app. The current release can be found here: https://github.com/android-password-store/android-password-store/releases/latest.
- 2. Search through **both** open and closed issues for your bug: https://github.com/android-password-store/Android-Password-Store/issues?q=is%3Aissue+sort%3Aupdated-desc+.
+ 1. Make sure you're on the latest version of the app. The current release can be found here: https://github.com/danixland/HardenedPass/releases/latest.
+ 2. Search through **both** open and closed issues for your bug: https://github.com/danixland/HardenedPass/issues?q=is%3Aissue+sort%3Aupdated-desc+.
3. ALWAYS fill this template. If you fail to do so, your issue will be immediately closed with no response. Maintaining open source projects for free is hard work, and we expect users to respect that time and effort by putting in a little bit of their own. That helps us fix these problems faster and in return you get a better app — everybody wins.
- 4. Please record a device log, and attach it to the issue. You can follow the steps here for it: https://docs.passwordstore.app/docs/users/reporting-bugs
+ 4. Please attach a log: turn on Settings > Misc > Debug logging, restart the app, reproduce the bug, then use Settings > Misc > Export log. Read the file first and remove anything you do not want public: it can show entry names, websites, apps and your git server.
- type: textarea
id: bug-description
@@ -58,7 +58,7 @@ body:
value: |
- Device: [e.g. Google Pixel 2]
- OS: [e.g. Android R]
- - App version: [e.g. 1.13.5]
+ - App version: [e.g. 0.3.2]
validations:
required: true
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 7e075dcc..f252cd29 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yaml
@@ -7,7 +7,7 @@ body:
attributes:
value: |
Thanks for taking the time to file this issue! Here are a few things to check before clicking the submit button :)
- 1. Make sure you're on the latest version of the app. Features are being added all the time and it is entirely possible what you're requesting has already been added. The current release can be found here: https://github.com/android-password-store/android-password-store/releases/latest.
+ 1. Make sure you're on the latest version of the app. Features are being added all the time and it is entirely possible what you're requesting has already been added. The current release can be found here: https://github.com/danixland/HardenedPass/releases/latest.
2. Check the changelog file to confirm that the feature hasn't been added for an upcoming release: https://github.com/danixland/HardenedPass/blob/master/CHANGELOG.md
3. ALWAYS fill this template. If you fail to do so, your issue will be immediately closed with no response. Maintaining open source projects for free is hard work, and we expect users to respect that time and effort by putting in a little bit of their own. A feature request that is clear and explicit in its needs lets us get to work faster.
diff --git a/.github/workflows/deploy_github_releases.yml b/.github/workflows/deploy_github_releases.yml
index f78687d1..4c0c7bd9 100644
--- a/.github/workflows/deploy_github_releases.yml
+++ b/.github/workflows/deploy_github_releases.yml
@@ -37,5 +37,8 @@ jobs:
apk="HardenedPass_${TAG}.apk"
cp app/build/outputs/apk/release/app-release.apk "$apk"
sha256sum "$apk" > "$apk.sha256"
- gh release create "$TAG" "$apk" "$apk.sha256" --draft --title "$TAG" \
- --notes "See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/${TAG}/CHANGELOG.md)."
+ # The release notes are this version's section of the changelog.
+ awk -v head="## [${TAG#v}]" 'index($0, head) == 1 { found = 1; next } /^## \[/ { found = 0 } found' \
+ CHANGELOG.md > notes.md
+ grep -q '[^[:space:]]' notes.md || { echo "No CHANGELOG.md section for $TAG"; exit 1; }
+ gh release create "$TAG" "$apk" "$apk.sha256" --draft --title "$TAG" --notes-file notes.md
diff --git a/AGENTS.md b/AGENTS.md
index 7497bf20..7c928fdc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,6 +25,9 @@ Debug"), so it installs next to upstream's `app.passwordstore` and never updates
Kotlin package stays `app.passwordstore`; renaming it is churn with no user-visible effect.
Keep "GrapheneOS" out of the app name: it is that project's trademark.
+minSdk is 31: GrapheneOS runs current Android only, and Android 12 has
+`UiModeManager.setApplicationNightMode`.
+
## Repository layout and remotes
- `master` is the only branch. Upstream's `develop` was renamed; do not recreate it.
@@ -32,7 +35,6 @@ Keep "GrapheneOS" out of the app name: it is that project's trademark.
and to the GitHub fork (`danixland/HardenedPass`) in one `git push`.
GitHub is a push mirror: never create commits on GitHub (bots, web edits), they would
diverge from the main repository.
-- `upstream` is fetch-only (push URL disabled).
- Work directly on `master`, no PRs.
## Build and test
@@ -80,15 +82,17 @@ names (`e2e_pass`, not `pass`, which is the real `pass` and reaches a real store
`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
+view, the editor and its generator sheet, the proxy screen, the passphrase dialogs, the git
+credential dialog 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:
+The `aps-test` AVD has lock PIN 1234: until it is entered at the keyguard, user storage stays
+locked. On the host, `ssh` to the e2e server needs `-F none -o IdentityAgent=none`, or
+`AddKeysToAgent` makes gpg-agent ask for a passphrase for the throwaway key. 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_`)
or `uiautomator dump` waits for an idle state that never comes. The OTP screen never goes idle
@@ -119,11 +123,17 @@ instead:
(Italian: "Cerca la voce" is the app's "Search entry" chip).
- Only read screens of the debug app, the test page and the autofill UI; the device holds the
owner's real data.
-
-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.
+- On the Pixel 6 Pro (GrapheneOS, Android 17) `uiautomator dump` fails on the debug app and on
+ Firefox with `null root node`, so drive by `screencap` and tap coordinates; secure windows need
+ the owner. The system file picker can show real files: the owner picks the keys there.
+
+Last full emulator run (2026-11-05, Android 15, minSdk 31 build): clone over SSH with a
+passphrase key (wrong, cancel, remembered, kept through a recreation), missing SSH key dialog, git
+error dialog, snackbars, conflict recovery (abort rebase, push a new branch), overwrite confirmation
+on move, light, dark and system themes with bar colors, QR scanner opens, decrypt, TOTP and
+fingerprint unlock passed. Last Firefox autofill run (2026-11-05): Pixel 6 Pro on Android 17 with the
+signed minSdk 31 release build (fill, save, sync, abort rebase, hard reset), all passed. The tablet
+(fill, save, stale cached passphrase) was last run before that build.
## Verified facts and gotchas
@@ -141,13 +151,13 @@ cache rule, no keys flow, picker, settings dialog), all passed.
- `LaunchActivity` is exported and forwards `FILE_PATH` to `MainActivity` (not exported) only
after `isFileInRepo` checks it canonically resolves inside the store; `MainActivity` opens it as
`EntryRoute`, whose destination checks it again before decrypting.
-- Material Components (Views) stays after the Compose overhaul: the window themes derive from
- `Theme.Material3.DayNight` (the AppCompat activities need it), and the git operations' View
- dialogs (`BaseGitActivity`, `GitOperation`, `CredentialFinder` with
- `git_credential_layout.xml`, `BreakOutOfDetached`, `StoreActions`), `DynamicColors` and the
- `snackbar` helper use it. `oreo_autofill_dataset.xml` stays a layout: fill suggestions are
- `RemoteViews` drawn by the system. `fragment-ktx` is declared only to pin `androidx.fragment`
- (appcompat alone resolves an older one).
+- Window themes derive from the platform `android:Theme.Material.*.NoActionBar`, activities from
+ `FragmentActivity` (`BiometricPrompt` needs it). Git operations and store actions show dialogs and
+ snackbars through the activity-scoped `Prompts` ViewModel (`activity.prompts`), drawn by
+ `PromptHost`, which only `MainActivity` and `AutofillSaveActivity` host. AppCompat stays in the
+ APK through zxing (`CaptureManager`, `zxing_CaptureTheme`) and `androidx.biometric`;
+ `oreo_autofill_dataset.xml` stays a layout: fill suggestions are `RemoteViews` drawn by the
+ system.
- 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
@@ -161,10 +171,18 @@ cache rule, no keys flow, picker, settings dialog), all passed.
AES-GCM encrypted under Android Keystore keys by `util/crypto/KeystorePreferences.kt`.
androidx security-crypto was removed; `removeSecurityCryptoData` in `Migrations.kt` deleted
its data once on upgrade from 0.1.x.
+- Autofill save on Android 14+: the framework sends the save `IntentSender` from the app being
+ filled, which is not opted in to background activity starts, so `AutofillSaveActivity` opens
+ hidden behind it (`Background activity launch blocked` in logcat). `FixedSaveCallback` starts it
+ from the service instead, which the system has bound. Firefox's own save prompt shows as well.
- JGit is on 5.13.x, the last line that needs only Java 8 APIs. JGit 6+ needs Java 11 APIs
Android only partly desugars; moving to it is a large, separate job. Window cache JMX is off
in `Application` and GC runs without JGit's PID lock, because Android has no JMX.
+- The about screen's `LICENSES` list (`AboutScreen.kt`) is kept by hand: update it when a
+ dependency that ships in the APK comes or goes. Its Ko-fi logo must stay unchanged, per Ko-fi's
+ guidance, on the white button that keeps its dark outline visible.
+
## Open work
`TODO.md` lists open features and fixes. Remove an item in the commit that finishes it.
@@ -186,7 +204,7 @@ manual because bot commits on the GitHub mirror would diverge from the main repo
./gradlew :autofill-parser:test -PslimTests
git commit -am "autofill-parser: update public suffix list" && git push
-Last run: 2026-11-04.
+Last run: 2026-11-06.
## Releases
@@ -201,5 +219,8 @@ be updated again.
`openssl enc -aes-256-cbc -md sha256 -pbkdf2`; the key is the `ENCRYPT_KEY` repository
secret. `scripts/signing-setup.sh` decrypts them, `signing-cleanup.sh` deletes them.
- Pushing a `vX.Y.Z` tag runs `deploy_github_releases.yml`: it builds the signed APK and creates
- a draft GitHub Release with `HardenedPass_vX.Y.Z.apk` and its `.sha256`.
+ a draft GitHub Release with `HardenedPass_vX.Y.Z.apk` and its `.sha256`. The notes are that
+ version's `CHANGELOG.md` section in full, so the release fails without one.
+- After publishing, rebuild the website with a push to its `production` branch: its landing page
+ reads the latest version from GitHub at build time.
- Never print, log or commit the keystore password or `ENCRYPT_KEY`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f869451c..7d9511c2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,55 @@
All notable changes to this project will be documented in this file.
+## [0.3.3] - 2026-11-06
+
+### Added
+
+- A "Push after each change" setting, off by default, pushes after saving, moving or deleting in the app; autofill saves stay local until the next push or sync
+- Settings, Misc: "Export log" saves the app's logs to a file you choose, to attach to a bug report
+
+### Changed
+
+- Debug logs no longer show entry names from commits and autofill matches, or form fields' HTML attributes other than id, name, type, autocomplete and maxlength
+
+### Fixed
+
+- Release builds made by CI no longer run with debug logging and the strict VM policy on. In 0.3.2 and earlier, debug lines went to the system log whatever the Debug logging setting said, including entry names and autofill sites. Other apps cannot read them: that takes adb or system access
+
+## [0.3.2] - 2026-11-05
+
+### Changed
+
+- About moved from Settings to the list's menu, and its links show that they open in the browser
+
+### Fixed
+
+- The version, signing certificate and SSH public key are no longer hidden in the clipboard preview
+
+## [0.3.1] - 2026-11-05
+
+### Added
+
+- An about screen in Settings: version, documentation and project links, Ko-fi, the privacy statement, the signing certificate of the installed copy and the licenses
+
+## [0.3.0] - 2026-11-05
+
+### Changed
+
+- Android 12 or later is now required; older devices stay on 0.2.0
+- New HardenedPass launcher icon
+- The git dialogs and snackbars are now Compose; Material Components are gone
+- The dialog asking for a git password or SSH passphrase is hidden from screenshots and recents
+- A snackbar confirms a successful git operation
+- Updated the public suffix list
+
+### Fixed
+
+- The clipboard is cleared after the 45 seconds Settings shows, not 60
+- Autofill save works with Firefox release and beta, and its window opens on top on Android 14+
+- Autofill asks again when a cached passphrase fails
+- An invalid `.gpg-id` shows as a snackbar in the entry view and the editor
+
## [0.2.0] - 2026-11-04
### Changed
diff --git a/README.md b/README.md
index e38ecf5f..172ab830 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# HardenedPass
+![HardenedPass](media/banner.png)
+
An Android client for [pass](https://www.passwordstore.org/), the standard Unix password manager.
Passwords are stored as OpenPGP-encrypted files in a git repository, so the same store works with
`pass` on the desktop and with this app on the phone. HardenedPass aims to work flawlessly on
@@ -10,6 +12,20 @@ This is a maintained fork of
which was archived in October 2024. All credit for the original work goes to the
Android Password Store authors.
+## Documentation
+
+How to install, set up and use the app: <https://danix.xyz/hardenedpass/>.
+
+New to `pass`? "Your own password store" is a beginner tutorial series, also available in
+[Italian](https://danix.xyz/it/articles/your-own-password-store-part-1/):
+
+1. [Create the store](https://danix.xyz/articles/your-own-password-store-part-1/) with `pass`,
+ GPG and git.
+2. [Choose a git host](https://danix.xyz/articles/your-own-password-store-part-2/), or do it all
+ from the phone with Termux.
+3. [Install HardenedPass](https://danix.xyz/articles/your-own-password-store-part-3/), connect it
+ to the store, and cover daily use and recovery.
+
## Changes from upstream
- Decrypts messages GnuPG 2.3+ writes by default (OCB / LibrePGP AEAD), which upstream rejected
@@ -19,6 +35,7 @@ Android Password Store authors.
- Decrypt screen no longer reachable by other apps with arbitrary file paths.
- No build scans uploaded to third-party services.
- No crash reporting (Sentry) and no Google Play Services; a single FOSS build.
+- Needs Android 12 or later.
- Own application ID (`xyz.danix.hardenedpass`): installs next to upstream instead of over it.
## Building
@@ -39,6 +56,13 @@ Services.
Report bugs and feature requests on the
[issue tracker](https://github.com/danixland/HardenedPass/issues).
+## Support
+
+HardenedPass will always be free, but maintaining a software can be costly, so if you found this piece of software useful, you can buy me a coffee.
+All donations will help me maintain HardenedPass in the future.
+
+[![Support on Ko-fi](media/kofi-button.png)](https://ko-fi.com/K6Y520C9I6)
+
## License
GPL-3.0-only, see [LICENSE.txt](LICENSE.txt).
diff --git a/TODO.md b/TODO.md
index 132aef17..dc3bdde9 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,17 +2,20 @@
Open work after 0.2.0. Remove an item in the commit that finishes it.
-## Features and UI
+## Bugs
-- Icon overhaul: the launcher icon is still Password Store's. Design a HardenedPass icon
- (adaptive icon, monochrome layer for themed icons) and replace it everywhere it appears.
-- Git dialogs to Compose: the git operations' View dialogs (`BaseGitActivity`, `GitOperation`,
- `CredentialFinder`, `BreakOutOfDetached`, `StoreActions`) and the `snackbar` helper still use
- Material Components (Views). Moving them lets that dependency go.
+- Pull fails on a store made with Create Local Repo once a remote is added in Edit Git server
+ settings: "Could not read an object while parsing commit <id>". Push works, and a cloned store
+ pulls fine (0.3.3, emulator, SSH). Likely cause: `PasswordRepository.addRemote` writes the refspec
+ `+refs/head/*:refs/remotes/$name/*`, with `head` where git uses `heads`, so nothing is fetched.
+ Stores already set up this way keep the bad refspec in `.git/config`, so a fix must repair them
+ too. Not confirmed by reading the store's config.
-## Fixes
+## Features and UI
-- Firefox release (`org.mozilla.firefox`) is not in the autofill save list, so saving from it
- does not work (fill and generate do). First check that it sends save requests.
-- A stale cached passphrase (the key's passphrase changed) makes every autofill fail until the
- cache is cleared; the entry view asks for the passphrase instead. Do the same in autofill.
+- Push after autofill saves: the "Push after each change" setting skips them, because
+ `AutofillSaveActivity` closes right after saving and would cancel the push.
+- AppCompat out of the APK: `zxing-android-embedded` (`CaptureManager`, `zxing_CaptureTheme`) needs
+ it at runtime, so the QR scanner must be replaced. That alone is not enough: `androidx.biometric`
+ depends on it too, for a fingerprint dialog unused at minSdk 31, so it must be excluded or
+ `BiometricPrompt` used from the platform.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index cad899f9..81414510 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -57,20 +57,18 @@ dependencies {
implementation(projects.ui.compose)
implementation(libs.androidx.activity)
implementation(libs.androidx.activity.compose)
- implementation(libs.androidx.appcompat)
implementation(libs.androidx.autofill)
implementation(libs.androidx.biometricKtx)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.documentfile)
- // Pins androidx.fragment (FragmentActivity, BiometricPrompt): appcompat alone resolves 1.5.4.
+ // FragmentActivity, which BiometricPrompt needs.
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.navigation.compose)
- implementation(libs.compose.ui.tooling)
+ debugImplementation(libs.compose.ui.tooling)
implementation(libs.dagger.hilt.android)
implementation(libs.kotlinx.collections.immutable)
diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml
index f77d22dd..6dba677d 100644
--- a/app/lint-baseline.xml
+++ b/app/lint-baseline.xml
@@ -85,20 +85,6 @@
</issue>
<issue
- id="ConvertToWebp"
- message="One or more images in this project can be converted to the WebP format which typically results in smaller file sizes, even for lossless conversion">
- <location
- file="src/debug/res/mipmap-xxxhdpi/ic_launcher.png"/>
- </issue>
-
- <issue
- id="ConvertToWebp"
- message="One or more images in this project can be converted to the WebP format which typically results in smaller file sizes, even for lossless conversion">
- <location
- file="src/main/res/mipmap-xxxhdpi/ic_launcher.png"/>
- </issue>
-
- <issue
id="SelectableText"
message="Consider making the text value selectable by specifying `android:textIsSelectable=&quot;true&quot;`"
errorLine1=" &lt;TextView"
diff --git a/app/lint.xml b/app/lint.xml
index 44b84e59..42a888eb 100644
--- a/app/lint.xml
+++ b/app/lint.xml
@@ -18,7 +18,6 @@
</issue>
<issue id="UnusedResources">
<ignore path="src/main/res/values/bools.xml" />
- <ignore path="src/main/res/values/colors_material3.xml" />
</issue>
<issue id="VectorPath">
<ignore path="src/main/res/drawable/app_settings_alt_24px.xml" />
diff --git a/app/src/debug/ic_launcher-playstore.png b/app/src/debug/ic_launcher-playstore.png
deleted file mode 100644
index d93ce3cf..00000000
--- a/app/src/debug/ic_launcher-playstore.png
+++ /dev/null
Binary files differ
diff --git a/app/src/debug/res/drawable/ic_launcher_background.xml b/app/src/debug/res/drawable/ic_launcher_background.xml
index f64ed7e3..d6bd4668 100644
--- a/app/src/debug/res/drawable/ic_launcher_background.xml
+++ b/app/src/debug/res/drawable/ic_launcher_background.xml
@@ -9,6 +9,6 @@
android:viewportWidth="432"
android:viewportHeight="432">
<path
- android:fillColor="#5E0033"
+ android:fillColor="#4B2E83"
android:pathData="M0,0h432v432h-432z" />
</vector>
diff --git a/app/src/debug/res/drawable/ic_launcher_foreground.xml b/app/src/debug/res/drawable/ic_launcher_foreground.xml
deleted file mode 100644
index 2cdef53f..00000000
--- a/app/src/debug/res/drawable/ic_launcher_foreground.xml
+++ /dev/null
@@ -1,63 +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
- -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:aapt="http://schemas.android.com/aapt"
- android:width="108dp"
- android:height="108dp"
- android:viewportWidth="432"
- android:viewportHeight="432">
- <path android:pathData="M128.18,195.79C126.46,181.89 137.12,169.6 150.91,169.6H281.09C294.91,169.6 305.59,181.95 303.8,195.88L292.58,283.72C291.09,295.32 281.37,304 269.86,304H161.8C150.26,304 140.51,295.27 139.07,283.63L128.18,195.79Z">
- <aapt:attr name="android:fillColor">
- <gradient
- android:endX="216"
- android:endY="304"
- android:startX="216"
- android:startY="169.6"
- android:type="linear">
- <item
- android:color="#FFFFA800"
- android:offset="0" />
- <item
- android:color="#FFFF6341"
- android:offset="1" />
- </gradient>
- </aapt:attr>
- </path>
- <path android:pathData="M245.33,169.6V160C245.33,144.09 232.2,131.2 216,131.2C199.8,131.2 186.67,144.09 186.67,160V169.6H167.11V160C167.11,133.49 189,112 216,112C243,112 264.89,133.49 264.89,160V169.6H245.33Z">
- <aapt:attr name="android:fillColor">
- <gradient
- android:centerX="216"
- android:centerY="169.6"
- android:gradientRadius="33.6"
- android:type="radial">
- <item
- android:color="#FFD1004B"
- android:offset="0.24" />
- <item
- android:color="#FFFFD749"
- android:offset="0.75" />
- </gradient>
- </aapt:attr>
- </path>
- <path
- android:fillColor="#FFD749"
- android:pathData="M245.33,169.6h19.56v38.4h-19.56z" />
- <path
- android:fillColor="#FFD749"
- android:pathData="M245.33,217.6h19.56v48h-19.56z" />
- <path
- android:fillColor="#FFD749"
- android:pathData="M239.11,205.19l13.83,13.58l-34.57,33.94l-13.83,-13.58z" />
- <path
- android:fillColor="#FFFFF0"
- android:pathData="M235.56,208a19.56,19.2 0,1 0,39.11 0a19.56,19.2 0,1 0,-39.11 0z" />
- <path
- android:fillColor="#FFFFF0"
- android:pathData="M235.56,275.2a19.56,19.2 0,1 0,39.11 0a19.56,19.2 0,1 0,-39.11 0z" />
- <path
- android:fillColor="#FFFFF0"
- android:pathData="M204.54,252.71m-13.83,-13.58a19.56,19.2 0,1 1,27.66 27.15a19.56,19.2 0,1 1,-27.66 -27.15" />
-</vector>
diff --git a/app/src/debug/res/mipmap-anydpi/ic_launcher.xml b/app/src/debug/res/mipmap-anydpi/ic_launcher.xml
deleted file mode 100644
index 22bd3428..00000000
--- a/app/src/debug/res/mipmap-anydpi/ic_launcher.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
- -->
-
-<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
- <background android:drawable="@drawable/ic_launcher_background" />
- <foreground android:drawable="@drawable/ic_launcher_foreground" />
- <monochrome android:drawable="@drawable/ic_launcher_foreground_monochrome" />
-</adaptive-icon>
diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher.png b/app/src/debug/res/mipmap-hdpi/ic_launcher.png
deleted file mode 100644
index a3b28da5..00000000
--- a/app/src/debug/res/mipmap-hdpi/ic_launcher.png
+++ /dev/null
Binary files differ
diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher.png b/app/src/debug/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100644
index 0be8ba3c..00000000
--- a/app/src/debug/res/mipmap-mdpi/ic_launcher.png
+++ /dev/null
Binary files differ
diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100644
index 02a4af49..00000000
--- a/app/src/debug/res/mipmap-xhdpi/ic_launcher.png
+++ /dev/null
Binary files differ
diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100644
index 9d32c97c..00000000
--- a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png
+++ /dev/null
Binary files differ
diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100644
index d7d4ef18..00000000
--- a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png
+++ /dev/null
Binary files differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 15d6447e..0eabbab4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -30,14 +30,12 @@
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppThemeM3"
- tools:ignore="GoogleAppIndexingWarning"
- tools:targetApi="s">
+ tools:ignore="GoogleAppIndexingWarning">
<activity
android:name=".ui.main.LaunchActivity"
android:configChanges="orientation|screenSize"
- android:exported="true"
- android:theme="@style/NoActionBarThemeM3">
+ android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -49,7 +47,6 @@
android:name=".ui.main.MainActivity"
android:configChanges="orientation|screenSize"
android:exported="false"
- android:theme="@style/NoActionBarThemeM3"
android:windowSoftInputMode="adjustResize" />
<activity
@@ -101,7 +98,6 @@
android:name=".ui.autofill.AutofillSaveActivity"
android:configChanges="orientation|screenSize"
android:exported="false"
- android:theme="@style/NoActionBarThemeM3"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.autofill.AutofillPublisherChangedActivity"
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
deleted file mode 100644
index 8de23a46..00000000
--- a/app/src/main/ic_launcher-playstore.png
+++ /dev/null
Binary files differ
diff --git a/app/src/main/java/app/passwordstore/Application.kt b/app/src/main/java/app/passwordstore/Application.kt
index bda61265..dbeb4ffb 100644
--- a/app/src/main/java/app/passwordstore/Application.kt
+++ b/app/src/main/java/app/passwordstore/Application.kt
@@ -4,14 +4,13 @@
*/
package app.passwordstore
+import android.app.UiModeManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
-import android.os.Build
import android.os.StrictMode
-import androidx.appcompat.app.AppCompatDelegate.*
import app.passwordstore.data.crypto.PGPPassphraseCache
import app.passwordstore.injection.context.FilesDirPath
import app.passwordstore.injection.prefs.SettingsPreferences
@@ -24,7 +23,6 @@ import app.passwordstore.util.settings.GitSettings
import app.passwordstore.util.settings.PreferenceKeys
import app.passwordstore.util.settings.removeSecurityCryptoData
import app.passwordstore.util.settings.runMigrations
-import com.google.android.material.color.DynamicColors
import dagger.hilt.android.HiltAndroidApp
import java.util.concurrent.Executors
import javax.inject.Inject
@@ -65,7 +63,6 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere
removeSecurityCryptoData(this, prefs)
runMigrations(filesDirPath, prefs, gitSettings)
proxyUtils.setDefaultProxy()
- DynamicColors.applyToActivitiesIfAvailable(this)
setupScreenOffHandler()
}
@@ -92,49 +89,35 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere
}
private fun setVmPolicy() {
- val builder =
+ StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectActivityLeaks()
.detectFileUriExposure()
.detectLeakedClosableObjects()
.detectLeakedRegistrationObjects()
.detectLeakedSqlLiteObjects()
-
- builder.detectContentUriWithoutPermission()
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- builder.detectCredentialProtectedWhileLocked().detectImplicitDirectBoot()
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- builder.detectNonSdkApiUsage()
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- builder.detectIncorrectContextUse().detectUnsafeIntentLaunch()
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- builder.penaltyListener(Executors.newSingleThreadExecutor()) { violation ->
- logcat(VERBOSE) { violation.stackTraceToString() }
- }
- } else {
- builder.penaltyLog()
- }
-
- val policy = builder.build()
- StrictMode.setVmPolicy(policy)
+ .detectContentUriWithoutPermission()
+ .detectCredentialProtectedWhileLocked()
+ .detectImplicitDirectBoot()
+ .detectNonSdkApiUsage()
+ .detectIncorrectContextUse()
+ .detectUnsafeIntentLaunch()
+ .penaltyListener(Executors.newSingleThreadExecutor()) { violation ->
+ logcat(VERBOSE) { violation.stackTraceToString() }
+ }
+ .build()
+ )
}
private fun setNightMode() {
- setDefaultNightMode(
- when (prefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) {
- "light" -> MODE_NIGHT_NO
- "dark" -> MODE_NIGHT_YES
- "follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM
- else -> MODE_NIGHT_AUTO_BATTERY
- }
- )
+ getSystemService(UiModeManager::class.java)
+ .setApplicationNightMode(
+ when (prefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) {
+ "light" -> UiModeManager.MODE_NIGHT_NO
+ "dark" -> UiModeManager.MODE_NIGHT_YES
+ else -> UiModeManager.MODE_NIGHT_AUTO
+ }
+ )
}
companion object {
diff --git a/app/src/main/java/app/passwordstore/injection/AutofillResponseBuilderModule.kt b/app/src/main/java/app/passwordstore/injection/AutofillResponseBuilderModule.kt
index 46e61397..1717e4cc 100644
--- a/app/src/main/java/app/passwordstore/injection/AutofillResponseBuilderModule.kt
+++ b/app/src/main/java/app/passwordstore/injection/AutofillResponseBuilderModule.kt
@@ -5,8 +5,6 @@
package app.passwordstore.injection
-import android.os.Build
-import app.passwordstore.util.autofill.Api26AutofillResponseBuilder
import app.passwordstore.util.autofill.Api30AutofillResponseBuilder
import app.passwordstore.util.autofill.AutofillResponseBuilder
import dagger.Module
@@ -21,11 +19,6 @@ object AutofillResponseBuilderModule {
@Provides
@Reusable
- fun provideAutofillResponseBuilder(): AutofillResponseBuilder.Factory {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- Api30AutofillResponseBuilder.Factory
- } else {
- Api26AutofillResponseBuilder.Factory
- }
- }
+ fun provideAutofillResponseBuilder(): AutofillResponseBuilder.Factory =
+ Api30AutofillResponseBuilder.Factory
}
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 7ba4df74..3b62de4c 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt
@@ -9,14 +9,12 @@ 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 android.widget.Toast
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
@@ -25,6 +23,7 @@ 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.FragmentActivity
import androidx.lifecycle.lifecycleScope
import app.passwordstore.Application.Companion.screenWasOff
import app.passwordstore.R
@@ -71,7 +70,7 @@ import logcat.logcat
* FLAG_SECURE window shows only the "no keys" and passphrase dialogs.
*/
@AndroidEntryPoint
-class AutofillDecryptActivity : AppCompatActivity() {
+class AutofillDecryptActivity : FragmentActivity() {
@SettingsPreferences @Inject lateinit var settings: SharedPreferences
@Inject lateinit var repository: CryptoRepository
@@ -368,11 +367,7 @@ class AutofillDecryptActivity : AppCompatActivity() {
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
- },
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE,
)
.intentSender
}
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 c607b81f..474005b5 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt
@@ -8,19 +8,18 @@ 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.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.passwordstore.R
import app.passwordstore.data.repo.PasswordRepository
@@ -44,7 +43,7 @@ import logcat.LogPriority.ERROR
import logcat.logcat
@AndroidEntryPoint
-class AutofillFilterView : AppCompatActivity() {
+class AutofillFilterView : FragmentActivity() {
@Inject lateinit var dispatcherProvider: DispatcherProvider
@@ -74,11 +73,7 @@ class AutofillFilterView : AppCompatActivity() {
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
- },
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE,
)
.intentSender
}
@@ -133,7 +128,6 @@ class AutofillFilterView : AppCompatActivity() {
return
}
}
- supportActionBar?.hide()
setResult(RESULT_CANCELED)
val isWeb = formOrigin is FormOrigin.Web
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 803896a5..74637ca1 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt
@@ -9,14 +9,13 @@ 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 androidx.fragment.app.FragmentActivity
import app.passwordstore.R
import app.passwordstore.ui.compose.theme.APSTheme
import app.passwordstore.util.autofill.AutofillMatcher
@@ -31,7 +30,7 @@ import com.github.michaelbull.result.runCatching
import logcat.LogPriority.ERROR
import logcat.logcat
-class AutofillPublisherChangedActivity : AppCompatActivity() {
+class AutofillPublisherChangedActivity : FragmentActivity() {
companion object {
@@ -54,11 +53,7 @@ class AutofillPublisherChangedActivity : AppCompatActivity() {
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
- },
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE,
)
.intentSender
}
@@ -79,7 +74,6 @@ class AutofillPublisherChangedActivity : AppCompatActivity() {
finish()
return
}
- supportActionBar?.hide()
val (packageInfo, appInfo, hash) =
runCatching {
Triple(
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 eb533411..2ed0adef 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt
@@ -12,14 +12,15 @@ 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 androidx.fragment.app.FragmentActivity
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.ui.prompts.PromptHost
import app.passwordstore.util.autofill.AutofillMatcher
import app.passwordstore.util.autofill.AutofillPreferences
import app.passwordstore.util.autofill.AutofillResponseBuilder
@@ -38,7 +39,7 @@ import logcat.logcat
* password, fills it in.
*/
@AndroidEntryPoint
-class AutofillSaveActivity : AppCompatActivity() {
+class AutofillSaveActivity : FragmentActivity() {
companion object {
@@ -139,6 +140,7 @@ class AutofillSaveActivity : AppCompatActivity() {
onSaved = ::finishSaved,
generate = generate,
)
+ PromptHost(this@AutofillSaveActivity)
}
}
}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt
index 67343a2e..626ec130 100644
--- a/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt
+++ b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt
@@ -7,9 +7,7 @@ package app.passwordstore.ui.crypto
import android.content.ClipData
import android.content.Intent
-import android.os.Build
import android.os.PersistableBundle
-import androidx.annotation.StringRes
import androidx.fragment.app.FragmentActivity
import app.passwordstore.R
import app.passwordstore.crypto.PGPIdentifier
@@ -17,7 +15,6 @@ 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
@@ -51,23 +48,15 @@ fun getLongName(fullPath: String, repositoryPath: String, basename: String): Str
}
}
-/**
- * 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,
-) {
+/** Copies provided [text] to the clipboard; [sensitive] text is hidden from its preview. */
+fun FragmentActivity.copyTextToClipboard(text: String?, sensitive: Boolean = true) {
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))
+ if (sensitive) {
+ clip.description.extras =
+ PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) }
}
+ clipboard.setPrimaryClip(clip)
}
/**
diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt
index b5a961d3..617d804a 100644
--- a/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt
+++ b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt
@@ -13,6 +13,7 @@ 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.SnackbarDuration
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -34,14 +35,15 @@ 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.git.base.BaseGitActivity
import app.passwordstore.ui.list.FolderPicker
+import app.passwordstore.ui.list.commitAndPush
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
@@ -142,18 +144,21 @@ fun EditorDestination(
R.string.password_creation_file_delete_fail_message,
it.nameWithoutExtension,
),
- length = Snackbar.LENGTH_LONG,
+ length = SnackbarDuration.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(
+ val message =
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"),
)
- )
+ // ponytail: AutofillSaveActivity closes right after saving, which would cancel a push, so its
+ // saves stay local until the next push or sync.
+ if (activity is BaseGitActivity) activity.commitAndPush(message)
+ else activity.commitChange(message)
keyboard?.hide()
focusManager.clearFocus()
viewModel.left()
diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt
index 6d2fbaec..fe5ee772 100644
--- a/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt
+++ b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt
@@ -10,8 +10,6 @@ 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
@@ -168,12 +166,8 @@ constructor(
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)
- }
+ ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri))
+ .copy(Bitmap.Config.ARGB_8888, false)
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)
diff --git a/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt b/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt
index 32872761..f7181c4e 100644
--- a/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt
@@ -5,10 +5,11 @@
package app.passwordstore.ui.git.base
import android.content.SharedPreferences
-import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.edit
+import androidx.fragment.app.FragmentActivity
import app.passwordstore.R
import app.passwordstore.injection.prefs.GitPreferences
+import app.passwordstore.ui.prompts.prompts
import app.passwordstore.util.coroutines.DispatcherProvider
import app.passwordstore.util.extensions.sharedPrefs
import app.passwordstore.util.git.ErrorMessages
@@ -25,10 +26,8 @@ import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.andThen
import com.github.michaelbull.result.mapError
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
-import kotlinx.coroutines.withContext
import logcat.asLog
import logcat.logcat
import net.schmizz.sshj.common.DisconnectReason
@@ -37,11 +36,11 @@ import net.schmizz.sshj.transport.TransportException
import net.schmizz.sshj.userauth.UserAuthException
/**
- * Abstract [AppCompatActivity] that holds some information that is commonly shared across
+ * Abstract [FragmentActivity] that holds some information that is commonly shared across
* git-related tasks and makes sense to be held here.
*/
@AndroidEntryPoint
-abstract class BaseGitActivity : AppCompatActivity() {
+abstract class BaseGitActivity : FragmentActivity() {
/** Enum of possible Git operations than can be run through [launchGitOperation]. */
enum class GitOp {
@@ -100,23 +99,17 @@ abstract class BaseGitActivity : AppCompatActivity() {
finish()
}
- suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) {
+ suspend fun promptOnErrorHandler(err: Throwable) {
val error = rootCauseException(err)
if (!isExplicitlyUserInitiatedError(error)) {
gitPrefs.edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
logcat { error.asLog() }
- withContext(dispatcherProvider.main()) {
- MaterialAlertDialogBuilder(this@BaseGitActivity).run {
- setTitle(resources.getString(R.string.jgit_error_dialog_title))
- setMessage(ErrorMessages[error])
- setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
- setOnDismissListener { onPromptDone() }
- show()
- }
- }
- } else {
- onPromptDone()
+ prompts.ask(
+ title = getString(R.string.jgit_error_dialog_title),
+ message = ErrorMessages[error],
+ confirm = getString(R.string.dialog_ok),
+ )
}
}
diff --git a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
index 54a7e66c..97092716 100644
--- a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
+++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
@@ -73,6 +73,7 @@ fun ListDestination(
onNewEntry: () -> Unit,
onClone: () -> Unit,
onSettings: () -> Unit,
+ onAbout: () -> Unit,
onNoStore: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SearchableRepositoryViewModel = hiltViewModel(),
@@ -159,6 +160,7 @@ fun ListDestination(
},
onGitOp = { op -> ifInitialized { runGit(op) } },
onSettings = onSettings,
+ onAbout = onAbout,
onNew = { dialog = ListDialog.Create },
onMove = { dialog = ListDialog.Move },
onDelete = {
diff --git a/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt
index 1b359d5b..593c744b 100644
--- a/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt
+++ b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt
@@ -91,6 +91,7 @@ fun ListScreen(
onSync: () -> Unit,
onGitOp: (GitOp) -> Unit,
onSettings: () -> Unit,
+ onAbout: () -> Unit,
onNew: () -> Unit,
onMove: () -> Unit,
onDelete: () -> Unit,
@@ -133,7 +134,7 @@ fun ListScreen(
SelectionBar(rows, selected, onClearSelection, onMove, onDelete, onRename, onPin)
} else {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
- SearchField(query, gitOps, onQueryChange, onGitOp, onSettings, searchFocus)
+ SearchField(query, gitOps, onQueryChange, onGitOp, onSettings, onAbout, searchFocus)
BreadcrumbBar(
crumbsFor(path),
stringResource(R.string.list_store_root),
@@ -238,6 +239,7 @@ private fun SearchField(
onQueryChange: (String) -> Unit,
onGitOp: (GitOp) -> Unit,
onSettings: () -> Unit,
+ onAbout: () -> Unit,
focus: FocusRequester,
) {
val focusManager = LocalFocusManager.current
@@ -282,6 +284,13 @@ private fun SearchField(
onSettings()
},
)
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.about)) },
+ onClick = {
+ menu = false
+ onAbout()
+ },
+ )
}
}
}
diff --git a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt
index 3bdede73..f300a814 100644
--- a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt
+++ b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt
@@ -6,11 +6,15 @@
package app.passwordstore.ui.list
import android.content.Context
+import androidx.compose.material3.SnackbarDuration
import androidx.core.content.edit
+import androidx.lifecycle.lifecycleScope
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.ui.prompts.Choice
+import app.passwordstore.ui.prompts.prompts
import app.passwordstore.util.autofill.AutofillMatcher
import app.passwordstore.util.extensions.base64
import app.passwordstore.util.extensions.commitChange
@@ -18,15 +22,17 @@ 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.extensions.snackbar
import app.passwordstore.util.settings.PasswordSortOrder
import app.passwordstore.util.settings.PreferenceKeys
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.onSuccess
import java.io.File
import java.nio.file.Files
-import kotlin.coroutines.resume
-import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority.ERROR
+import logcat.asLog
import logcat.logcat
/** Shared preferences file of the "recently used" sort order, keyed by base64 of the path. */
@@ -65,6 +71,27 @@ internal fun File.deleteWithoutFollowingLinks(): Boolean {
return delete()
}
+/**
+ * Commits the store and, when the user asked for it, pushes without holding up the caller. A failed
+ * push only says so: the commit stays local, and [BaseGitActivity.promptOnErrorHandler] would
+ * delete the saved credentials over what may just be a missing network.
+ *
+ * ponytail: a push cut off by recreation is not resumed; the next one sends everything. An SSH key
+ * behind biometrics prompts on each change.
+ */
+suspend fun BaseGitActivity.commitAndPush(message: String) {
+ commitChange(message).onSuccess {
+ if (gitSettings.pushAfterCommit && gitSettings.url != null) {
+ lifecycleScope.launch {
+ launchGitOperation(BaseGitActivity.GitOp.PUSH).onFailure {
+ logcat(ERROR) { it.asLog() }
+ snackbar(getString(R.string.git_push_after_commit_failed), SnackbarDuration.Long)
+ }
+ }
+ }
+ }
+}
+
/** Deletes [files] (entries or whole folders), drops their autofill matches and commits. */
suspend fun BaseGitActivity.deleteItems(files: List<File>) {
val root = PasswordRepository.getRepositoryDirectory()
@@ -73,7 +100,7 @@ suspend fun BaseGitActivity.deleteItems(files: List<File>) {
files.forEach { it.deleteWithoutFollowingLinks() }
AutofillMatcher.updateMatches(applicationContext, delete = deleted)
}
- commitChange(
+ commitAndPush(
getString(
R.string.git_commit_remove_text,
files.joinToString(", ") { it.toRelativeString(root) },
@@ -117,14 +144,14 @@ suspend fun BaseGitActivity.moveItems(files: List<File>, target: File) {
val relative = "${target.absolutePath}/".replace(repo, "").replace("/+".toRegex(), "/")
getString(R.string.git_commit_move_multiple_text, relative)
}
- commitChange(message)
+ commitAndPush(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))
+ commitAndPush(getString(R.string.git_commit_move_text, from.name, to.name))
}
/** Carries the "recently used" time of [from] over to [to]. */
@@ -152,28 +179,21 @@ private suspend fun BaseGitActivity.moveFile(source: File, destination: File): B
}
}
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()
+ logcat(ERROR) { "Something went wrong while moving a file." }
+ prompts.ask(
+ title = getString(R.string.password_move_error_title),
+ message = getString(R.string.password_move_error_message, source, destination),
+ confirm = getString(R.string.dialog_ok),
+ )
}
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() }
- }
+private suspend fun BaseGitActivity.confirm(title: String, message: String): Boolean =
+ prompts.ask(
+ title = title,
+ message = message,
+ confirm = getString(R.string.dialog_ok),
+ cancel = getString(R.string.dialog_cancel),
+ ) == Choice.Confirm
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 c0af5742..54aaf9de 100644
--- a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt
@@ -9,8 +9,8 @@ import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
-import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.edit
+import androidx.fragment.app.FragmentActivity
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.util.auth.BiometricAuthenticator
import app.passwordstore.util.auth.BiometricAuthenticator.Result
@@ -19,7 +19,7 @@ import app.passwordstore.util.settings.PreferenceKeys
import java.io.File
@SuppressLint("CustomSplashScreen")
-class LaunchActivity : AppCompatActivity() {
+class LaunchActivity : FragmentActivity() {
private var prompted = 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
index 462bdb18..5037e646 100644
--- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
@@ -29,6 +29,8 @@ 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.prompts.PromptHost
+import app.passwordstore.ui.settings.AboutRoute
import app.passwordstore.ui.settings.SettingsRoute
import app.passwordstore.ui.settings.settingsGraph
import app.passwordstore.ui.setup.GitServerRoute
@@ -70,8 +72,8 @@ import kotlinx.serialization.Serializable
/**
* 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.
+ * [BaseGitActivity] (a FragmentActivity, which BiometricPrompt needs) because the git operations
+ * require one; it hosts no fragments.
*/
@AndroidEntryPoint
class MainActivity : BaseGitActivity() {
@@ -129,6 +131,7 @@ class MainActivity : BaseGitActivity() {
onSettings = {
if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(SettingsRoute)
},
+ onAbout = { if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(AboutRoute) },
onNoStore = {
// The store is gone (deleted from Settings): setup starts over.
if (nav.currentBackStackEntry?.id == entry.id)
@@ -199,6 +202,7 @@ class MainActivity : BaseGitActivity() {
setupGraph(this@MainActivity, nav)
settingsGraph(this@MainActivity, nav)
}
+ PromptHost(this@MainActivity)
}
}
}
@@ -263,7 +267,7 @@ class MainActivity : BaseGitActivity() {
*/
// 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.
+ // key" dialog could navigate in-graph if git operations could reach the nav controller.
fun setupIntent(context: Context, start: SetupStart): Intent =
Intent(context, MainActivity::class.java).putExtra(EXTRA_SETUP, start.name)
}
diff --git a/app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt b/app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt
new file mode 100644
index 00000000..1af3f01c
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt
@@ -0,0 +1,293 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.prompts
+
+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.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+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.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+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.mutableStateListOf
+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.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.text.input.VisualTransformation
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.window.SecureFlagPolicy
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import app.passwordstore.R
+import app.passwordstore.ui.compose.R as composeR
+import kotlin.coroutines.resume
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+/** The button that answered a [Question]. Dismissing the dialog answers [Cancel]. */
+enum class Choice {
+ Confirm,
+ Other,
+ Cancel,
+}
+
+/** What the user entered in a [CredentialRequest]. */
+class CredentialAnswer(val text: String, val remember: Boolean)
+
+/** A dialog waiting for its answer; [cancelled] is the answer to dismissing it. */
+sealed class Prompt<T>(private val cancelled: T, private val deliver: (T) -> Unit) {
+ internal fun answer(value: T) = deliver(value)
+
+ internal fun cancel() = deliver(cancelled)
+}
+
+class Question(
+ val title: String,
+ val message: String,
+ val confirm: String,
+ val cancel: String?,
+ val other: String?,
+ deliver: (Choice) -> Unit,
+) : Prompt<Choice>(Choice.Cancel, deliver)
+
+class CredentialRequest(
+ val message: String,
+ val hint: String,
+ val remember: String,
+ val error: String?,
+ deliver: (CredentialAnswer?) -> Unit,
+) : Prompt<CredentialAnswer?>(null, deliver)
+
+/** A snackbar; each one is a distinct object, so showing the same text again shows it again. */
+class Snack(val message: String, val length: SnackbarDuration)
+
+/**
+ * The dialogs and the snackbar of one activity, for code with no composable of its own (git
+ * operations and store actions), drawn by [PromptHost]. Reach it through [prompts].
+ */
+class Prompts : ViewModel() {
+
+ private val queue = mutableStateListOf<Prompt<*>>()
+
+ /** Set once the activity is gone for good: nothing draws a new dialog any more. */
+ private var closed = false
+
+ /** The dialog on screen. The others wait for it to be answered. */
+ val current: Prompt<*>?
+ get() = queue.firstOrNull()
+
+ var snack by mutableStateOf<Snack?>(null)
+ private set
+
+ /** Shows a dialog and suspends until it is answered. Cancelling the caller closes it. */
+ suspend fun ask(
+ title: String,
+ message: String,
+ confirm: String,
+ cancel: String? = null,
+ other: String? = null,
+ ): Choice = suspendCancellableCoroutine { cont ->
+ val question = Question(title, message, confirm, cancel, other) { cont.resume(it) }
+ queue += question
+ cont.invokeOnCancellation { queue.remove(question) }
+ }
+
+ /**
+ * Asks for a credential. Not suspending: the SSH thread waits on [deliver], and no cancellation
+ * reaches it, so this dialog outlives a recreated activity and is drawn again by the new one.
+ */
+ fun askCredential(
+ message: String,
+ hint: String,
+ remember: String,
+ error: String?,
+ deliver: (CredentialAnswer?) -> Unit,
+ ) {
+ if (closed) {
+ deliver(null)
+ return
+ }
+ queue += CredentialRequest(message, hint, remember, error, deliver)
+ }
+
+ /** Answers [prompt], once: a double tap must not resume its caller twice. */
+ fun <T> answer(prompt: Prompt<T>, value: T) {
+ if (queue.remove(prompt)) prompt.answer(value)
+ }
+
+ fun cancel(prompt: Prompt<*>) {
+ if (queue.remove(prompt)) prompt.cancel()
+ }
+
+ fun cancelAll() {
+ queue.toList().forEach(::cancel)
+ }
+
+ /** Shows [message] in place of any current snackbar, as the View snackbar did. */
+ fun snackbar(message: String, length: SnackbarDuration = SnackbarDuration.Short): Snack =
+ Snack(message, length).also { snack = it }
+
+ /** Hides [shown], unless another snackbar replaced it. */
+ fun dismiss(shown: Snack) {
+ if (snack === shown) snack = null
+ }
+
+ internal fun close() {
+ closed = true
+ cancelAll()
+ }
+
+ override fun onCleared() = close()
+}
+
+/** The [Prompts] of this activity; it survives recreation. */
+val FragmentActivity.prompts: Prompts
+ get() = ViewModelProvider(this)[Prompts::class.java]
+
+/** Draws the [Prompts] of [activity]. Place it after the activity's content, inside `APSTheme`. */
+@Composable
+fun PromptHost(activity: FragmentActivity, modifier: Modifier = Modifier) {
+ val prompts = remember(activity) { activity.prompts }
+ val snackbars = remember { SnackbarHostState() }
+ val snack = prompts.snack
+ // A new snack cancels this effect, which takes the old one off the screen.
+ LaunchedEffect(snack) {
+ if (snack != null) {
+ snackbars.showSnackbar(snack.message, duration = snack.length)
+ prompts.dismiss(snack)
+ }
+ }
+ Box(modifier.fillMaxSize().navigationBarsPadding().imePadding(), Alignment.BottomCenter) {
+ SnackbarHost(snackbars)
+ }
+ val prompt = prompts.current
+ // Keyed: a retry is a new request and starts with an empty field.
+ key(prompt) {
+ when (prompt) {
+ is Question -> QuestionDialog(prompt, prompts)
+ is CredentialRequest -> CredentialDialog(prompt, prompts)
+ null -> {}
+ }
+ }
+}
+
+@Composable
+private fun QuestionDialog(question: Question, prompts: Prompts) {
+ AlertDialog(
+ onDismissRequest = { prompts.cancel(question) },
+ title = { Text(question.title) },
+ text = { Text(question.message) },
+ confirmButton = {
+ TextButton(onClick = { prompts.answer(question, Choice.Confirm) }) { Text(question.confirm) }
+ },
+ dismissButton = {
+ // No Row: the dialog's button area wraps its children when they do not fit.
+ question.cancel?.let {
+ TextButton(onClick = { prompts.answer(question, Choice.Cancel) }) { Text(it) }
+ }
+ question.other?.let {
+ TextButton(onClick = { prompts.answer(question, Choice.Other) }) { Text(it) }
+ }
+ },
+ )
+}
+
+/** The credential lives only in `remember` and the dialog's window is FLAG_SECURE. */
+@Composable
+private fun CredentialDialog(request: CredentialRequest, prompts: Prompts) {
+ // remember, never rememberSaveable: the credential must not reach saved state.
+ var text by remember { mutableStateOf("") }
+ var keep by remember { mutableStateOf(true) }
+ var revealed by remember { mutableStateOf(false) }
+ var error by remember { mutableStateOf(request.error) }
+ val focus = remember { FocusRequester() }
+ fun submit() = prompts.answer(request, CredentialAnswer(text, keep))
+ AlertDialog(
+ onDismissRequest = { prompts.cancel(request) },
+ properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
+ title = { Text(stringResource(R.string.passphrase_dialog_title)) },
+ text = {
+ Column {
+ Text(request.message)
+ OutlinedTextField(
+ value = text,
+ onValueChange = {
+ text = it
+ error = null
+ },
+ modifier = Modifier.focusRequester(focus),
+ label = { Text(request.hint) },
+ supportingText = error?.let { { Text(it) } },
+ isError = error != null,
+ trailingIcon = {
+ IconButton(onClick = { revealed = !revealed }) {
+ Icon(
+ painterResource(
+ if (revealed) composeR.drawable.baseline_visibility_off_24
+ else composeR.drawable.baseline_visibility_24
+ ),
+ contentDescription =
+ stringResource(
+ if (revealed) R.string.entry_hide_password else R.string.entry_show_password
+ ),
+ )
+ }
+ },
+ visualTransformation =
+ if (revealed) VisualTransformation.None else PasswordVisualTransformation(),
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(onDone = { submit() }),
+ )
+ Row(
+ Modifier.toggleable(value = keep, role = Role.Checkbox, onValueChange = { keep = it }),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(checked = keep, onCheckedChange = null)
+ Text(request.remember)
+ }
+ }
+ LaunchedEffect(Unit) { focus.requestFocus() }
+ },
+ confirmButton = { TextButton(onClick = ::submit) { Text(stringResource(R.string.dialog_ok)) } },
+ dismissButton = {
+ TextButton(onClick = { prompts.cancel(request) }) {
+ Text(stringResource(R.string.dialog_cancel))
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt
new file mode 100644
index 00000000..4a366390
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt
@@ -0,0 +1,231 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Image
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import androidx.fragment.app.FragmentActivity
+import app.passwordstore.BuildConfig
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.crypto.copyTextToClipboard
+import app.passwordstore.util.extensions.getPackageInfoCompat
+import app.passwordstore.util.extensions.snackbar
+import java.security.MessageDigest
+
+private const val REPOSITORY = "https://github.com/danixland/HardenedPass"
+private const val DOCUMENTATION = "https://danix.xyz/hardenedpass"
+private const val KO_FI = "https://ko-fi.com/K6Y520C9I6"
+
+/**
+ * What ships in the APK and its license, by hand: update it when a dependency comes or goes. A
+ * generating plugin would be one more build dependency for a list this short.
+ */
+private val LICENSES =
+ listOf(
+ "AndroidX, Jetpack Compose" to "Apache-2.0",
+ "Apache Commons Codec, Curtains, Guava, JavaEWAH, JSpecify, uri-kmp" to "Apache-2.0",
+ "Bouncy Castle" to "Bouncy Castle Licence (MIT)",
+ "Dagger, Hilt" to "Apache-2.0",
+ "EFF Diceware word list" to "CC-BY",
+ "JGit" to "EDL-1.0 (BSD-3-Clause)",
+ "kotlin-result" to "ISC",
+ "Kotlin, kotlinx" to "Apache-2.0",
+ "LeakCanary plumber, logcat" to "Apache-2.0",
+ "Manrope" to "OFL-1.1",
+ "PGPainless" to "Apache-2.0",
+ "Public Suffix List" to "MPL-2.0",
+ "SLF4J" to "MIT",
+ "sshj" to "Apache-2.0",
+ "sublime-fuzzy" to "MIT",
+ "ZXing, zxing-android-embedded" to "Apache-2.0",
+ )
+
+/** `0.3.0 (300)`, followed by the [commit] on the debug builds that have one. */
+internal fun versionLine(name: String, code: Int, commit: String): String =
+ if (commit.isEmpty()) "$name ($code)" else "$name ($code) · $commit"
+
+/** The changelog at the tag of [versionName], without a suffix like `-debug`. */
+internal fun changelogUrl(versionName: String): String =
+ "$REPOSITORY/blob/v${versionName.substringBefore('-')}/CHANGELOG.md"
+
+/** SHA-256 of [certificate] as `apksigner` and `keytool` users compare it, colon separated. */
+internal fun certificateFingerprint(certificate: ByteArray): String =
+ MessageDigest.getInstance("SHA-256").digest(certificate).joinToString(":") { "%02X".format(it) }
+
+/** The certificate this copy was really signed with, so it can be compared with the published. */
+private fun Context.signingFingerprint(): String? =
+ packageManager
+ .getPackageInfoCompat(packageName, PackageManager.GET_SIGNING_CERTIFICATES)
+ .signingInfo
+ ?.apkContentsSigners
+ ?.firstOrNull()
+ ?.let { certificateFingerprint(it.toByteArray()) }
+
+/** The about screen. Links leave for the browser: the app itself makes no connection for them. */
+@Composable
+fun AboutDestination(
+ activity: FragmentActivity,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val fingerprint = remember { activity.signingFingerprint() }
+ AboutScreen(
+ version =
+ versionLine(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE, BuildConfig.GIT_COMMIT),
+ changelog = changelogUrl(BuildConfig.VERSION_NAME),
+ fingerprint = fingerprint,
+ onCopy = { activity.copyTextToClipboard(it, sensitive = false) },
+ onOpen = { url ->
+ try {
+ activity.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
+ } catch (_: ActivityNotFoundException) {
+ activity.snackbar(message = activity.getString(R.string.about_no_browser))
+ }
+ },
+ onBack = onBack,
+ modifier = modifier,
+ )
+}
+
+@Composable
+private fun AboutScreen(
+ version: String,
+ changelog: String,
+ fingerprint: String?,
+ onCopy: (String) -> Unit,
+ onOpen: (String) -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ SettingsScaffold(stringResource(R.string.about), onBack, modifier) {
+ Column(
+ Modifier.fillMaxWidth().padding(SpacingMedium),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(SpacingMedium),
+ ) {
+ Box(Modifier.size(96.dp).clip(CircleShape)) {
+ Image(painterResource(R.drawable.ic_launcher_background), contentDescription = null)
+ Image(painterResource(R.drawable.ic_launcher_foreground), contentDescription = null)
+ }
+ Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineSmall)
+ Text(stringResource(R.string.about_summary), textAlign = TextAlign.Center)
+ }
+ AboutRow(stringResource(R.string.about_version), version) { onCopy(version) }
+ LinkRow(stringResource(R.string.about_docs)) { onOpen(DOCUMENTATION) }
+ LinkRow(stringResource(R.string.about_changelog)) { onOpen(changelog) }
+ LinkRow(stringResource(R.string.about_source)) { onOpen(REPOSITORY) }
+ LinkRow(stringResource(R.string.about_issues)) { onOpen("$REPOSITORY/issues") }
+ // White in both themes: the logo's dark outline disappears on a dark background, and Ko-fi
+ // asks that the logo itself stays as it is.
+ Button(
+ onClick = { onOpen(KO_FI) },
+ modifier = Modifier.fillMaxWidth().padding(SpacingMedium),
+ colors =
+ ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
+ ) {
+ Image(
+ painterResource(R.drawable.ic_kofi_symbol),
+ contentDescription = null,
+ modifier = Modifier.height(24.dp),
+ )
+ Box(Modifier.width(SpacingMedium))
+ Text(stringResource(R.string.about_support))
+ }
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.about_privacy_title)) },
+ supportingContent = { Text(stringResource(R.string.about_privacy)) },
+ colors = rowColors(),
+ )
+ if (fingerprint != null)
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.about_certificate)) },
+ supportingContent = { Text(fingerprint, fontFamily = FontFamily.Monospace) },
+ modifier = Modifier.clickable(role = Role.Button) { onCopy(fingerprint) },
+ colors = rowColors(),
+ )
+ AboutRow(stringResource(R.string.about_license), stringResource(R.string.about_license_text)) {
+ onOpen("$REPOSITORY/blob/master/LICENSE.txt")
+ }
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.about_libraries)) },
+ colors = rowColors(),
+ )
+ LICENSES.forEach { (name, license) ->
+ ListItem(
+ headlineContent = { Text(name) },
+ supportingContent = { Text(license) },
+ colors = rowColors(),
+ )
+ }
+ }
+}
+
+@Composable
+private fun AboutRow(
+ title: String,
+ summary: String? = null,
+ trailing: (@Composable () -> Unit)? = null,
+ onClick: () -> Unit,
+) {
+ ListItem(
+ headlineContent = { Text(title) },
+ supportingContent = summary?.let { { Text(it) } },
+ trailingContent = trailing,
+ modifier = Modifier.clickable(role = Role.Button, onClick = onClick),
+ colors = rowColors(),
+ )
+}
+
+/** A row that leaves for the browser, and says so. */
+@Composable
+private fun LinkRow(title: String, summary: String? = null, onClick: () -> Unit) {
+ AboutRow(
+ title = title,
+ summary = summary,
+ trailing = {
+ Icon(
+ painterResource(R.drawable.ic_open_in_new_24dp),
+ contentDescription = stringResource(R.string.about_opens_browser),
+ )
+ },
+ onClick = onClick,
+ )
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt
index d008eea3..ed07c389 100644
--- a/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt
+++ b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt
@@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -54,7 +55,6 @@ 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
/**
@@ -223,7 +223,7 @@ fun GitConfigDestination(
}
activity.snackbar(
message = activity.getString(R.string.git_tool_done, activity.getString(label)),
- length = Snackbar.LENGTH_LONG,
+ length = SnackbarDuration.Long,
)
viewModel.done = null
}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt
index fb2bfe93..10f82178 100644
--- a/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt
+++ b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt
@@ -6,7 +6,6 @@
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
@@ -19,10 +18,7 @@ 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 ||
+ return InetAddresses.isNumericAddress(text) ||
(!text.contains(':') && !text.contains('/') && Patterns.DOMAIN_NAME.matcher(text).matches())
}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt
index c3809b36..05dc6b65 100644
--- a/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt
@@ -38,6 +38,7 @@ import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.lifecycleScope
import app.passwordstore.BuildConfig
import app.passwordstore.R
import app.passwordstore.ui.compose.theme.SpacingLarge
@@ -52,6 +53,7 @@ import app.passwordstore.util.services.PasswordExportService
import app.passwordstore.util.settings.PreferenceKeys
import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel
import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel
+import kotlinx.coroutines.launch
private enum class SettingsDialog {
Autofill,
@@ -125,6 +127,16 @@ fun SettingsSectionDestination(
)
}
+ val exportLog =
+ rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
+ if (uri != null)
+ activity.lifecycleScope.launch {
+ val message =
+ if (viewModel.exportLog(uri)) R.string.log_exported else R.string.log_export_failed
+ activity.snackbar(message = activity.getString(message))
+ }
+ }
+
// 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) }
@@ -206,6 +218,7 @@ fun SettingsSectionDestination(
SettingsAction.ClearSecret -> dialog = SettingsDialog.ClearSecret
SettingsAction.DeleteRepository -> dialog = SettingsDialog.DeleteRepository
SettingsAction.Export -> export.launch(null)
+ SettingsAction.ExportLog -> exportLog.launch("hardenedpass-log.txt")
else -> onOpen(action)
}
},
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt
index ae4e277b..e45166e0 100644
--- a/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt
@@ -30,6 +30,9 @@ import kotlinx.serialization.Serializable
/** The store's commit log. */
@Serializable data object GitLogRoute
+/** Version, links, certificate and licenses. */
+@Serializable data object AboutRoute
+
/** HTTP(S) proxy settings. */
@Serializable data object ProxyRoute
@@ -47,6 +50,9 @@ fun NavGraphBuilder.settingsGraph(activity: MainActivity, nav: NavHostController
onBack = { close(entry) },
)
}
+ composable<AboutRoute> { entry ->
+ AboutDestination(activity = activity, onBack = { close(entry) })
+ }
composable<SettingsSectionRoute> { entry ->
SettingsSectionDestination(
activity = activity,
@@ -64,7 +70,8 @@ fun NavGraphBuilder.settingsGraph(activity: MainActivity, nav: NavHostController
// Handled by the destination itself.
SettingsAction.ClearSecret,
SettingsAction.DeleteRepository,
- SettingsAction.Export -> Unit
+ SettingsAction.Export,
+ SettingsAction.ExportLog -> Unit
}
},
onDeleted = {
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt
index eca6770c..54250f74 100644
--- a/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt
@@ -9,6 +9,7 @@ import app.passwordstore.util.extensions.hash
import app.passwordstore.util.extensions.time
import app.passwordstore.util.git.GitCommit
import java.io.IOException
+import java.io.OutputStream
import logcat.LogPriority.ERROR
import logcat.asLog
import logcat.logcat
@@ -77,3 +78,17 @@ fun gitLog(repo: Repository?): List<GitCommit> {
emptyList()
}
}
+
+/**
+ * Writes [header], then what logcat holds for this app: Android shows an app only its own lines,
+ * and those of its earlier runs and crashes until the buffer rotates.
+ */
+fun writeAppLog(header: String, out: OutputStream) {
+ out.write(header.toByteArray())
+ val logcat =
+ ProcessBuilder("logcat", "-d", "-b", "main,crash", "-v", "threadtime")
+ .redirectErrorStream(true)
+ .start()
+ logcat.inputStream.use { it.copyTo(out) }
+ logcat.waitFor()
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt
index 7d45ad74..2e7840ad 100644
--- a/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt
@@ -35,6 +35,7 @@ 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.Constants
import app.passwordstore.util.settings.PreferenceKeys
import kotlinx.collections.immutable.persistentListOf
import kotlinx.serialization.Serializable
@@ -62,12 +63,13 @@ enum class SettingsAction {
ClearSecret,
DeleteRepository,
Export,
+ ExportLog,
}
/** Top bar with [title] and back, then [content] in one grouped surface, scrolling. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun SettingsScaffold(
+internal fun SettingsScaffold(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
@@ -229,7 +231,7 @@ fun SettingsSectionScreen(
summary =
stringResource(
R.string.pref_clipboard_timeout_summary,
- state.clipboardTimeout.ifEmpty { "45" },
+ state.clipboardTimeout.ifEmpty { Constants.DEFAULT_DECRYPTION_TIMEOUT.toString() },
),
keyboardType = KeyboardType.Number,
)
@@ -257,6 +259,12 @@ fun SettingsSectionScreen(
else R.string.pref_rebase_on_pull_summary
),
)
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_push_after_commit_title),
+ checked = state.pushAfterCommit,
+ onCheckedChange = { onBoolean(PreferenceKeys.PUSH_AFTER_COMMIT, it) },
+ summary = stringResource(R.string.pref_push_after_commit_summary),
+ )
if (state.isGitRepo) {
PreferenceAction(
title = stringResource(R.string.pref_edit_git_server_settings),
@@ -317,6 +325,11 @@ fun SettingsSectionScreen(
onCheckedChange = { onBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, it) },
summary = stringResource(R.string.pref_debug_logging_summary),
)
+ PreferenceAction(
+ title = stringResource(R.string.pref_export_log_title),
+ summary = stringResource(R.string.pref_export_log_summary),
+ onClick = { onAction(SettingsAction.ExportLog) },
+ )
}
SettingsSection.Pgp -> {
PreferenceAction(
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt
index ef604f1f..90aecb2a 100644
--- a/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt
@@ -8,6 +8,8 @@ package app.passwordstore.ui.settings
import android.app.Application
import android.content.SharedPreferences
import android.content.pm.ShortcutManager
+import android.net.Uri
+import android.os.Build
import androidx.annotation.StringRes
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -16,6 +18,7 @@ import androidx.core.content.edit
import androidx.core.content.getSystemService
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
+import app.passwordstore.BuildConfig
import app.passwordstore.R
import app.passwordstore.data.crypto.PGPPassphraseCache
import app.passwordstore.data.repo.PasswordRepository
@@ -31,12 +34,16 @@ 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.io.IOException
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
+import logcat.LogPriority.ERROR
+import logcat.asLog
+import logcat.logcat
/** What the settings sections show: stored values, and the state rows depend on. */
data class SettingsState(
@@ -55,6 +62,7 @@ data class SettingsState(
val showPassword: Boolean,
val copyOnDecrypt: Boolean,
val rebaseOnPull: Boolean,
+ val pushAfterCommit: Boolean,
val isGitRepo: Boolean,
val httpsRemote: Boolean,
val canShowSshKey: Boolean,
@@ -110,6 +118,27 @@ constructor(
state = read()
}
+ /** Saves the app's log to [uri]; false when that failed. */
+ suspend fun exportLog(uri: Uri): Boolean =
+ withContext(dispatcherProvider.io()) {
+ val header =
+ "HardenedPass ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}), " +
+ "Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}), ${Build.MODEL}\n\n"
+ try {
+ val out =
+ getApplication<Application>().contentResolver.openOutputStream(uri)
+ ?: throw IOException("No stream for the chosen file")
+ out.use { writeAppLog(header, it) }
+ true
+ } catch (e: IOException) {
+ logcat(ERROR) { e.asLog() }
+ false
+ } catch (e: SecurityException) {
+ logcat(ERROR) { e.asLog() }
+ false
+ }
+ }
+
fun putBoolean(key: String, value: Boolean) {
prefs.edit { putBoolean(key, value) }
}
@@ -205,6 +234,7 @@ constructor(
showPassword = prefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true),
copyOnDecrypt = prefs.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false),
rebaseOnPull = gitSettings.rebaseOnPull,
+ pushAfterCommit = gitSettings.pushAfterCommit,
isGitRepo = PasswordRepository.isGitRepo(),
httpsRemote = gitSettings.url?.startsWith("https") == true,
canShowSshKey = SshKey.canShowSshPublicKey,
diff --git a/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt
index fd440996..4b24375b 100644
--- a/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt
+++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt
@@ -32,6 +32,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -60,7 +61,6 @@ 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
/**
@@ -210,7 +210,7 @@ fun GitServerDestination(
BackHandler(enabled = viewModel.progress != null) {}
fun say(message: String) {
- activity.snackbar(message = message, length = Snackbar.LENGTH_LONG)
+ activity.snackbar(message = message, length = SnackbarDuration.Long)
}
// ponytail: a theme or locale change cancels the clone with the activity's scope, like every
diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt
index 11fd979c..6004ec90 100644
--- a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt
+++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt
@@ -31,6 +31,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -59,7 +60,6 @@ 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
@@ -269,7 +269,7 @@ fun PgpKeysDestination(
if (error != null) {
activity.snackbar(
message = activity.getString(R.string.store_create_failed, error),
- length = Snackbar.LENGTH_LONG,
+ length = SnackbarDuration.Long,
)
} else {
// A failed commit is reported like any git error; the store exists either way, and
diff --git a/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt
index f93fc9be..390f1160 100644
--- a/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt
+++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt
@@ -195,7 +195,7 @@ fun SshKeyDestination(
busy = viewModel.busy,
onImport = { if (SshKey.exists) replace = true else picker.launch(arrayOf("*/*")) },
onGenerate = onGenerate,
- onCopy = { publicKey?.let { activity.copyTextToClipboard(it) } },
+ onCopy = { publicKey?.let { activity.copyTextToClipboard(it, sensitive = false) } },
onShare = { publicKey?.let { activity.startActivity(shareKey(it)) } },
onBack = { if (!viewModel.busy) onClose() },
)
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 98f2229a..8f912879 100644
--- a/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt
+++ b/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt
@@ -5,7 +5,6 @@
package app.passwordstore.util.auth
import android.app.KeyguardManager
-import android.os.Build
import androidx.annotation.StringRes
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators
@@ -23,14 +22,10 @@ object BiometricAuthenticator {
/**
* Keystore keys that require user authentication are unlocked only by the device credential or a
* Class 3 (strong) biometric, so a weak biometric would pass the prompt and then fail to sign or
- * decrypt with `User not authenticated`. Android 9 and 10 do not support combining
- * [Authenticators.BIOMETRIC_STRONG] with [Authenticators.DEVICE_CREDENTIAL], so they keep the
- * weak class.
+ * decrypt with `User not authenticated`.
*/
private val VALID_AUTHENTICATORS =
- Authenticators.DEVICE_CREDENTIAL or
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Authenticators.BIOMETRIC_STRONG
- else Authenticators.BIOMETRIC_WEAK
+ Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_STRONG
/**
* Sealed class to wrap [BiometricPrompt]'s [Int]-based return codes into more easily-interpreted
diff --git a/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt
deleted file mode 100644
index f38011a7..00000000
--- a/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.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.util.autofill
-
-import android.content.Context
-import android.content.IntentSender
-import android.os.Build
-import android.service.autofill.Dataset
-import android.service.autofill.FillCallback
-import android.service.autofill.FillRequest
-import android.service.autofill.FillResponse
-import android.service.autofill.SaveInfo
-import app.passwordstore.ui.autofill.AutofillDecryptActivity
-import app.passwordstore.ui.autofill.AutofillFilterView
-import app.passwordstore.ui.autofill.AutofillPublisherChangedActivity
-import app.passwordstore.ui.autofill.AutofillSaveActivity
-import com.github.androidpasswordstore.autofillparser.AutofillAction
-import com.github.androidpasswordstore.autofillparser.FillableForm
-import com.github.androidpasswordstore.autofillparser.fillWith
-import com.github.michaelbull.result.fold
-import java.io.File
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-class Api26AutofillResponseBuilder private constructor(form: FillableForm) :
- AutofillResponseBuilder {
-
- object Factory : AutofillResponseBuilder.Factory {
- override fun create(form: FillableForm): AutofillResponseBuilder =
- Api26AutofillResponseBuilder(form)
- }
-
- private val formOrigin = form.formOrigin
- private val scenario = form.scenario
- private val ignoredIds = form.ignoredIds
- private val saveFlags = form.saveFlags
- private val clientState = form.toClientState()
-
- // We do not offer save when the only relevant field is a username field or there is no field.
- private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
- private val canBeSaved = saveFlags != null && scenarioSupportsSave
-
- @Suppress("DEPRECATION")
- private fun makeIntentDataset(
- context: Context,
- action: AutofillAction,
- intentSender: IntentSender,
- metadata: DatasetMetadata,
- ): Dataset {
- return Dataset.Builder(makeRemoteView(context, metadata)).run {
- fillWith(scenario, action, credentials = null)
- setAuthentication(intentSender)
- build()
- }
- }
-
- private fun makeMatchDataset(context: Context, file: File): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
- val metadata = makeFillMatchMetadata(context, file)
- val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
- return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
- }
-
- private fun makeSearchDataset(context: Context): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
- val metadata = makeSearchAndFillMetadata(context)
- val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
- return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata)
- }
-
- private fun makeGenerateDataset(context: Context): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
- val metadata = makeGenerateAndFillMetadata(context)
- val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
- return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata)
- }
-
- private fun makePublisherChangedDataset(
- context: Context,
- publisherChangedException: AutofillPublisherChangedException,
- ): Dataset {
- val metadata = makeWarningMetadata(context)
- // If the user decides to trust the new publisher, they can choose reset the list of
- // matches. In this case we need to immediately show a new `FillResponse` as if the app were
- // autofilled for the first time. This `FillResponse` needs to be returned as a result from
- // `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
- val fillResponseAfterReset = makeFillResponse(context, emptyList())
- val intentSender =
- AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
- context,
- publisherChangedException,
- fillResponseAfterReset,
- )
- return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
- }
-
- private fun makePublisherChangedResponse(
- context: Context,
- publisherChangedException: AutofillPublisherChangedException,
- ): FillResponse {
- return FillResponse.Builder().run {
- addDataset(makePublisherChangedDataset(context, publisherChangedException))
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
- }
- }
-
- // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
- // See:
- // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
- private fun makeSaveInfo(): SaveInfo? {
- if (!canBeSaved) return null
- check(saveFlags != null) { "saveFlags must not be null" }
- val idsToSave = scenario.fieldsToSave.toTypedArray()
- if (idsToSave.isEmpty()) return null
- var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
- if (scenario.hasUsername) {
- saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
- }
- return SaveInfo.Builder(saveDataTypes, idsToSave).run {
- setFlags(saveFlags)
- build()
- }
- }
-
- private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
- var datasetCount = 0
- return FillResponse.Builder().run {
- for (file in matchedFiles) {
- makeMatchDataset(context, file)?.let {
- datasetCount++
- addDataset(it)
- }
- }
- makeGenerateDataset(context)?.let {
- datasetCount++
- addDataset(it)
- }
- makeSearchDataset(context)?.let {
- datasetCount++
- addDataset(it)
- }
- if (datasetCount == 0) return null
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- setHeader(
- makeRemoteView(
- context,
- makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)),
- )
- )
- }
- makeSaveInfo()?.let { setSaveInfo(it) }
- setClientState(clientState)
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
- }
- }
-
- /** Creates and returns a suitable [FillResponse] to the Autofill framework. */
- override fun fillCredentials(context: Context, fillRequest: FillRequest, callback: FillCallback) {
- AutofillMatcher.getMatchesFor(context, formOrigin)
- .fold(
- success = { matchedFiles -> callback.onSuccess(makeFillResponse(context, matchedFiles)) },
- failure = { e ->
- logcat(ERROR) { e.asLog() }
- callback.onSuccess(makePublisherChangedResponse(context, e))
- },
- )
- }
-}
diff --git a/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt
index 55679b21..bf41327f 100644
--- a/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt
+++ b/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt
@@ -31,7 +31,6 @@ import logcat.asLog
import logcat.logcat
/** Implements [AutofillResponseBuilder]'s methods for API 30 and above */
-@RequiresApi(Build.VERSION_CODES.R)
class Api30AutofillResponseBuilder private constructor(form: FillableForm) :
AutofillResponseBuilder {
diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt
index dbaa5675..b4b3d017 100644
--- a/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt
+++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt
@@ -194,7 +194,7 @@ class AutofillMatcher {
.minus(oldNewPathMap.values)
.map { match ->
val newPath = oldNewPathMap[match] ?: return@map match
- logcat { "Updating match for $key: $match --> $newPath" }
+ logcat { "Updating match for $key" }
newPath
}
.toSet()
diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt
index bfa47d74..a98aefc5 100644
--- a/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt
+++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt
@@ -6,7 +6,6 @@
package app.passwordstore.util.autofill
import android.content.Context
-import android.os.Build
import android.os.Bundle
import android.service.autofill.Dataset
import android.service.autofill.FillCallback
@@ -34,19 +33,11 @@ interface AutofillResponseBuilder {
action: AutofillAction,
): Dataset {
val scenario = AutofillScenario.fromClientState(clientState)
- // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even
- // though they are rarely shown.
// FIXME: We should clone the original dataset here and add the credentials to be filled
// in. Otherwise, the entry in the cached list of datasets will be overwritten by the
// fill-in dataset without any visual representation. This causes it to be missing from
// the Autofill suggestions shown after the user clears the filled out form fields.
- val builder =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- Dataset.Builder()
- } else {
- @Suppress("DEPRECATION") Dataset.Builder(makeRemoteView(context, makeEmptyMetadata()))
- }
- return builder.run {
+ return Dataset.Builder().run {
if (scenario != null) fillWith(scenario, action, credentials)
else logcat(LogPriority.ERROR) { "Failed to recover scenario from client state" }
build()
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 59fd5cca..321c111a 100644
--- a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt
+++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt
@@ -10,7 +10,6 @@ import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.graphics.drawable.Icon
-import android.os.Build
import android.service.autofill.InlinePresentation
import android.view.View
import android.widget.RemoteViews
@@ -47,8 +46,6 @@ fun makeInlinePresentation(
imeSpec: InlinePresentationSpec,
metadata: DatasetMetadata,
): InlinePresentation? {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
-
if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) return null
val launchIntent =
@@ -56,11 +53,7 @@ fun makeInlinePresentation(
context,
0,
Intent(context, LaunchActivity::class.java),
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- PendingIntent.FLAG_MUTABLE
- } else {
- 0
- },
+ PendingIntent.FLAG_MUTABLE,
)
val slice =
InlineSuggestionUi.newContentBuilder(launchIntent).run {
@@ -101,8 +94,6 @@ fun makeGenerateAndFillMetadata(context: Context) =
R.drawable.ic_autofill_new_password,
)
-fun makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
-
fun makeWarningMetadata(context: Context) =
DatasetMetadata(
context.getString(R.string.oreo_autofill_warning_publisher_dataset_title),
diff --git a/app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt b/app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt
index f4d43e95..e23ec896 100644
--- a/app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt
+++ b/app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt
@@ -7,7 +7,6 @@ package app.passwordstore.util.crypto
import android.content.Context
import android.content.SharedPreferences
-import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.security.keystore.StrongBoxUnavailableException
@@ -66,18 +65,14 @@ fun keystoreAesKey(
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.apply(configure)
- .apply {
- if (useStrongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- setIsStrongBoxBacked(true)
- }
- }
+ .setIsStrongBoxBacked(useStrongBox)
.build()
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
init(spec)
generateKey()
}
}
- if (!strongBox || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return generate(false)
+ if (!strongBox) return generate(false)
return try {
generate(true)
} catch (_: StrongBoxUnavailableException) {
@@ -91,14 +86,10 @@ fun keystoreAesKey(
*/
fun KeyGenParameterSpec.Builder.requireUserAuthentication(seconds: Int) {
setUserAuthenticationRequired(true)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- setUserAuthenticationParameters(
- seconds,
- KeyProperties.AUTH_DEVICE_CREDENTIAL or KeyProperties.AUTH_BIOMETRIC_STRONG,
- )
- } else {
- @Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(seconds)
- }
+ setUserAuthenticationParameters(
+ seconds,
+ KeyProperties.AUTH_DEVICE_CREDENTIAL or KeyProperties.AUTH_BIOMETRIC_STRONG,
+ )
}
/**
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 6528334d..e24f96ca 100644
--- a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
+++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
@@ -16,21 +16,21 @@ import android.content.pm.PackageManager.ApplicationInfoFlags
import android.content.pm.PackageManager.PackageInfoFlags
import android.os.Build
import android.util.Base64
-import android.view.View
import android.view.autofill.AutofillManager
+import androidx.compose.material3.SnackbarDuration
import androidx.core.content.getSystemService
import androidx.fragment.app.FragmentActivity
import app.passwordstore.BuildConfig
-import app.passwordstore.R
import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.prompts.Snack
+import app.passwordstore.ui.prompts.prompts
import app.passwordstore.util.crypto.keystorePreferences
import app.passwordstore.util.git.operation.GitOperation
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
-import com.google.android.material.snackbar.Snackbar
import logcat.logcat
-/** Get an instance of [AutofillManager]. Only available on Android Oreo and above */
+/** Get an instance of [AutofillManager] */
val Context.autofillManager: AutofillManager?
get() = getSystemService()
@@ -70,24 +70,19 @@ suspend fun FragmentActivity.commitChange(message: String): Result<Unit, Throwab
git.commit().setAll(true).setMessage(message),
)
- override fun preExecute(): Boolean {
- logcat { "Committing with message: '$message'" }
+ override suspend fun preExecute(): Boolean {
+ logcat { "Committing" }
return true
}
}
.execute()
}
-/** Show a [Snackbar] in a [FragmentActivity]. */
+/** Shows a snackbar through this activity's [prompts]; it must host a `PromptHost`. */
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.show()
- return snackbar
-}
+ length: SnackbarDuration = SnackbarDuration.Short,
+): Snack = prompts.snackbar(message, length)
/** Simplifies the common `getString(key, null) ?: defaultValue` case slightly */
fun SharedPreferences.getString(key: String): String? = getString(key, null)
diff --git a/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt b/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt
index feb2cb2d..6d497bce 100644
--- a/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt
+++ b/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt
@@ -6,8 +6,10 @@
package app.passwordstore.util.git
import android.widget.Toast
+import androidx.compose.material3.SnackbarDuration
import androidx.fragment.app.FragmentActivity
import app.passwordstore.R
+import app.passwordstore.ui.prompts.prompts
import app.passwordstore.util.coroutines.DispatcherProvider
import app.passwordstore.util.extensions.snackbar
import app.passwordstore.util.extensions.unsafeLazy
@@ -17,7 +19,6 @@ import app.passwordstore.util.git.operation.GitOperation
import app.passwordstore.util.settings.GitSettings
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.runCatching
-import com.google.android.material.snackbar.Snackbar
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
@@ -45,10 +46,10 @@ class GitCommandExecutor(
suspend fun execute(): Result<Unit, Throwable> {
val gitSettings = hiltEntryPoint.gitSettings()
val dispatcherProvider = hiltEntryPoint.dispatcherProvider()
- val snackbar =
+ val running =
activity.snackbar(
message = activity.resources.getString(R.string.git_operation_running),
- length = Snackbar.LENGTH_INDEFINITE,
+ length = SnackbarDuration.Indefinite,
)
// Count the number of uncommitted files
var nbChanges = 0
@@ -123,7 +124,7 @@ class GitCommandExecutor(
}
}
}
- .also { snackbar.dismiss() }
+ .also { activity.prompts.dismiss(running) }
}
@EntryPoint
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 4b4913d0..a08f2a35 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
@@ -4,17 +4,17 @@
*/
package app.passwordstore.util.git.operation
-import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.FragmentActivity
import app.passwordstore.R
import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.prompts.prompts
import app.passwordstore.util.extensions.unsafeLazy
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.eclipse.jgit.api.GitCommand
import org.eclipse.jgit.api.RebaseCommand
import org.eclipse.jgit.api.ResetCommand
import org.eclipse.jgit.lib.RepositoryState
-class BreakOutOfDetached(callingActivity: AppCompatActivity) : GitOperation(callingActivity) {
+class BreakOutOfDetached(callingActivity: FragmentActivity) : GitOperation(callingActivity) {
private val merging = repository.repositoryState == RepositoryState.MERGING
private val localBranch = PasswordRepository.getCurrentBranch()
@@ -50,16 +50,14 @@ class BreakOutOfDetached(callingActivity: AppCompatActivity) : GitOperation(call
}
}
- override fun preExecute() =
+ override suspend fun preExecute() =
if (!git.repository.repositoryState.isRebasing && !merging) {
- MaterialAlertDialogBuilder(callingActivity)
- .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title))
- .setMessage(
- callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded)
- )
- // Only closes the dialog: the calling activity is the app's main screen.
- .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok), null)
- .show()
+ val resources = callingActivity.resources
+ callingActivity.prompts.ask(
+ title = resources.getString(R.string.git_abort_and_push_title),
+ message = resources.getString(R.string.git_break_out_of_detached_unneeded),
+ confirm = resources.getString(R.string.dialog_ok),
+ )
false
} else {
true
diff --git a/app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt
index 0386f674..0d74835f 100644
--- a/app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt
+++ b/app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt
@@ -4,7 +4,7 @@
*/
package app.passwordstore.util.git.operation
-import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.FragmentActivity
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.GitCommand
@@ -14,7 +14,7 @@ import org.eclipse.jgit.api.GitCommand
* @param uri URL to clone the repository from
* @param callingActivity the calling activity
*/
-class CloneOperation(callingActivity: AppCompatActivity, uri: String) :
+class CloneOperation(callingActivity: FragmentActivity, uri: String) :
GitOperation(callingActivity) {
override val commands: Array<GitCommand<out Any>> =
diff --git a/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt b/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt
index e0ecb170..a0d6d16d 100644
--- a/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt
+++ b/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt
@@ -5,24 +5,17 @@
package app.passwordstore.util.git.operation
-import android.annotation.SuppressLint
import android.content.SharedPreferences
-import android.view.LayoutInflater
-import android.view.WindowManager
import androidx.annotation.StringRes
import androidx.core.content.edit
-import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.FragmentActivity
import app.passwordstore.R
import app.passwordstore.injection.prefs.GitPreferences
+import app.passwordstore.ui.prompts.Prompts
import app.passwordstore.util.coroutines.DispatcherProvider
import app.passwordstore.util.git.sshj.InteractivePasswordFinder
import app.passwordstore.util.settings.AuthMode
import app.passwordstore.util.settings.PreferenceKeys
-import com.google.android.material.checkbox.MaterialCheckBox
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.textfield.TextInputEditText
-import com.google.android.material.textfield.TextInputLayout
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
@@ -32,6 +25,7 @@ import kotlin.coroutines.resume
class CredentialFinder(
private val callingActivity: FragmentActivity,
+ private val prompts: Prompts,
private val authMode: AuthMode,
dispatcherProvider: DispatcherProvider,
) : InteractivePasswordFinder(dispatcherProvider) {
@@ -71,42 +65,18 @@ class CredentialFinder(
if (isRetry) gitOperationPrefs.edit { remove(credentialPref) }
val storedCredential = gitOperationPrefs.getString(credentialPref, null)
if (storedCredential == null) {
- val layoutInflater = LayoutInflater.from(callingActivity)
-
- @SuppressLint("InflateParams")
- val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null)
- val credentialLayout =
- dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout)
- val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential)
- editCredential.setHint(hintRes)
- val rememberCredential =
- dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential)
- rememberCredential.setText(rememberRes)
- if (isRetry) {
- credentialLayout.error = callingActivity.resources.getString(errorRes)
- // Reset error when user starts entering a password
- editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null }
- }
- MaterialAlertDialogBuilder(callingActivity)
- .run {
- setTitle(R.string.passphrase_dialog_title)
- setMessage(messageRes)
- setView(dialogView)
- setPositiveButton(R.string.dialog_ok) { _, _ ->
- val credential = editCredential.text.toString()
- if (rememberCredential.isChecked) {
- gitOperationPrefs.edit { putString(credentialPref, credential) }
- }
- cont.resume(credential)
- }
- setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(null) }
- setOnCancelListener { cont.resume(null) }
- create()
- }
- .run {
- window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- show()
+ val resources = callingActivity.resources
+ prompts.askCredential(
+ message = resources.getString(messageRes),
+ hint = resources.getString(hintRes),
+ remember = resources.getString(rememberRes),
+ error = if (isRetry) resources.getString(errorRes) else null,
+ ) { answer ->
+ if (answer?.remember == true) {
+ gitOperationPrefs.edit { putString(credentialPref, answer.text) }
}
+ cont.resume(answer?.text)
+ }
} else {
cont.resume(storedCredential)
}
diff --git a/app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt
index c1487fbf..64467f3e 100644
--- a/app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt
+++ b/app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt
@@ -5,7 +5,7 @@
package app.passwordstore.util.git.operation
-import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.FragmentActivity
import org.eclipse.jgit.api.GitCommand
import org.eclipse.jgit.internal.storage.file.FileRepository
import org.eclipse.jgit.internal.storage.file.GC
@@ -14,7 +14,7 @@ import org.eclipse.jgit.internal.storage.file.GC
* Run an aggressive garbage collection job on the repository, expiring every loose object to
* achieve the best compression.
*/
-class GcOperation(callingActivity: AppCompatActivity) : GitOperation(callingActivity) {
+class GcOperation(callingActivity: FragmentActivity) : GitOperation(callingActivity) {
override val requiresAuth: Boolean = false
override val commands: Array<GitCommand<out Any>> =
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 25d99d6d..f4ed7cd1 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
@@ -5,11 +5,12 @@
package app.passwordstore.util.git.operation
import android.widget.Toast
-import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.FragmentActivity
import app.passwordstore.R
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.prompts.Choice
+import app.passwordstore.ui.prompts.prompts
import app.passwordstore.ui.setup.SetupStart
import app.passwordstore.util.auth.BiometricAuthenticator
import app.passwordstore.util.auth.BiometricAuthenticator.Result.CanceledByUser
@@ -24,7 +25,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.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
@@ -63,8 +63,6 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
EntryPointAccessors.fromApplication<GitOperationEntryPoint>(callingActivity)
protected val repository = PasswordRepository.repository!!
protected val git = Git(repository)
- private val authActivity
- get() = callingActivity as AppCompatActivity
private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) :
CredentialsProvider() {
@@ -131,22 +129,22 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
return operationResult
}
- private fun onMissingSshKeyFile() {
- MaterialAlertDialogBuilder(callingActivity)
- .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text))
- .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title))
- .setPositiveButton(callingActivity.resources.getString(R.string.button_label_import)) { _, _
- ->
- getSshKey(false)
- }
- .setNegativeButton(
- callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)
- ) { _, _ ->
- getSshKey(true)
- }
- // 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()
+ private suspend fun onMissingSshKeyFile() {
+ val resources = callingActivity.resources
+ val choice =
+ callingActivity.prompts.ask(
+ title = resources.getString(R.string.ssh_preferences_dialog_title),
+ message = resources.getString(R.string.ssh_preferences_dialog_text),
+ confirm = resources.getString(R.string.button_label_import),
+ cancel = resources.getString(R.string.dialog_cancel),
+ other = resources.getString(R.string.ssh_preferences_dialog_generate),
+ )
+ when (choice) {
+ Choice.Confirm -> getSshKey(false)
+ Choice.Other -> getSshKey(true)
+ // Only closes the dialog: the calling activity is the app's own screen now.
+ Choice.Cancel -> {}
+ }
}
suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> {
@@ -169,7 +167,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
}
when (result) {
is Success -> {
- registerAuthProviders(SshAuthMethod.SshKey(authActivity))
+ registerAuthProviders(SshAuthMethod.SshKey(callingActivity))
}
is CanceledByUser -> {
return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
@@ -189,7 +187,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
}
}
} else {
- registerAuthProviders(SshAuthMethod.SshKey(authActivity))
+ registerAuthProviders(SshAuthMethod.SshKey(callingActivity))
}
} else {
onMissingSshKeyFile()
@@ -202,11 +200,12 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
HttpsCredentialsProvider(
CredentialFinder(
callingActivity,
+ callingActivity.prompts,
AuthMode.Password,
hiltEntryPoint.dispatcherProvider(),
)
)
- registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider)
+ registerAuthProviders(SshAuthMethod.Password(callingActivity), httpsCredentialProvider)
}
AuthMode.None -> {}
}
@@ -214,7 +213,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
}
/** Called before execution of the Git operation. Return false to cancel. */
- open fun preExecute() = true
+ open suspend fun preExecute() = true
private suspend fun postExecute() {
withContext(hiltEntryPoint.dispatcherProvider().io()) { sshSessionFactory?.close() }
diff --git a/app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt
index 1966c4ab..4738d7ee 100644
--- a/app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt
+++ b/app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt
@@ -4,10 +4,10 @@
*/
package app.passwordstore.util.git.operation
-import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.FragmentActivity
import org.eclipse.jgit.api.GitCommand
-class PullOperation(callingActivity: AppCompatActivity, rebase: Boolean) :
+class PullOperation(callingActivity: FragmentActivity, rebase: Boolean) :
GitOperation(callingActivity) {
/**
diff --git a/app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt
index e013b401..a998cda5 100644
--- a/app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt
+++ b/app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt
@@ -4,10 +4,10 @@
*/
package app.passwordstore.util.git.operation
-import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.FragmentActivity
import org.eclipse.jgit.api.GitCommand
-class PushOperation(callingActivity: AppCompatActivity) : GitOperation(callingActivity) {
+class PushOperation(callingActivity: FragmentActivity) : GitOperation(callingActivity) {
override val commands: Array<GitCommand<out Any>> =
arrayOf(git.push().setPushAll().setRemote("origin"))
diff --git a/app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt
index 7ca1282b..904c62ee 100644
--- a/app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt
+++ b/app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt
@@ -4,12 +4,12 @@
*/
package app.passwordstore.util.git.operation
-import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.FragmentActivity
import org.eclipse.jgit.api.CreateBranchCommand.SetupUpstreamMode.TRACK
import org.eclipse.jgit.api.GitCommand
import org.eclipse.jgit.api.ResetCommand
-class ResetToRemoteOperation(callingActivity: AppCompatActivity, remoteBranch: String) :
+class ResetToRemoteOperation(callingActivity: FragmentActivity, remoteBranch: String) :
GitOperation(callingActivity) {
override val commands: Array<GitCommand<out Any>> =
diff --git a/app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt
index 25078e8a..c598b829 100644
--- a/app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt
+++ b/app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt
@@ -4,10 +4,10 @@
*/
package app.passwordstore.util.git.operation
-import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.FragmentActivity
import org.eclipse.jgit.api.GitCommand
-class SyncOperation(callingActivity: AppCompatActivity, rebase: Boolean) :
+class SyncOperation(callingActivity: FragmentActivity, rebase: Boolean) :
GitOperation(callingActivity) {
override val commands: Array<GitCommand<out Any>> =
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 7c8f5ba5..e2a2c2dd 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
@@ -7,7 +7,6 @@ package app.passwordstore.util.git.sshj
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
-import android.os.Build
import android.provider.OpenableColumns
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyInfo
@@ -157,9 +156,7 @@ object SshKey {
context.sharedPrefs.edit { putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) }
private val isStrongBoxSupported by unsafeLazy {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
- context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
- else false
+ context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
}
private enum class Type(val value: String) {
@@ -198,9 +195,7 @@ object SshKey {
setKeySize(256)
setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
setDigests(KeyProperties.DIGEST_SHA256)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- setIsStrongBoxBacked(isStrongBoxSupported)
- }
+ setIsStrongBoxBacked(isStrongBoxSupported)
},
),
}
@@ -316,17 +311,7 @@ object SshKey {
val parameterSpec =
KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run {
apply(algorithm.applyToSpec)
- if (requireAuthentication) {
- setUserAuthenticationRequired(true)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- setUserAuthenticationParameters(
- 30,
- KeyProperties.AUTH_DEVICE_CREDENTIAL or KeyProperties.AUTH_BIOMETRIC_STRONG,
- )
- } else {
- @Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(30)
- }
- }
+ if (requireAuthentication) requireUserAuthentication(30)
build()
}
val keyPair =
diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt
index 51657b02..631e416f 100644
--- a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt
+++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt
@@ -5,7 +5,9 @@
package app.passwordstore.util.git.sshj
import android.util.Base64
-import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.FragmentActivity
+import app.passwordstore.ui.prompts.Prompts
+import app.passwordstore.ui.prompts.prompts
import app.passwordstore.util.coroutines.DispatcherProvider
import app.passwordstore.util.git.operation.CredentialFinder
import app.passwordstore.util.settings.AuthMode
@@ -42,10 +44,16 @@ import org.eclipse.jgit.transport.SshSessionFactory
import org.eclipse.jgit.transport.URIish
import org.eclipse.jgit.util.FS
-sealed class SshAuthMethod(val activity: AppCompatActivity) {
- class Password(activity: AppCompatActivity) : SshAuthMethod(activity)
+sealed class SshAuthMethod(val activity: FragmentActivity) {
+ /**
+ * The activity's [Prompts], taken while it is alive: the SSH thread reads it later, and asking an
+ * activity that was finished meanwhile for it would create one nothing draws.
+ */
+ val prompts = activity.prompts
+
+ class Password(activity: FragmentActivity) : SshAuthMethod(activity)
- class SshKey(activity: AppCompatActivity) : SshAuthMethod(activity)
+ class SshKey(activity: FragmentActivity) : SshAuthMethod(activity)
}
abstract class InteractivePasswordFinder(private val dispatcherProvider: DispatcherProvider) :
@@ -156,7 +164,14 @@ private class SshjSession(
ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22)
if (!ssh.isConnected) throw IOException()
val passwordAuth =
- AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password, dispatcherProvider))
+ AuthPassword(
+ CredentialFinder(
+ authMethod.activity,
+ authMethod.prompts,
+ AuthMode.Password,
+ dispatcherProvider,
+ )
+ )
when (authMethod) {
is SshAuthMethod.Password -> {
ssh.auth(username, passwordAuth)
@@ -166,7 +181,12 @@ private class SshjSession(
AuthPublickey(
SshKey.provide(
ssh,
- CredentialFinder(authMethod.activity, AuthMode.SshKey, dispatcherProvider),
+ CredentialFinder(
+ authMethod.activity,
+ authMethod.prompts,
+ AuthMode.SshKey,
+ dispatcherProvider,
+ ),
)
)
ssh.auth(username, pubkeyAuth, passwordAuth)
diff --git a/app/src/main/java/app/passwordstore/util/log/LogcatLogger.kt b/app/src/main/java/app/passwordstore/util/log/LogcatLogger.kt
index c62e60a8..5ac2e0db 100644
--- a/app/src/main/java/app/passwordstore/util/log/LogcatLogger.kt
+++ b/app/src/main/java/app/passwordstore/util/log/LogcatLogger.kt
@@ -12,7 +12,8 @@ import logcat.logcat
/** An implementation of [AbstractLogger] that forwards logging calls to [logcat]. */
class LogcatLogger(name: String) : AbstractLogger(name) {
- override fun isTraceEnabled() = true
+ // sshj's trace level dumps every SSH packet in the clear, passwords included. Never log it.
+ override fun isTraceEnabled() = false
override fun isDebugEnabled() = true
@@ -27,11 +28,11 @@ class LogcatLogger(name: String) : AbstractLogger(name) {
@Suppress("RegExpRedundantEscape")
private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
- override fun t(message: String, t: Throwable?, vararg args: Any?) {
- logcat(name, LogPriority.VERBOSE) { message.fix().format(*args) + (t?.asLog() ?: "") }
- }
+ override fun t(message: String, t: Throwable?, vararg args: Any?) = Unit
override fun d(message: String, t: Throwable?, vararg args: Any?) {
+ // JGit's FileSnapshot writes thousands of lines per sync, pushing the rest out of the buffer.
+ if (name.endsWith(".FileSnapshot")) return
logcat(name) { message.fix().format(*args) + (t?.asLog() ?: "") }
}
diff --git a/app/src/main/java/app/passwordstore/util/services/ClipboardService.kt b/app/src/main/java/app/passwordstore/util/services/ClipboardService.kt
index b4bb0438..3347da78 100644
--- a/app/src/main/java/app/passwordstore/util/services/ClipboardService.kt
+++ b/app/src/main/java/app/passwordstore/util/services/ClipboardService.kt
@@ -12,7 +12,6 @@ import android.app.PendingIntent
import android.app.Service
import android.content.ClipData
import android.content.Intent
-import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
@@ -121,11 +120,7 @@ class ClipboardService : Service() {
this,
0,
clearIntent,
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
- } else {
- PendingIntent.FLAG_UPDATE_CURRENT
- },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
)
val notification = createNotification(pendingIntent, clearTimeMs)
diff --git a/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt b/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt
index beeb4be7..a8b525d5 100644
--- a/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt
+++ b/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt
@@ -5,7 +5,6 @@
package app.passwordstore.util.services
import android.content.Context
-import android.os.Build
import android.os.CancellationSignal
import android.service.autofill.AutofillService
import android.service.autofill.FillCallback
@@ -74,16 +73,12 @@ class OreoAutofillService : AutofillService() {
return
}
if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- callback.onSuccess(
- FillResponse.Builder().run {
- disableAutofill(DISABLE_AUTOFILL_DURATION_MS)
- build()
- }
- )
- } else {
- callback.onSuccess(null)
- }
+ callback.onSuccess(
+ FillResponse.Builder().run {
+ disableAutofill(DISABLE_AUTOFILL_DURATION_MS)
+ build()
+ }
+ )
return
}
val formToFill =
diff --git a/app/src/main/java/app/passwordstore/util/settings/Constants.kt b/app/src/main/java/app/passwordstore/util/settings/Constants.kt
index a0c4b038..2d5760e4 100644
--- a/app/src/main/java/app/passwordstore/util/settings/Constants.kt
+++ b/app/src/main/java/app/passwordstore/util/settings/Constants.kt
@@ -6,5 +6,5 @@
package app.passwordstore.util.settings
object Constants {
- const val DEFAULT_DECRYPTION_TIMEOUT = 60
+ const val DEFAULT_DECRYPTION_TIMEOUT = 45
}
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 004028c4..0052f79c 100644
--- a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt
+++ b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt
@@ -125,6 +125,12 @@ constructor(
settings.edit { putBoolean(PreferenceKeys.REBASE_ON_PULL, value) }
}
+ var pushAfterCommit
+ get() = settings.getBoolean(PreferenceKeys.PUSH_AFTER_COMMIT, false)
+ set(value) {
+ settings.edit { putBoolean(PreferenceKeys.PUSH_AFTER_COMMIT, value) }
+ }
+
sealed class UpdateConnectionSettingsResult {
class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult()
diff --git a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt
index ecc7a51c..9fd2658d 100644
--- a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt
+++ b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt
@@ -86,6 +86,7 @@ object PreferenceKeys {
const val PROXY_PASSWORD = "proxy_password"
const val REBASE_ON_PULL = "rebase_on_pull"
+ const val PUSH_AFTER_COMMIT = "push_after_commit"
const val DICEWARE_SEPARATOR = "diceware_separator"
const val DICEWARE_LENGTH = "diceware_length"
diff --git a/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp b/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp
new file mode 100644
index 00000000..f31847d8
--- /dev/null
+++ b/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp
Binary files differ
diff --git a/app/src/main/res/drawable/ic_action_new_folder.xml b/app/src/main/res/drawable/ic_action_new_folder.xml
index 6db26eaf..7bccd8a6 100644
--- a/app/src/main/res/drawable/ic_action_new_folder.xml
+++ b/app/src/main/res/drawable/ic_action_new_folder.xml
@@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
- android:tint="?attr/colorControlNormal"
+ android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
diff --git a/app/src/main/res/drawable/ic_action_new_password.xml b/app/src/main/res/drawable/ic_action_new_password.xml
index 1b3df410..f204ea59 100644
--- a/app/src/main/res/drawable/ic_action_new_password.xml
+++ b/app/src/main/res/drawable/ic_action_new_password.xml
@@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
- android:tint="?attr/colorControlNormal"
+ android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
diff --git a/app/src/main/res/drawable/ic_action_secure_24dp.xml b/app/src/main/res/drawable/ic_action_secure_24dp.xml
index 9eb00ee4..5a62a473 100644
--- a/app/src/main/res/drawable/ic_action_secure_24dp.xml
+++ b/app/src/main/res/drawable/ic_action_secure_24dp.xml
@@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
- android:tint="?attr/colorControlNormal"
+ android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
diff --git a/app/src/main/res/drawable/ic_delete_24dp.xml b/app/src/main/res/drawable/ic_delete_24dp.xml
index 27865f8d..151f808e 100644
--- a/app/src/main/res/drawable/ic_delete_24dp.xml
+++ b/app/src/main/res/drawable/ic_delete_24dp.xml
@@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
- android:tint="?attr/colorControlNormal"
+ android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
diff --git a/app/src/main/res/drawable/ic_edit_24dp.xml b/app/src/main/res/drawable/ic_edit_24dp.xml
index 8bcfb841..b5f43cfd 100644
--- a/app/src/main/res/drawable/ic_edit_24dp.xml
+++ b/app/src/main/res/drawable/ic_edit_24dp.xml
@@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
- android:tint="?attr/colorControlNormal"
+ android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
index e83fc4a3..cd894791 100644
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -9,6 +9,6 @@
android:viewportWidth="432"
android:viewportHeight="432">
<path
- android:fillColor="#00375E"
+ android:fillColor="#2A2F38"
android:pathData="M0,0h432v432h-432z" />
</vector>
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
index 550a5531..04ef7ff9 100644
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -1,63 +1,18 @@
<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
+ ~ Copyright (C) 2026 Danilo M. <danix@danix.xyz>
~ SPDX-License-Identifier: GPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
- android:viewportWidth="432"
- android:viewportHeight="432">
- <path android:pathData="M128.18,195.79C126.46,181.89 137.12,169.6 150.91,169.6H281.09C294.91,169.6 305.59,181.95 303.8,195.88L292.58,283.72C291.09,295.32 281.37,304 269.86,304H161.8C150.26,304 140.51,295.27 139.07,283.63L128.18,195.79Z">
- <aapt:attr name="android:fillColor">
- <gradient
- android:endX="216"
- android:endY="304"
- android:startX="216"
- android:startY="169.6"
- android:type="linear">
- <item
- android:color="#FF2AE9DD"
- android:offset="0" />
- <item
- android:color="#FF0BAFAF"
- android:offset="1" />
- </gradient>
- </aapt:attr>
- </path>
- <path android:pathData="M245.33,169.6V160C245.33,144.09 232.2,131.2 216,131.2C199.8,131.2 186.67,144.09 186.67,160V169.6H167.11V160C167.11,133.49 189,112 216,112C243,112 264.89,133.49 264.89,160V169.6H245.33Z">
- <aapt:attr name="android:fillColor">
- <gradient
- android:centerX="216"
- android:centerY="169.6"
- android:gradientRadius="33.6"
- android:type="radial">
- <item
- android:color="#FF0086D1"
- android:offset="0.24" />
- <item
- android:color="#FF77E7FF"
- android:offset="0.75" />
- </gradient>
- </aapt:attr>
- </path>
+ android:viewportWidth="108"
+ android:viewportHeight="108">
<path
- android:fillColor="#77E7FF"
- android:pathData="M245.33,169.6h19.56v38.4h-19.56z" />
+ android:fillColor="#FF7A45"
+ android:pathData="M39,42V36a15,15 0 0 1 30,0v6h-6V36a9,9 0 0 0 -18,0v6Z" />
<path
- android:fillColor="#77E7FF"
- android:pathData="M245.33,217.6h19.56v48h-19.56z" />
- <path
- android:fillColor="#77E7FF"
- android:pathData="M239.11,205.19l13.83,13.58l-34.57,33.94l-13.83,-13.58z" />
- <path
- android:fillColor="#D1FEFF"
- android:pathData="M235.56,208a19.56,19.2 0,1 0,39.11 0a19.56,19.2 0,1 0,-39.11 0z" />
- <path
- android:fillColor="#D1FEFF"
- android:pathData="M235.56,275.2a19.56,19.2 0,1 0,39.11 0a19.56,19.2 0,1 0,-39.11 0z" />
- <path
- android:fillColor="#D1FEFF"
- android:pathData="M204.54,252.71m-13.83,-13.58a19.56,19.2 0,1 1,27.66 27.15a19.56,19.2 0,1 1,-27.66 -27.15" />
+ android:fillColor="#B8C4CF"
+ android:fillType="evenOdd"
+ android:pathData="M32,40H76V58C76,71 66,80 54,86C42,80 32,71 32,58ZM61.800,54.233L61.800,67.767A4.6,4.6 0 1 1 58.200,67.767L58.200,58.102L50.568,62.463A4.6,4.6 0 1 1 48.782,59.337L57.932,54.109A4.6,4.6 0 1 1 61.800,54.233Z" />
</vector>
diff --git a/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml b/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml
index 74af9a81..ce263547 100644
--- a/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml
+++ b/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml
@@ -1,14 +1,18 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
+<?xml version="1.0" encoding="utf-8"?><!--
+ ~ Copyright (C) 2026 Danilo M. <danix@danix.xyz>
~ SPDX-License-Identifier: GPL-3.0-only
-->
-<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt"
- android:viewportWidth="108"
- android:viewportHeight="108"
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
- android:height="108dp">
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+ <path
+ android:fillColor="#FFFFFF"
+ android:pathData="M39,42V36a15,15 0 0 1 30,0v6h-6V36a9,9 0 0 0 -18,0v6Z" />
<path
- android:pathData="M44.556 46.3997L44.556 44.5001C44.556 39.2533 48.7844 35 54.0005 35C59.2165 35 63.4449 39.2533 63.4449 44.5001L63.4444 46.4001L66.5738 46.3997C69.2445 46.3997 71.3066 48.8438 70.9624 51.6012L68.7933 68.9857C68.5068 71.2819 66.6283 73 64.4045 73L43.5297 73C41.2994 73 39.4177 71.272 39.1389 68.9681L37.0354 51.5836C36.7024 48.8318 38.7625 46.3997 41.4263 46.3997L44.556 46.3997ZM59.6671 44.5001C59.6671 41.3521 57.1301 38.8 54.0005 38.8C50.8708 38.8 48.3338 41.3521 48.3338 44.5001L48.3338 46.3997L59.6671 46.4001L59.6667 50.7076C58.5375 51.3647 57.7778 52.5928 57.7778 53.9993C57.7778 54.0436 57.7785 54.0877 57.78 54.1316L52.765 59.1762C51.5047 58.8377 50.1043 59.1658 49.1156 60.1603C47.6403 61.6443 47.6403 64.0504 49.1156 65.5344C50.5909 67.0184 52.9829 67.0184 54.4582 65.5344C55.447 64.5397 55.7731 63.1309 55.4364 61.8631L59.6667 57.6079L59.6667 64.0078C58.5375 64.6648 57.7778 65.8929 57.7778 67.2995C57.7778 69.3982 59.4691 71.0995 61.5556 71.0995C63.642 71.0995 65.3333 69.3982 65.3333 67.2995C65.3333 65.8929 64.5736 64.6648 63.4444 64.0078L63.4444 57.291C64.5736 56.6339 65.3333 55.4059 65.3333 53.9993C65.3333 52.5928 64.5736 51.3647 63.4444 50.7076L63.4444 46.4001L59.6671 46.4001L59.6671 44.5001Z"
- android:fillColor="#FFFFFF" />
+ android:fillColor="#FFFFFF"
+ android:fillType="evenOdd"
+ android:pathData="M32,40H76V58C76,71 66,80 54,86C42,80 32,71 32,58ZM61.800,54.233L61.800,67.767A4.6,4.6 0 1 1 58.200,67.767L58.200,58.102L50.568,62.463A4.6,4.6 0 1 1 48.782,59.337L57.932,54.109A4.6,4.6 0 1 1 61.800,54.233Z" />
</vector>
diff --git a/app/src/main/res/drawable/ic_move_folder_24dp.xml b/app/src/main/res/drawable/ic_move_folder_24dp.xml
index 3df87291..e12ddaee 100644
--- a/app/src/main/res/drawable/ic_move_folder_24dp.xml
+++ b/app/src/main/res/drawable/ic_move_folder_24dp.xml
@@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
- android:tint="?attr/colorControlNormal"
+ android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
diff --git a/app/src/main/res/drawable/ic_open_in_new_24dp.xml b/app/src/main/res/drawable/ic_open_in_new_24dp.xml
new file mode 100644
index 00000000..b82ebe62
--- /dev/null
+++ b/app/src/main/res/drawable/ic_open_in_new_24dp.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:autoMirrored="true"
+ android:tint="?android:attr/colorControlNormal"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M19,19H5V5h7V3H5c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2v7zM14,3v2h3.59l-9.83,9.83 1.41,1.41L19,6.41V10h2V3h-7z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_push_pin_24dp.xml b/app/src/main/res/drawable/ic_push_pin_24dp.xml
index c542d784..41f61e0a 100644
--- a/app/src/main/res/drawable/ic_push_pin_24dp.xml
+++ b/app/src/main/res/drawable/ic_push_pin_24dp.xml
@@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
- android:tint="?attr/colorControlNormal"
+ android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
diff --git a/app/src/main/res/drawable/ic_round_import_export.xml b/app/src/main/res/drawable/ic_round_import_export.xml
index 2f243b22..7ab2aa1c 100644
--- a/app/src/main/res/drawable/ic_round_import_export.xml
+++ b/app/src/main/res/drawable/ic_round_import_export.xml
@@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
- android:tint="?attr/colorControlNormal"
+ android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
diff --git a/app/src/main/res/drawable/ic_search_24dp.xml b/app/src/main/res/drawable/ic_search_24dp.xml
index bb454f5b..666883b6 100644
--- a/app/src/main/res/drawable/ic_search_24dp.xml
+++ b/app/src/main/res/drawable/ic_search_24dp.xml
@@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
- android:tint="?attr/colorControlNormal"
+ android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
diff --git a/app/src/main/res/drawable/ic_share_24dp.xml b/app/src/main/res/drawable/ic_share_24dp.xml
index 2295122c..83d28c8d 100644
--- a/app/src/main/res/drawable/ic_share_24dp.xml
+++ b/app/src/main/res/drawable/ic_share_24dp.xml
@@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
- android:tint="?attr/colorControlNormal"
+ android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
diff --git a/app/src/main/res/layout/git_credential_layout.xml b/app/src/main/res/layout/git_credential_layout.xml
deleted file mode 100644
index 8fc527e9..00000000
--- a/app/src/main/res/layout/git_credential_layout.xml
+++ /dev/null
@@ -1,37 +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="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>
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
deleted file mode 100644
index a1133420..00000000
--- a/app/src/main/res/mipmap-hdpi/ic_launcher.png
+++ /dev/null
Binary files differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100644
index b9e28b18..00000000
--- a/app/src/main/res/mipmap-mdpi/ic_launcher.png
+++ /dev/null
Binary files differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100644
index 353fe9c4..00000000
--- a/app/src/main/res/mipmap-xhdpi/ic_launcher.png
+++ /dev/null
Binary files differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100644
index 148db12b..00000000
--- a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
+++ /dev/null
Binary files differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100644
index 2c1e2b05..00000000
--- a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
+++ /dev/null
Binary files differ
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index a0a20dc7..7baf4113 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -37,7 +37,6 @@
<string name="git_commit_move_multiple_text">Verschiebe mehrere Passwörter nach %1$s.</string>
<string name="git_commit_gpg_id">Initialisiere PGP-IDs in %1$s.</string>
<!-- PGPHandler -->
- <string name="clipboard_copied_text">In die Zwischenablage kopiert</string>
<string name="file_toast_text">Bitte setzen Sie einen Dateinamen.</string>
<string name="empty_toast_text">Sie können kein leeres Passwort oder leere Extra-Angaben setzen.</string>
<!-- Git Async Task -->
@@ -232,7 +231,6 @@
<string name="pref_app_theme_title">App-Farbthema</string>
<string name="pref_app_theme_value_light">Hell</string>
<string name="pref_app_theme_value_dark">Dunkel</string>
- <string name="pref_app_theme_value_battery_saver">Durch Energiesparmodus gesetzt</string>
<string name="pref_app_theme_value_follow_system">Systemstandard</string>
<string name="connection_mode_ssh_key">SSH-Schlüssel</string>
<string name="connection_mode_basic_authentication">Passwort</string>
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index d4d4d424..57be2b30 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -36,7 +36,6 @@
<string name="git_commit_move_text">Renommer %1$sà %2$s. </string>
<string name="git_commit_move_multiple_text">Déplacement de mots de passe vers %1$s.</string>
<!-- 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="empty_toast_text">Vous ne pouvez pas utiliser un mot de passe vide ou des données supplémentaires vide</string>
<!-- Git Async Task -->
@@ -220,7 +219,6 @@
<string name="pref_app_theme_title">Thème de l\'application</string>
<string name="pref_app_theme_value_light">Clair</string>
<string name="pref_app_theme_value_dark">Sombre</string>
- <string name="pref_app_theme_value_battery_saver">Défini par l\'économiseur de batterie</string>
<string name="pref_app_theme_value_follow_system">Valeur par défaut du système</string>
<string name="connection_mode_ssh_key">Clé SSH</string>
<string name="connection_mode_basic_authentication">Mot de passe</string>
diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml
index f966230c..b7a59428 100644
--- a/app/src/main/res/values-gl/strings.xml
+++ b/app/src/main/res/values-gl/strings.xml
@@ -37,7 +37,6 @@
<string name="git_commit_move_multiple_text">Mover varios contrasinais a %1$s.</string>
<string name="git_commit_gpg_id">Iniciar cos IDs GPG en %1$s.</string>
<!-- PGPHandler -->
- <string name="clipboard_copied_text">Copiado ao portapapeis</string>
<string name="file_toast_text">Debes proporcionar un nome de ficheiro</string>
<string name="empty_toast_text">Non podes deixar baleiro o contrasinal ou o contido extra</string>
<!-- Git Async Task -->
@@ -234,7 +233,6 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid
<string name="pref_app_theme_title">Decorado da App</string>
<string name="pref_app_theme_value_light">Claro</string>
<string name="pref_app_theme_value_dark">Escuro</string>
- <string name="pref_app_theme_value_battery_saver">Establecido polo Aforrador de enerxía</string>
<string name="pref_app_theme_value_follow_system">Por omisión do sistema</string>
<string name="connection_mode_ssh_key">Chave SSH</string>
<string name="connection_mode_basic_authentication">Contrasinal</string>
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index e7c05909..fed8adec 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -36,7 +36,6 @@
<string name="git_commit_move_text">Rinomina %1$s in %2$s.</string>
<string name="git_commit_move_multiple_text">Sposta più password in %1$s.</string>
<!-- PGPHandler -->
- <string name="clipboard_copied_text">Copiato negli appunti</string>
<string name="file_toast_text">Inserisci un nome per il file</string>
<string name="empty_toast_text">Non puoi usare una password vuota o dei contenuti extra vuoti</string>
<!-- Git Async Task -->
@@ -215,7 +214,6 @@
<string name="pref_app_theme_title">Tema dell\'App</string>
<string name="pref_app_theme_value_light">Chiaro</string>
<string name="pref_app_theme_value_dark">Scuro</string>
- <string name="pref_app_theme_value_battery_saver">Impostato dal Risparmio Energetico</string>
<string name="pref_app_theme_value_follow_system">Predefinito del sistema</string>
<string name="connection_mode_ssh_key">Chiave SSH</string>
<string name="connection_mode_basic_authentication">Password</string>
@@ -290,6 +288,13 @@
<string name="pref_rebase_on_pull_title">Rebase al pull</string>
<string name="pref_rebase_on_pull_summary">Durante pull o sincronizzazione, crea un merge commit con le modifiche del remote</string>
<string name="pref_rebase_on_pull_summary_on">Durante pull o sincronizzazione, esegui il rebase dei commit che non sono sul remote</string>
+ <string name="pref_export_log_title">Esporta log</string>
+ <string name="pref_export_log_summary">Salva in un file i log di questa app. Possono mostrare nomi delle voci, siti, app e il tuo server git: leggi il file prima di condividerlo</string>
+ <string name="log_exported">Log salvato</string>
+ <string name="log_export_failed">Impossibile salvare il log</string>
+ <string name="pref_push_after_commit_title">Push dopo ogni modifica</string>
+ <string name="pref_push_after_commit_summary">Esegui il push sul repository remoto dopo aver salvato, spostato o eliminato nell\'app</string>
+ <string name="git_push_after_commit_failed">Salvato sul dispositivo, ma push non riuscito. Sincronizza per riprovare</string>
<string name="pref_disable_sync_on_pull_title">Disattiva la sincronizzazione trascinando</string>
<string name="pref_disable_sync_on_pull_summary">Trascinare verso il basso l\'elenco delle password non avvia una sincronizzazione Git</string>
<string name="pref_import_pgp_key_title">Importa chiave PGP</string>
@@ -381,4 +386,20 @@
<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>
+ <string name="about">Informazioni</string>
+ <string name="about_summary">Un client Android per pass, il gestore di password standard di Unix. Un fork mantenuto di Android Password Store.</string>
+ <string name="about_version">Versione</string>
+ <string name="about_docs">Documentazione</string>
+ <string name="about_changelog">Novità</string>
+ <string name="about_source">Codice sorgente</string>
+ <string name="about_issues">Segnala un problema</string>
+ <string name="about_support">Sostieni su Ko-fi</string>
+ <string name="about_privacy_title">Privacy</string>
+ <string name="about_privacy">Nessuna telemetria, nessuna analisi, nessun servizio Google. HardenedPass si collega solo al tuo server git, tramite il tuo proxy se ne imposti uno.</string>
+ <string name="about_certificate">Certificato di firma (SHA-256)</string>
+ <string name="about_license">Licenza</string>
+ <string name="about_license_text">GNU General Public License v3.0 only. Copyright degli autori di Android Password Store e dei contributori di HardenedPass.</string>
+ <string name="about_libraries">Licenze open source</string>
+ <string name="about_opens_browser">Si apre nel browser</string>
+ <string name="about_no_browser">Nessun browser trovato per aprire questo link</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 add3de87..f6535e05 100644
--- a/app/src/main/res/values-ko-rKR/strings.xml
+++ b/app/src/main/res/values-ko-rKR/strings.xml
@@ -35,7 +35,6 @@
<string name="git_commit_move_multiple_text">여러개의 비밀번호를 %1$s 로 옮겼습니다.</string>
<string name="git_commit_gpg_id">%1$s의 GPG ID를 초기화 합니다.</string>
<!-- PGPHandler -->
- <string name="clipboard_copied_text">클립보드로 복사됨</string>
<string name="file_toast_text">파일 이름이 필요합니다</string>
<string name="empty_toast_text">비어있는 비밀번호나 추가 정보를 사용할 수 없습니다</string>
<!-- Git Async Task -->
@@ -228,7 +227,6 @@
<string name="pref_app_theme_title">앱 테마</string>
<string name="pref_app_theme_value_light">밝은 테마</string>
<string name="pref_app_theme_value_dark">어두운 테마</string>
- <string name="pref_app_theme_value_battery_saver">배터리 절약모드 설정</string>
<string name="pref_app_theme_value_follow_system">시스템 기본값</string>
<string name="connection_mode_ssh_key">SSH 키</string>
<string name="connection_mode_basic_authentication">비밀번호</string>
diff --git a/app/src/main/res/values-night/themes_material3.xml b/app/src/main/res/values-night/themes_material3.xml
index 548afa5f..22b62088 100644
--- a/app/src/main/res/values-night/themes_material3.xml
+++ b/app/src/main/res/values-night/themes_material3.xml
@@ -5,32 +5,7 @@
<resources>
- <style name="APSThemeM3" parent="Theme.Material3.DayNight">
- <item name="colorPrimary">@color/md_theme_dark_primary</item>
- <item name="colorOnPrimary">@color/md_theme_dark_onPrimary</item>
- <item name="colorPrimaryContainer">@color/md_theme_dark_primaryContainer</item>
- <item name="colorOnPrimaryContainer">@color/md_theme_dark_onPrimaryContainer</item>
- <item name="colorSecondary">@color/md_theme_dark_secondary</item>
- <item name="colorOnSecondary">@color/md_theme_dark_onSecondary</item>
- <item name="colorSecondaryContainer">@color/md_theme_dark_secondaryContainer</item>
- <item name="colorOnSecondaryContainer">@color/md_theme_dark_onSecondaryContainer</item>
- <item name="colorTertiary">@color/md_theme_dark_tertiary</item>
- <item name="colorOnTertiary">@color/md_theme_dark_onTertiary</item>
- <item name="colorTertiaryContainer">@color/md_theme_dark_tertiaryContainer</item>
- <item name="colorOnTertiaryContainer">@color/md_theme_dark_onTertiaryContainer</item>
- <item name="colorError">@color/md_theme_dark_error</item>
- <item name="colorErrorContainer">@color/md_theme_dark_errorContainer</item>
- <item name="colorOnError">@color/md_theme_dark_onError</item>
- <item name="colorOnErrorContainer">@color/md_theme_dark_onErrorContainer</item>
- <item name="android:colorBackground">@color/md_theme_dark_background</item>
- <item name="colorOnBackground">@color/md_theme_dark_onBackground</item>
- <item name="colorSurface">@color/md_theme_dark_surface</item>
- <item name="colorOnSurface">@color/md_theme_dark_onSurface</item>
- <item name="colorSurfaceVariant">@color/md_theme_dark_surfaceVariant</item>
- <item name="colorOnSurfaceVariant">@color/md_theme_dark_onSurfaceVariant</item>
- <item name="colorOutline">@color/md_theme_dark_outline</item>
- <item name="colorOnSurfaceInverse">@color/md_theme_dark_inverseOnSurface</item>
- <item name="colorSurfaceInverse">@color/md_theme_dark_inverseSurface</item>
- <item name="colorPrimaryInverse">@color/md_theme_dark_primaryInverse</item>
+ <style name="APSThemeM3" parent="android:Theme.Material.NoActionBar">
+ <item name="android:colorBackground">@android:color/system_neutral2_900</item>
</style>
</resources>
diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml
index 4d41b2f6..d130bc70 100644
--- a/app/src/main/res/values-pl-rPL/strings.xml
+++ b/app/src/main/res/values-pl-rPL/strings.xml
@@ -35,7 +35,6 @@
<string name="git_commit_move_text">Zmień nazwę %1$s na %2$s.</string>
<string name="git_commit_move_multiple_text">Przenieś hasła do %1$s.</string>
<!-- PGPHandler -->
- <string name="clipboard_copied_text">Skopiowano do schowka</string>
<string name="file_toast_text">Podaj nazwę pliku</string>
<string name="empty_toast_text">Nie możesz użyć pustego hasła lub pustej dodatkowej zawartości</string>
<!-- Git Async Task -->
@@ -227,7 +226,6 @@
<string name="pref_app_theme_title">Motyw aplikacji</string>
<string name="pref_app_theme_value_light">Jasny</string>
<string name="pref_app_theme_value_dark">Ciemny</string>
- <string name="pref_app_theme_value_battery_saver">Ustawiany przez funkcję oszczędzania energii</string>
<string name="pref_app_theme_value_follow_system">Domyślny</string>
<string name="connection_mode_ssh_key">Klucz SSH</string>
<string name="connection_mode_basic_authentication">Hasło</string>
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 14e862c5..4ee08b50 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -37,7 +37,6 @@
<string name="git_commit_move_multiple_text">Mova múltiplas senhas para %1$s.</string>
<string name="git_commit_gpg_id">Inicializar IDs GPG em %1$s.</string>
<!-- 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="empty_toast_text">Você não pode usar uma senha vazia ou conteúdo extra vazio</string>
<!-- Git Async Task -->
@@ -232,7 +231,6 @@
<string name="pref_app_theme_title">Tema do aplicativo</string>
<string name="pref_app_theme_value_light">Claro</string>
<string name="pref_app_theme_value_dark">Escuro</string>
- <string name="pref_app_theme_value_battery_saver">Configurado pela Economia de bateria</string>
<string name="pref_app_theme_value_follow_system">Padrão do sistema</string>
<string name="connection_mode_ssh_key">Chave SSH</string>
<string name="connection_mode_basic_authentication">Senha</string>
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index ddc33aa3..a966640d 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -41,7 +41,6 @@
<string name="git_commit_move_multiple_text">Переместить несколько паролей в %1$s.</string>
<string name="git_commit_gpg_id">Инициализация GPG ID через %1$s.</string>
<!-- PGPHandler -->
- <string name="clipboard_copied_text">Скопировано в буфер обмена</string>
<string name="file_toast_text">Пожалуйста, укажите имя файла</string>
<string name="empty_toast_text">Вы не можете использовать пустой пароль или пустое поле информации</string>
<!-- Git Async Task -->
@@ -225,7 +224,6 @@
<string name="pref_app_theme_title">Тема приложения</string>
<string name="pref_app_theme_value_light">Светлая</string>
<string name="pref_app_theme_value_dark">Темная</string>
- <string name="pref_app_theme_value_battery_saver">Задается экономией батареи</string>
<string name="pref_app_theme_value_follow_system">Системная</string>
<string name="connection_mode_ssh_key">SSH ключ</string>
<string name="connection_mode_basic_authentication">Пароль</string>
diff --git a/app/src/main/res/values-v29/arrays.xml b/app/src/main/res/values-v29/arrays.xml
deleted file mode 100644
index aa78032c..00000000
--- a/app/src/main/res/values-v29/arrays.xml
+++ /dev/null
@@ -1,17 +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>
- <string-array name="app_theme_options">
- <item>@string/pref_app_theme_value_light</item>
- <item>@string/pref_app_theme_value_dark</item>
- <item>@string/pref_app_theme_value_follow_system</item>
- </string-array>
- <string-array name="app_theme_values">
- <item>light</item>
- <item>dark</item>
- <item>follow_system</item>
- </string-array>
-</resources>
diff --git a/app/src/main/res/values-v29/prefs.xml b/app/src/main/res/values-v29/prefs.xml
deleted file mode 100644
index 383b5f8b..00000000
--- a/app/src/main/res/values-v29/prefs.xml
+++ /dev/null
@@ -1,8 +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>
- <string name="app_theme_def" translatable="false">follow_system</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 3f6abeb3..dae54d05 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -36,7 +36,6 @@
<string name="git_commit_move_multiple_text">将多个密码移至%1$s</string>
<string name="git_commit_gpg_id">初始化 %1$s 中的 GPG ID</string>
<!-- PGPHandler -->
- <string name="clipboard_copied_text">复制到剪切板</string>
<string name="file_toast_text">请提供文件名</string>
<string name="empty_toast_text">您不能使用空密码或空额外内容</string>
<!-- Git Async Task -->
@@ -233,7 +232,6 @@
<string name="pref_app_theme_title">应用主题</string>
<string name="pref_app_theme_value_light">浅色</string>
<string name="pref_app_theme_value_dark">深色</string>
- <string name="pref_app_theme_value_battery_saver">由省电模式设定</string>
<string name="pref_app_theme_value_follow_system">系统默认</string>
<string name="connection_mode_ssh_key">SSH密钥</string>
<string name="connection_mode_basic_authentication">密码</string>
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index 2891987d..dde1a99f 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -33,12 +33,12 @@
<string-array name="app_theme_options">
<item>@string/pref_app_theme_value_light</item>
<item>@string/pref_app_theme_value_dark</item>
- <item>@string/pref_app_theme_value_battery_saver</item>
+ <item>@string/pref_app_theme_value_follow_system</item>
</string-array>
<string-array name="app_theme_values">
<item>light</item>
<item>dark</item>
- <item>battery_saver</item>
+ <item>follow_system</item>
</string-array>
</resources>
diff --git a/app/src/main/res/values/colors_material3.xml b/app/src/main/res/values/colors_material3.xml
index ecd34921..47ffbc6a 100644
--- a/app/src/main/res/values/colors_material3.xml
+++ b/app/src/main/res/values/colors_material3.xml
@@ -5,59 +5,5 @@
<resources>
<color name="md_theme_light_primary">#006591</color>
- <color name="md_theme_light_onPrimary">#FFFFFF</color>
- <color name="md_theme_light_primaryContainer">#C7E6FF</color>
- <color name="md_theme_light_onPrimaryContainer">#001E30</color>
- <color name="md_theme_light_secondary">#4F606E</color>
- <color name="md_theme_light_onSecondary">#FFFFFF</color>
- <color name="md_theme_light_secondaryContainer">#D3E5F5</color>
- <color name="md_theme_light_onSecondaryContainer">#0B1D29</color>
- <color name="md_theme_light_tertiary">#006494</color>
- <color name="md_theme_light_onTertiary">#FFFFFF</color>
- <color name="md_theme_light_tertiaryContainer">#C8E6FF</color>
- <color name="md_theme_light_onTertiaryContainer">#001E31</color>
- <color name="md_theme_light_error">#BA1B1B</color>
- <color name="md_theme_light_errorContainer">#FFDAD4</color>
- <color name="md_theme_light_onError">#FFFFFF</color>
- <color name="md_theme_light_onErrorContainer">#410001</color>
- <color name="md_theme_light_background">#FCFCFF</color>
- <color name="md_theme_light_onBackground">#1A1C1E</color>
- <color name="md_theme_light_surface">#FCFCFF</color>
- <color name="md_theme_light_onSurface">#1A1C1E</color>
- <color name="md_theme_light_surfaceVariant">#DDE3EA</color>
- <color name="md_theme_light_onSurfaceVariant">#41474D</color>
- <color name="md_theme_light_outline">#72787E</color>
- <color name="md_theme_light_inverseOnSurface">#F0F0F3</color>
- <color name="md_theme_light_inverseSurface">#2E3133</color>
- <color name="md_theme_light_primaryInverse">#85CEFF</color>
- <color name="md_theme_dark_primary">#85CEFF</color>
- <color name="md_theme_dark_onPrimary">#00344E</color>
- <color name="md_theme_dark_primaryContainer">#004C6F</color>
- <color name="md_theme_dark_onPrimaryContainer">#C7E6FF</color>
- <color name="md_theme_dark_secondary">#B7C9D9</color>
- <color name="md_theme_dark_onSecondary">#21323E</color>
- <color name="md_theme_dark_secondaryContainer">#384956</color>
- <color name="md_theme_dark_onSecondaryContainer">#D3E5F5</color>
- <color name="md_theme_dark_tertiary">#8ACEFF</color>
- <color name="md_theme_dark_onTertiary">#003450</color>
- <color name="md_theme_dark_tertiaryContainer">#004B70</color>
- <color name="md_theme_dark_onTertiaryContainer">#C8E6FF</color>
- <color name="md_theme_dark_error">#FFB4A9</color>
- <color name="md_theme_dark_errorContainer">#930006</color>
- <color name="md_theme_dark_onError">#680003</color>
- <color name="md_theme_dark_onErrorContainer">#FFDAD4</color>
- <color name="md_theme_dark_background">#1A1C1E</color>
- <color name="md_theme_dark_onBackground">#E1E2E5</color>
- <color name="md_theme_dark_surface">#1A1C1E</color>
- <color name="md_theme_dark_onSurface">#E1E2E5</color>
- <color name="md_theme_dark_surfaceVariant">#41474D</color>
- <color name="md_theme_dark_onSurfaceVariant">#C1C7CE</color>
- <color name="md_theme_dark_outline">#8B9197</color>
- <color name="md_theme_dark_inverseOnSurface">#1A1C1E</color>
- <color name="md_theme_dark_inverseSurface">#E1E2E5</color>
- <color name="md_theme_dark_primaryInverse">#006591</color>
- <color name="seed">#003E5B</color>
- <color name="error">#BA1B1B</color>
- <!-- Multiselect color -->
</resources>
diff --git a/app/src/main/res/values/prefs.xml b/app/src/main/res/values/prefs.xml
index a5977d9d..383b5f8b 100644
--- a/app/src/main/res/values/prefs.xml
+++ b/app/src/main/res/values/prefs.xml
@@ -4,5 +4,5 @@
-->
<resources>
- <string name="app_theme_def" translatable="false">battery_saver</string>
+ <string name="app_theme_def" translatable="false">follow_system</string>
</resources>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f1a858fd..a415e95f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -44,7 +44,6 @@
<string name="git_commit_gpg_id">Initialize GPG IDs in %1$s.</string>
<!-- PGPHandler -->
- <string name="clipboard_copied_text">Copied to clipboard</string>
<string name="file_toast_text">Please provide a file name</string>
<string name="empty_toast_text">You cannot use an empty password or empty extra content</string>
@@ -127,6 +126,13 @@
<string name="pref_rebase_on_pull_title">Rebase on pull</string>
<string name="pref_rebase_on_pull_summary">When pulling or syncing, create a merge commit with upstream changes</string>
<string name="pref_rebase_on_pull_summary_on">When pulling or syncing, rebase commits that are not present in the remote repository</string>
+ <string name="pref_export_log_title">Export log</string>
+ <string name="pref_export_log_summary">Save this app\'s logs to a file. They can show entry names, websites, apps and your git server, so read the file before sharing it</string>
+ <string name="log_exported">Log saved</string>
+ <string name="log_export_failed">Could not save the log</string>
+ <string name="pref_push_after_commit_title">Push after each change</string>
+ <string name="pref_push_after_commit_summary">Push to the remote repository after saving, moving or deleting in the app</string>
+ <string name="git_push_after_commit_failed">Saved on this device, but not pushed. Sync to try again</string>
<string name="pref_disable_sync_on_pull_title">Disable pull to sync action</string>
<string name="pref_disable_sync_on_pull_summary">Prevent pulling down on the password list from triggering a Git sync</string>
<string name="pref_import_pgp_key_title">Import PGP key</string>
@@ -263,7 +269,6 @@
<string name="pref_app_theme_title">App theme</string>
<string name="pref_app_theme_value_light">Light</string>
<string name="pref_app_theme_value_dark">Dark</string>
- <string name="pref_app_theme_value_battery_saver">Set by Battery Saver</string>
<string name="pref_app_theme_value_follow_system">System default</string>
<string name="connection_mode_ssh_key">SSH key</string>
<string name="connection_mode_basic_authentication">Password</string>
@@ -415,4 +420,20 @@
<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>
+ <string name="about">About</string>
+ <string name="about_summary">An Android client for pass, the standard Unix password manager. A maintained fork of Android Password Store.</string>
+ <string name="about_version">Version</string>
+ <string name="about_docs">Documentation</string>
+ <string name="about_changelog">What\'s new</string>
+ <string name="about_source">Source code</string>
+ <string name="about_issues">Report a problem</string>
+ <string name="about_support">Support on Ko-fi</string>
+ <string name="about_privacy_title">Privacy</string>
+ <string name="about_privacy">No telemetry, no analytics, no Google services. HardenedPass connects only to your git server, through your proxy if you set one.</string>
+ <string name="about_certificate">Signing certificate (SHA-256)</string>
+ <string name="about_license">License</string>
+ <string name="about_license_text">GNU General Public License v3.0 only. Copyright the Android Password Store authors and HardenedPass contributors.</string>
+ <string name="about_libraries">Open source licenses</string>
+ <string name="about_opens_browser">Opens in the browser</string>
+ <string name="about_no_browser">No browser found to open this link</string>
</resources>
diff --git a/app/src/main/res/values/themes_material3.xml b/app/src/main/res/values/themes_material3.xml
index 0ec74a45..7db01be0 100644
--- a/app/src/main/res/values/themes_material3.xml
+++ b/app/src/main/res/values/themes_material3.xml
@@ -6,18 +6,11 @@
<resources>
<style name="AppThemeM3" parent="APSThemeM3">
- <item name="fontFamily">@font/manrope</item>
<item name="android:windowLightStatusBar">@bool/light_status_bar</item>
<item name="android:statusBarColor">?android:colorBackground</item>
<item name="android:navigationBarColor">?android:colorBackground</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">
<item name="android:background">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">true</item>
@@ -25,48 +18,17 @@
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowContentOverlay">@null</item>
- <item name="android:windowActionBar">false</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowEnterAnimation">@android:anim/fade_in</item>
<item name="android:windowExitAnimation">@android:anim/fade_out</item>
- <item name="colorPrimaryDark">@android:color/transparent</item>
- <item name="windowNoTitle">true</item>
</style>
<style name="DialogLikeThemeM3" parent="AppThemeM3">
- <item name="android:windowActionBar">false</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">true</item>
- <!-- Needs to be set without android: prefix as per https://stackoverflow.com/a/33417456 -->
- <item name="windowNoTitle">true</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>
- <item name="colorPrimaryContainer">@color/md_theme_light_primaryContainer</item>
- <item name="colorOnPrimaryContainer">@color/md_theme_light_onPrimaryContainer</item>
- <item name="colorSecondary">@color/md_theme_light_secondary</item>
- <item name="colorOnSecondary">@color/md_theme_light_onSecondary</item>
- <item name="colorSecondaryContainer">@color/md_theme_light_secondaryContainer</item>
- <item name="colorOnSecondaryContainer">@color/md_theme_light_onSecondaryContainer</item>
- <item name="colorTertiary">@color/md_theme_light_tertiary</item>
- <item name="colorOnTertiary">@color/md_theme_light_onTertiary</item>
- <item name="colorTertiaryContainer">@color/md_theme_light_tertiaryContainer</item>
- <item name="colorOnTertiaryContainer">@color/md_theme_light_onTertiaryContainer</item>
- <item name="colorError">@color/md_theme_light_error</item>
- <item name="colorErrorContainer">@color/md_theme_light_errorContainer</item>
- <item name="colorOnError">@color/md_theme_light_onError</item>
- <item name="colorOnErrorContainer">@color/md_theme_light_onErrorContainer</item>
- <item name="android:colorBackground">@color/md_theme_light_background</item>
- <item name="colorOnBackground">@color/md_theme_light_onBackground</item>
- <item name="colorSurface">@color/md_theme_light_surface</item>
- <item name="colorOnSurface">@color/md_theme_light_onSurface</item>
- <item name="colorSurfaceVariant">@color/md_theme_light_surfaceVariant</item>
- <item name="colorOnSurfaceVariant">@color/md_theme_light_onSurfaceVariant</item>
- <item name="colorOutline">@color/md_theme_light_outline</item>
- <item name="colorOnSurfaceInverse">@color/md_theme_light_inverseOnSurface</item>
- <item name="colorSurfaceInverse">@color/md_theme_light_inverseSurface</item>
- <item name="colorPrimaryInverse">@color/md_theme_light_primaryInverse</item>
+ <style name="APSThemeM3" parent="android:Theme.Material.Light.NoActionBar">
+ <item name="android:colorBackground">@android:color/system_neutral2_10</item>
</style>
</resources>
diff --git a/app/src/test/java/app/passwordstore/ui/prompts/PromptsTest.kt b/app/src/test/java/app/passwordstore/ui/prompts/PromptsTest.kt
new file mode 100644
index 00000000..12d1671e
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/prompts/PromptsTest.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.prompts
+
+import androidx.compose.material3.SnackbarDuration
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertSame
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.async
+import kotlinx.coroutines.runBlocking
+
+class PromptsTest {
+
+ private fun Prompts.question() = current as Question
+
+ @Test
+ fun answerResumesAsk() = runBlocking {
+ val prompts = Prompts()
+ val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") }
+ prompts.answer(prompts.question(), Choice.Other)
+ assertEquals(Choice.Other, asked.await())
+ assertNull(prompts.current)
+ }
+
+ @Test
+ fun dismissingAnswersCancel() = runBlocking {
+ val prompts = Prompts()
+ val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") }
+ prompts.cancel(prompts.question())
+ assertEquals(Choice.Cancel, asked.await())
+ }
+
+ @Test
+ fun secondAnswerIsIgnored() = runBlocking {
+ val prompts = Prompts()
+ val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") }
+ val question = prompts.question()
+ prompts.answer(question, Choice.Confirm)
+ prompts.answer(question, Choice.Cancel)
+ assertEquals(Choice.Confirm, asked.await())
+ }
+
+ @Test
+ fun cancelledAskRemovesItsDialog() = runBlocking {
+ val prompts = Prompts()
+ val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") }
+ asked.cancel()
+ assertNull(prompts.current)
+ }
+
+ @Test
+ fun promptsShowOneAtATimeInOrder() =
+ runBlocking<Unit> {
+ val prompts = Prompts()
+ val first = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("1", "m", "ok") }
+ val second = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("2", "m", "ok") }
+ assertEquals("1", prompts.question().title)
+ prompts.answer(prompts.question(), Choice.Confirm)
+ assertEquals("2", prompts.question().title)
+ prompts.answer(prompts.question(), Choice.Confirm)
+ first.await()
+ second.await()
+ }
+
+ @Test
+ fun cancelAllAnswersAPendingCredentialWithNull() {
+ val prompts = Prompts()
+ val answers = mutableListOf<CredentialAnswer?>(CredentialAnswer("unset", false))
+ prompts.askCredential("m", "h", "r", null) { answers[0] = it }
+ prompts.cancelAll()
+ assertNull(answers[0])
+ assertNull(prompts.current)
+ }
+
+ @Test
+ fun askCredentialAfterCloseAnswersNullAtOnce() {
+ val prompts = Prompts()
+ prompts.close()
+ val answers = mutableListOf<CredentialAnswer?>(CredentialAnswer("unset", false))
+ prompts.askCredential("m", "h", "r", null) { answers[0] = it }
+ assertNull(answers[0])
+ assertNull(prompts.current)
+ }
+
+ @Test
+ fun newSnackbarReplacesTheCurrentOne() {
+ val prompts = Prompts()
+ val first = prompts.snackbar("one", SnackbarDuration.Indefinite)
+ val second = prompts.snackbar("two", SnackbarDuration.Short)
+ assertSame(second, prompts.snack)
+ // The finished operation dismisses its own snackbar only.
+ prompts.dismiss(first)
+ assertSame(second, prompts.snack)
+ prompts.dismiss(second)
+ assertNull(prompts.snack)
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/ui/settings/AboutTest.kt b/app/src/test/java/app/passwordstore/ui/settings/AboutTest.kt
new file mode 100644
index 00000000..e692c229
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/settings/AboutTest.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class AboutTest {
+
+ @Test
+ fun versionLineWithoutCommit() {
+ assertEquals("0.3.0 (300)", versionLine("0.3.0", 300, ""))
+ }
+
+ @Test
+ fun versionLineWithCommit() {
+ assertEquals("0.3.0-debug (300) · daa7ee01", versionLine("0.3.0-debug", 300, "daa7ee01"))
+ }
+
+ @Test
+ fun changelogUrlPointsAtTheReleaseTag() {
+ assertEquals(
+ "https://github.com/danixland/HardenedPass/blob/v0.3.0/CHANGELOG.md",
+ changelogUrl("0.3.0-debug"),
+ )
+ }
+
+ @Test
+ fun fingerprintIsColonSeparatedUppercaseSha256() {
+ assertEquals(
+ "BA:78:16:BF:8F:01:CF:EA:41:41:40:DE:5D:AE:22:23:" +
+ "B0:03:61:A3:96:17:7A:9C:B4:10:FF:61:F2:00:15:AD",
+ certificateFingerprint("abc".encodeToByteArray()),
+ )
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt
index 6b30c248..111e7f51 100644
--- a/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt
+++ b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt
@@ -9,7 +9,9 @@ import app.passwordstore.util.extensions.getString
import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNull
+import kotlin.test.assertTrue
class GitSettingsTest {
@@ -24,4 +26,19 @@ class GitSettingsTest {
assertEquals("proxy-user", proxy.getString(PreferenceKeys.PROXY_USERNAME))
assertNull(settings.getString(PreferenceKeys.PROXY_USERNAME))
}
+
+ @Test
+ fun pushAfterCommitIsOptIn() {
+ val settings = SPMockBuilder().createSharedPreferences()
+ val gitSettings =
+ GitSettings(
+ settings,
+ SPMockBuilder().createSharedPreferences(),
+ SPMockBuilder().createSharedPreferences(),
+ "/nonexistent",
+ )
+ assertFalse(gitSettings.pushAfterCommit)
+ gitSettings.pushAfterCommit = true
+ assertTrue(settings.getBoolean(PreferenceKeys.PUSH_AFTER_COMMIT, false))
+ }
}
diff --git a/app/src/test/java/app/passwordstore/util/viewmodel/StrictDomainRegexTest.kt b/app/src/test/java/app/passwordstore/util/viewmodel/StrictDomainRegexTest.kt
index 0ddb72f8..59c1de05 100644
--- a/app/src/test/java/app/passwordstore/util/viewmodel/StrictDomainRegexTest.kt
+++ b/app/src/test/java/app/passwordstore/util/viewmodel/StrictDomainRegexTest.kt
@@ -10,13 +10,11 @@ import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
private infix fun String.matchedForDomain(domain: String) =
SearchableRepositoryViewModel.generateStrictDomainRegex(domain)?.containsMatchIn(this) == true
@RunWith(RobolectricTestRunner::class)
-@Config(sdk = [23])
class StrictDomainRegexTest {
@Test
diff --git a/app/version.properties b/app/version.properties
index 77a7d569..5b4f8e0b 100644
--- a/app/version.properties
+++ b/app/version.properties
@@ -1,5 +1,5 @@
#
# This file was automatically generated by 'versioning-plugin'. DO NOT EDIT MANUALLY.
#
-versioning-plugin.versionCode=200
-versioning-plugin.versionName=0.2.0
+versioning-plugin.versionCode=303
+versioning-plugin.versionName=0.3.3
diff --git a/autofill-parser/api.txt b/autofill-parser/api.txt
index 89cfaa3c..09b2974f 100644
--- a/autofill-parser/api.txt
+++ b/autofill-parser/api.txt
@@ -12,7 +12,7 @@ package com.github.androidpasswordstore.autofillparser {
method public static String computeCertificatesHash(android.content.Context context, String appPackage);
}
- @RequiresApi(android.os.Build.VERSION_CODES.O) public abstract sealed nonexhaustive class AutofillScenario<T> {
+ public abstract sealed nonexhaustive class AutofillScenario<T> {
method @InaccessibleFromKotlin public final java.util.List<T> getFieldsToSave();
method @InaccessibleFromKotlin public final boolean getHasFieldsToSave();
method @InaccessibleFromKotlin public final boolean getHasPasswordFieldsToSave();
@@ -35,13 +35,13 @@ package com.github.androidpasswordstore.autofillparser {
}
public final class AutofillScenarioKt {
- method @KotlinOnly @RequiresApi(android.os.Build.VERSION_CODES.O) public static void fillWith(android.service.autofill.Dataset.Builder, com.github.androidpasswordstore.autofillparser.AutofillScenario<android.view.autofill.AutofillId> scenario, com.github.androidpasswordstore.autofillparser.AutofillAction action, com.github.androidpasswordstore.autofillparser.Credentials? credentials);
- method @InaccessibleFromKotlin @RequiresApi(android.os.Build.VERSION_CODES.O) public static void fillWithAutofillId(android.service.autofill.Dataset.Builder, com.github.androidpasswordstore.autofillparser.AutofillScenario<android.view.autofill.AutofillId> scenario, com.github.androidpasswordstore.autofillparser.AutofillAction action, com.github.androidpasswordstore.autofillparser.Credentials? credentials);
- method @InaccessibleFromKotlin @RequiresApi(android.os.Build.VERSION_CODES.O) public static String? getPasswordValue(com.github.androidpasswordstore.autofillparser.AutofillScenario<? extends android.app.assist.AssistStructure.ViewNode>);
- method @InaccessibleFromKotlin @RequiresApi(android.os.Build.VERSION_CODES.O) public static String? getUsernameValue(com.github.androidpasswordstore.autofillparser.AutofillScenario<? extends android.app.assist.AssistStructure.ViewNode>);
- method @RequiresApi(android.os.Build.VERSION_CODES.O) public static com.github.androidpasswordstore.autofillparser.AutofillScenario<android.app.assist.AssistStructure.ViewNode>? recoverNodes(com.github.androidpasswordstore.autofillparser.AutofillScenario<android.view.autofill.AutofillId>, android.app.assist.AssistStructure structure);
- property @RequiresApi(android.os.Build.VERSION_CODES.O) public static String? com.github.androidpasswordstore.autofillparser.AutofillScenario<android.app.assist.AssistStructure.ViewNode>.passwordValue;
- property @RequiresApi(android.os.Build.VERSION_CODES.O) public static String? com.github.androidpasswordstore.autofillparser.AutofillScenario<android.app.assist.AssistStructure.ViewNode>.usernameValue;
+ method @KotlinOnly public static void fillWith(android.service.autofill.Dataset.Builder, com.github.androidpasswordstore.autofillparser.AutofillScenario<android.view.autofill.AutofillId> scenario, com.github.androidpasswordstore.autofillparser.AutofillAction action, com.github.androidpasswordstore.autofillparser.Credentials? credentials);
+ method @InaccessibleFromKotlin public static void fillWithAutofillId(android.service.autofill.Dataset.Builder, com.github.androidpasswordstore.autofillparser.AutofillScenario<android.view.autofill.AutofillId> scenario, com.github.androidpasswordstore.autofillparser.AutofillAction action, com.github.androidpasswordstore.autofillparser.Credentials? credentials);
+ method @InaccessibleFromKotlin public static String? getPasswordValue(com.github.androidpasswordstore.autofillparser.AutofillScenario<? extends android.app.assist.AssistStructure.ViewNode>);
+ method @InaccessibleFromKotlin public static String? getUsernameValue(com.github.androidpasswordstore.autofillparser.AutofillScenario<? extends android.app.assist.AssistStructure.ViewNode>);
+ method public static com.github.androidpasswordstore.autofillparser.AutofillScenario<android.app.assist.AssistStructure.ViewNode>? recoverNodes(com.github.androidpasswordstore.autofillparser.AutofillScenario<android.view.autofill.AutofillId>, android.app.assist.AssistStructure structure);
+ property public static String? com.github.androidpasswordstore.autofillparser.AutofillScenario<android.app.assist.AssistStructure.ViewNode>.passwordValue;
+ property public static String? com.github.androidpasswordstore.autofillparser.AutofillScenario<android.app.assist.AssistStructure.ViewNode>.usernameValue;
}
public enum BrowserAutofillSupportLevel {
@@ -68,10 +68,10 @@ package com.github.androidpasswordstore.autofillparser {
}
public final class FeatureAndTrustDetectionKt {
- method @RequiresApi(android.os.Build.VERSION_CODES.O) public static java.util.List<kotlin.Pair<java.lang.String,com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel>> getInstalledBrowsersWithAutofillSupportLevel(android.content.Context context);
+ method public static java.util.List<kotlin.Pair<java.lang.String,com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel>> getInstalledBrowsersWithAutofillSupportLevel(android.content.Context context);
}
- @RequiresApi(android.os.Build.VERSION_CODES.O) public final class FillableForm {
+ public final class FillableForm {
method @InaccessibleFromKotlin public com.github.androidpasswordstore.autofillparser.FormOrigin getFormOrigin();
method @InaccessibleFromKotlin public java.util.List<android.view.autofill.AutofillId> getIgnoredIds();
method @InaccessibleFromKotlin public Integer? getSaveFlags();
@@ -88,7 +88,7 @@ package com.github.androidpasswordstore.autofillparser {
method public com.github.androidpasswordstore.autofillparser.FillableForm? parseAssistStructure(android.content.Context context, android.app.assist.AssistStructure structure, boolean isManualRequest, optional kotlin.sequences.Sequence<java.lang.String> customSuffixes);
}
- @RequiresApi(android.os.Build.VERSION_CODES.O) public final class FixedSaveCallback {
+ public final class FixedSaveCallback {
ctor public FixedSaveCallback(android.content.Context context, android.service.autofill.SaveCallback callback);
method public void onFailure(CharSequence message);
method public void onSuccess(android.content.IntentSender intentSender);
diff --git a/autofill-parser/build.gradle.kts b/autofill-parser/build.gradle.kts
index 39157b37..30a952b9 100644
--- a/autofill-parser/build.gradle.kts
+++ b/autofill-parser/build.gradle.kts
@@ -11,10 +11,7 @@ plugins {
}
android {
- defaultConfig {
- minSdk = 23
- consumerProguardFiles("consumer-rules.pro")
- }
+ defaultConfig { consumerProguardFiles("consumer-rules.pro") }
sourceSets { getByName("test") { resources.directories.add("src/main/assets") } }
namespace = "com.github.androidpasswordstore.autofillparser"
}
diff --git a/autofill-parser/src/main/assets/publicsuffixes b/autofill-parser/src/main/assets/publicsuffixes
index 8daa1e58..84cf47df 100644
--- a/autofill-parser/src/main/assets/publicsuffixes
+++ b/autofill-parser/src/main/assets/publicsuffixes
Binary files differ
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt
index 3dc74f33..eba3b2bc 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt
@@ -11,7 +11,6 @@ import android.content.pm.PackageManager.ApplicationInfoFlags
import android.os.Build
import android.os.Bundle
import android.view.autofill.AutofillId
-import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import logcat.logcat
@@ -69,7 +68,6 @@ public sealed class FormOrigin(public open val identifier: String) {
/**
* Manages the detection of fields to fill in an [AssistStructure] and determines the [FormOrigin].
*/
-@RequiresApi(Build.VERSION_CODES.O)
private class AutofillFormParser(
context: Context,
structure: AssistStructure,
@@ -200,7 +198,6 @@ public data class Credentials(val username: String?, val password: String?, val
* Represents a collection of fields in a specific app that can be filled or saved. This is the
* entry point to all fill and save features.
*/
-@RequiresApi(Build.VERSION_CODES.O)
public class FillableForm
private constructor(
public val formOrigin: FormOrigin,
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt
index bac5d7f6..b177b5c0 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt
@@ -5,6 +5,7 @@
package com.github.androidpasswordstore.autofillparser
import android.annotation.SuppressLint
+import android.app.ActivityOptions
import android.app.assist.AssistStructure
import android.content.Context
import android.content.IntentSender
@@ -14,7 +15,6 @@ import android.service.autofill.SaveCallback
import android.util.Base64
import android.view.autofill.AutofillId
import android.widget.Toast
-import androidx.annotation.RequiresApi
import java.security.MessageDigest
import logcat.LogPriority.ERROR
import logcat.logcat
@@ -55,24 +55,22 @@ public fun computeCertificatesHash(context: Context, appPackage: String): String
.signatures
.orEmpty()
val stableHashOld = stableHash(signaturesOld.map { it.toByteArray() })
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- val info =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- context.packageManager.getPackageInfo(
- appPackage,
- PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES.toLong()),
- )
- } else {
- context.packageManager.getPackageInfo(appPackage, PackageManager.GET_SIGNING_CERTIFICATES)
- }
- val signaturesNew =
- info.signingInfo?.signingCertificateHistory ?: info.signingInfo?.apkContentsSigners.orEmpty()
- val stableHashNew = stableHash(signaturesNew.map { it.toByteArray() })
- if (stableHashNew != stableHashOld)
- logcat("CertificatesHash", ERROR) {
- "Mismatch between old and new hash: $stableHashNew != $stableHashOld"
- }
- }
+ val info =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ context.packageManager.getPackageInfo(
+ appPackage,
+ PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES.toLong()),
+ )
+ } else {
+ context.packageManager.getPackageInfo(appPackage, PackageManager.GET_SIGNING_CERTIFICATES)
+ }
+ val signaturesNew =
+ info.signingInfo?.signingCertificateHistory ?: info.signingInfo?.apkContentsSigners.orEmpty()
+ val stableHashNew = stableHash(signaturesNew.map { it.toByteArray() })
+ if (stableHashNew != stableHashOld)
+ logcat("CertificatesHash", ERROR) {
+ "Mismatch between old and new hash: $stableHashNew != $stableHashOld"
+ }
return stableHashOld
}
@@ -81,15 +79,12 @@ public fun computeCertificatesHash(context: Context, appPackage: String): String
* its `webDomain` and `webScheme`, if available.
*/
internal val AssistStructure.ViewNode.webOrigin: String?
- @RequiresApi(Build.VERSION_CODES.O)
get() =
webDomain?.let { domain ->
- val scheme =
- (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) webScheme else null) ?: "https"
+ val scheme = webScheme ?: "https"
"$scheme://$domain"
}
-@RequiresApi(Build.VERSION_CODES.O)
public class FixedSaveCallback(private val context: Context, private val callback: SaveCallback) {
private val applicationContext = context.applicationContext
@@ -105,13 +100,21 @@ public class FixedSaveCallback(private val context: Context, private val callbac
}
public fun onSuccess(intentSender: IntentSender) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- callback.onSuccess(intentSender)
- } else {
- callback.onSuccess()
- // On SDKs < 28, we cannot advise the Autofill framework to launch the save intent in
- // the context of the app that triggered the save request. Hence, we launch it here.
- applicationContext.startIntentSender(intentSender, null, 0, 0, 0)
+ when {
+ // From Android 14 the framework sends this intent from the app being filled, which does not
+ // opt in to background activity starts when it targets SDK 34 or later: the save window
+ // opens hidden behind it. This service is bound by the system, so it may start it itself.
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> {
+ val options =
+ ActivityOptions.makeBasic()
+ .setPendingIntentBackgroundActivityStartMode(
+ ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
+ )
+ .toBundle()
+ applicationContext.startIntentSender(intentSender, null, 0, 0, 0, options)
+ callback.onSuccess()
+ }
+ else -> callback.onSuccess(intentSender)
}
}
}
@@ -132,7 +135,6 @@ private fun visitViewNode(
}
}
-@RequiresApi(Build.VERSION_CODES.O)
internal fun AssistStructure.findNodeByAutofillId(
autofillId: AutofillId
): AssistStructure.ViewNode? {
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt
index 8a5b7fe3..15faabde 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt
@@ -11,7 +11,6 @@ import android.service.autofill.Dataset
import android.service.autofill.Field
import android.view.autofill.AutofillId
import android.view.autofill.AutofillValue
-import androidx.annotation.RequiresApi
import androidx.core.os.BundleCompat
import logcat.LogPriority.ERROR
import logcat.asLog
@@ -30,7 +29,6 @@ public enum class AutofillAction {
* [FormField], [AssistStructure.ViewNode] or [AutofillId], depending on how much metadata about the
* field is needed and available in the particular situation.
*/
-@RequiresApi(Build.VERSION_CODES.O)
public sealed class AutofillScenario<out T : Any> {
public companion object {
@@ -187,7 +185,6 @@ public sealed class AutofillScenario<out T : Any> {
get() = username != null
}
-@RequiresApi(Build.VERSION_CODES.O)
internal data class ClassifiedAutofillScenario<T : Any>(
override val username: T?,
override val fillUsername: Boolean,
@@ -212,7 +209,6 @@ internal data class ClassifiedAutofillScenario<T : Any>(
get() = newPassword.ifEmpty { currentPassword }
}
-@RequiresApi(Build.VERSION_CODES.O)
internal data class GenericAutofillScenario<T : Any>(
override val username: T?,
override val fillUsername: Boolean,
@@ -236,7 +232,6 @@ internal data class GenericAutofillScenario<T : Any>(
get() = genericPassword
}
-@RequiresApi(Build.VERSION_CODES.O)
internal fun AutofillScenario<FormField>.passesOriginCheck(singleOriginMode: Boolean): Boolean {
return if (singleOriginMode) {
// In single origin mode, only the browsers URL bar (which is never filled) should have
@@ -249,7 +244,6 @@ internal fun AutofillScenario<FormField>.passesOriginCheck(singleOriginMode: Boo
}
}
-@RequiresApi(Build.VERSION_CODES.O)
@JvmName("fillWithAutofillId")
public fun Dataset.Builder.fillWith(
scenario: AutofillScenario<AutofillId>,
@@ -272,7 +266,6 @@ public fun Dataset.Builder.fillWith(
}
}
-@RequiresApi(Build.VERSION_CODES.O)
internal inline fun <T : Any, S : Any> AutofillScenario<T>.map(
transform: (T) -> S
): AutofillScenario<S> {
@@ -292,7 +285,6 @@ internal inline fun <T : Any, S : Any> AutofillScenario<T>.map(
return builder.build()
}
-@RequiresApi(Build.VERSION_CODES.O)
@JvmName("toBundleAutofillId")
internal fun AutofillScenario<AutofillId>.toBundle(): Bundle =
when (this) {
@@ -321,7 +313,6 @@ internal fun AutofillScenario<AutofillId>.toBundle(): Bundle =
}
}
-@RequiresApi(Build.VERSION_CODES.O)
public fun AutofillScenario<AutofillId>.recoverNodes(
structure: AssistStructure
): AutofillScenario<AssistStructure.ViewNode>? {
@@ -329,13 +320,11 @@ public fun AutofillScenario<AutofillId>.recoverNodes(
}
public val AutofillScenario<AssistStructure.ViewNode>.usernameValue: String?
- @RequiresApi(Build.VERSION_CODES.O)
get() {
val value = username?.autofillValue ?: return null
return if (value.isText) value.textValue.toString() else null
}
public val AutofillScenario<AssistStructure.ViewNode>.passwordValue: String?
- @RequiresApi(Build.VERSION_CODES.O)
get() {
val distinctValues =
passwordFieldsToSave
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt
index bc227bad..5e1cebad 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt
@@ -4,8 +4,6 @@
*/
package com.github.androidpasswordstore.autofillparser
-import android.os.Build
-import androidx.annotation.RequiresApi
import com.github.androidpasswordstore.autofillparser.CertaintyLevel.Certain
import com.github.androidpasswordstore.autofillparser.CertaintyLevel.Likely
@@ -22,7 +20,6 @@ private inline fun <T> Pair<T, T>.none(predicate: T.() -> Boolean) =
* The strategy used to detect [AutofillScenario] s; expressed using the DSL implemented in
* [AutofillDsl].
*/
-@RequiresApi(Build.VERSION_CODES.O)
internal val autofillStrategy = strategy {
// Match two new password fields, an optional current password field right below or above, and
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt
index 673c7466..a9b01f25 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt
@@ -4,14 +4,11 @@
*/
package com.github.androidpasswordstore.autofillparser
-import android.os.Build
-import androidx.annotation.RequiresApi
import logcat.LogPriority.WARN
import logcat.logcat
@DslMarker internal annotation class AutofillDsl
-@RequiresApi(Build.VERSION_CODES.O)
internal interface FieldMatcher {
fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>?
@@ -72,7 +69,6 @@ internal interface FieldMatcher {
}
}
-@RequiresApi(Build.VERSION_CODES.O)
internal class SingleFieldMatcher(
private val take: (FormField, List<FormField>) -> Boolean,
private val tieBreakers: List<(FormField, List<FormField>) -> Boolean>,
@@ -136,7 +132,6 @@ internal class SingleFieldMatcher(
}
}
-@RequiresApi(Build.VERSION_CODES.O)
private class PairOfFieldsMatcher(
private val take: (Pair<FormField, FormField>, List<FormField>) -> Boolean,
private val tieBreakers: List<(Pair<FormField, FormField>, List<FormField>) -> Boolean>,
@@ -174,7 +169,6 @@ private class PairOfFieldsMatcher(
}
}
-@RequiresApi(Build.VERSION_CODES.O)
internal class AutofillRule
private constructor(
private val matchers: List<AutofillRuleMatcher>,
@@ -384,7 +378,6 @@ private constructor(
}
}
-@RequiresApi(Build.VERSION_CODES.O)
internal class AutofillStrategy private constructor(private val rules: List<AutofillRule>) {
@AutofillDsl
@@ -436,6 +429,5 @@ internal class AutofillStrategy private constructor(private val rules: List<Auto
}
}
-@RequiresApi(Build.VERSION_CODES.O)
internal fun strategy(block: AutofillStrategy.Builder.() -> Unit) =
AutofillStrategy.Builder().apply(block).build()
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt
index d531dd49..2db02f51 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt
@@ -11,7 +11,6 @@ import android.content.pm.PackageManager.ResolveInfoFlags
import android.os.Build
import android.provider.Settings
import android.service.autofill.SaveInfo
-import androidx.annotation.RequiresApi
import androidx.core.net.toUri
/**
@@ -143,7 +142,6 @@ private fun getBrowserMultiOriginMethod(appPackage: String): BrowserMultiOriginM
* Some browsers may not issue save requests automatically and thus need
* `FLAG_SAVE_ON_ALL_VIEW_INVISIBLE` to be set.
*/
-@RequiresApi(Build.VERSION_CODES.O)
private val BROWSER_SAVE_FLAG =
mapOf(
"com.duckduckgo.mobile.android" to 0,
@@ -152,12 +150,13 @@ private val BROWSER_SAVE_FLAG =
"org.mozilla.fenix" to 0,
"org.mozilla.fenix.nightly" to 0,
"org.mozilla.fennec_aurora" to 0,
+ "org.mozilla.firefox" to 0,
+ "org.mozilla.firefox_beta" to 0,
"com.opera.mini.native" to 0,
"com.opera.mini.native.beta" to 0,
"com.opera.touch" to 0,
)
-@RequiresApi(Build.VERSION_CODES.O)
private val BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY =
mapOf(
"com.android.chrome" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE,
@@ -178,7 +177,6 @@ private fun isNoAccessibilityServiceEnabled(context: Context): Boolean {
.isNullOrEmpty()
}
-@RequiresApi(Build.VERSION_CODES.O)
private fun getBrowserSaveFlag(context: Context, appPackage: String): Int? =
BROWSER_SAVE_FLAG[appPackage]
?: BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY[appPackage]?.takeIf {
@@ -190,7 +188,6 @@ internal data class BrowserAutofillSupportInfo(
val saveFlags: Int?,
)
-@RequiresApi(Build.VERSION_CODES.O)
internal fun getBrowserAutofillSupportInfoIfTrusted(
context: Context,
appPackage: String,
@@ -213,7 +210,6 @@ public enum class BrowserAutofillSupportLevel {
GeneralFillAndSave,
}
-@RequiresApi(Build.VERSION_CODES.O)
private fun getBrowserAutofillSupportLevel(
context: Context,
appPackage: String,
@@ -228,19 +224,9 @@ private fun getBrowserAutofillSupportLevel(
BrowserAutofillSupportLevel.PasswordFill
browserInfo.saveFlags == null -> BrowserAutofillSupportLevel.GeneralFill
else -> BrowserAutofillSupportLevel.GeneralFillAndSave
- }.takeUnless { supportLevel ->
- // On Android Oreo, only browsers with native Autofill support can be used with Password
- // Store
- // (compatibility mode is only available on Android Pie and higher). Since all known
- // browsers
- // with native Autofill support offer full save support as well, we reuse the list of those
- // browsers here.
- supportLevel != BrowserAutofillSupportLevel.GeneralFillAndSave &&
- Build.VERSION.SDK_INT < Build.VERSION_CODES.P
- } ?: BrowserAutofillSupportLevel.None
+ }
}
-@RequiresApi(Build.VERSION_CODES.O)
public fun getInstalledBrowsersWithAutofillSupportLevel(
context: Context
): List<Pair<String, BrowserAutofillSupportLevel>> {
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt
index 983a8f0a..bcd1081d 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt
@@ -5,11 +5,9 @@
package com.github.androidpasswordstore.autofillparser
import android.app.assist.AssistStructure
-import android.os.Build
import android.text.InputType
import android.view.View
import android.view.autofill.AutofillId
-import androidx.annotation.RequiresApi
import androidx.autofill.HintConstants
import java.util.Locale
@@ -24,7 +22,6 @@ internal enum class CertaintyLevel {
* Represents a single potentially fillable or saveable field together with all meta data extracted
* from its [AssistStructure.ViewNode].
*/
-@RequiresApi(Build.VERSION_CODES.O)
internal class FormField(
node: AssistStructure.ViewNode,
private val index: Int,
@@ -91,7 +88,6 @@ internal class FormField(
.toSet()
.toList()
- @RequiresApi(Build.VERSION_CODES.O)
private fun isSupportedHint(hint: String?) = hint in HINTS_FILLABLE
private val EXCLUDED_TERMS =
@@ -145,7 +141,7 @@ internal class FormField(
?.filter { it.first != null && it.second != null }
?.associate { Pair(it.first.lowercase(Locale.US), it.second.lowercase(Locale.US)) }
?: emptyMap()
- private val htmlAttributesDebug = htmlAttributes.entries.joinToString { "${it.key}=${it.value}" }
+ private val htmlAttributesDebug = htmlAttributesForLog(htmlAttributes)
private val htmlInputType = htmlAttributes["type"]
private val htmlName = htmlAttributes["name"] ?: ""
private val htmlMaxLength = htmlAttributes["maxlength"]?.toIntOrNull()
@@ -296,3 +292,10 @@ internal class FormField(
return autofillId.hashCode()
}
}
+
+/** The [attributes] that describe a field: `value` and the like can hold what the user typed. */
+internal fun htmlAttributesForLog(attributes: Map<String, String>): String =
+ attributes
+ .filterKeys { it in setOf("id", "name", "type", "autocomplete", "maxlength") }
+ .entries
+ .joinToString { "${it.key}=${it.value}" }
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt
index 8eb113d5..d14358e8 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt
@@ -6,7 +6,6 @@ package com.github.androidpasswordstore.autofillparser
import android.content.Context
import android.net.InetAddresses
-import android.os.Build
import android.util.Patterns
import kotlinx.coroutines.runBlocking
import mozilla.components.lib.publicsuffixlist.PublicSuffixList
@@ -45,21 +44,13 @@ internal fun getPublicSuffixPlusOne(
// We do not check whether the domain actually exists (actually, not even whether its TLD
// exists). As long as we restrict ourselves to syntactically valid domain names,
// getPublicSuffixPlusOne will return non-colliding results.
- if (!Patterns.DOMAIN_NAME.matcher(domain).matches() || isNumericAddress(domain)) {
+ if (!Patterns.DOMAIN_NAME.matcher(domain).matches() || InetAddresses.isNumericAddress(domain)) {
domain
} else {
getCanonicalSuffix(context, domain, customSuffixes)
}
}
-private fun isNumericAddress(domain: String): Boolean {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- InetAddresses.isNumericAddress(domain)
- } else {
- @Suppress("DEPRECATION") Patterns.IP_ADDRESS.matcher(domain).matches()
- }
-}
-
/**
* Returns:
* - [domain], if [domain] equals [suffix];
diff --git a/autofill-parser/src/test/kotlin/com/github/androidpasswordstore/autofillparser/FormFieldLogTest.kt b/autofill-parser/src/test/kotlin/com/github/androidpasswordstore/autofillparser/FormFieldLogTest.kt
new file mode 100644
index 00000000..be6b4b32
--- /dev/null
+++ b/autofill-parser/src/test/kotlin/com/github/androidpasswordstore/autofillparser/FormFieldLogTest.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package com.github.androidpasswordstore.autofillparser
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class FormFieldLogTest {
+
+ @Test
+ fun onlyDescriptiveAttributesAreLogged() {
+ val attributes =
+ mapOf("type" to "email", "value" to "me@example.org", "placeholder" to "me@example.org")
+ assertEquals("type=email", htmlAttributesForLog(attributes))
+ }
+}
diff --git a/build-logic/src/main/kotlin/app/passwordstore/gradle/AndroidCommon.kt b/build-logic/src/main/kotlin/app/passwordstore/gradle/AndroidCommon.kt
index 94b9c112..70d48586 100644
--- a/build-logic/src/main/kotlin/app/passwordstore/gradle/AndroidCommon.kt
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/AndroidCommon.kt
@@ -20,7 +20,7 @@ object AndroidCommon {
fun configure(project: Project) {
project.extensions.getByType(CommonExtension::class.java).apply {
compileSdk = 35
- defaultConfig.minSdk = 26
+ defaultConfig.minSdk = 31
packaging.resources.excludes.add("**/*.version")
packaging.resources.excludes.add("**/*.txt")
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 051645d1..b6332b6a 100644
--- a/build-logic/src/main/kotlin/app/passwordstore/gradle/ApplicationPlugin.kt
+++ b/build-logic/src/main/kotlin/app/passwordstore/gradle/ApplicationPlugin.kt
@@ -22,6 +22,14 @@ class ApplicationPlugin : Plugin<Project> {
project.pluginManager.apply(AppPlugin::class)
AndroidCommon.configure(project)
val minifySwitch = project.providers.environmentVariable("DISABLE_MINIFY")
+ val commit =
+ project.providers
+ .exec {
+ commandLine("git", "rev-parse", "--short=8", "HEAD")
+ isIgnoreExitValue = true
+ }
+ .standardOutput
+ .asText
project.extensions.configure<ApplicationExtension> {
dependenciesInfo {
includeInBundle = false
@@ -40,25 +48,19 @@ class ApplicationPlugin : Plugin<Project> {
"proguard-rules-missing-classes.pro",
)
)
- buildConfigField("boolean", "ENABLE_DEBUG_FEATURES", "${project.isSnapshot()}")
+ buildConfigField("boolean", "ENABLE_DEBUG_FEATURES", "false")
+ buildConfigField("String", "GIT_COMMIT", "\"\"")
}
named("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
isMinifyEnabled = false
buildConfigField("boolean", "ENABLE_DEBUG_FEATURES", "true")
+ buildConfigField("String", "GIT_COMMIT", "\"${commit.get().trim()}\"")
}
}
project.configureBuildSigning()
}
}
-
- private fun Project.isSnapshot(): Boolean {
- with(providers) {
- val workflow = environmentVariable("GITHUB_WORKFLOW")
- val snapshot = environmentVariable("SNAPSHOT")
- return workflow.isPresent || snapshot.isPresent
- }
- }
}
diff --git a/docs/superpowers/plans/2026-11-05-git-dialogs-compose.md b/docs/superpowers/plans/2026-11-05-git-dialogs-compose.md
new file mode 100644
index 00000000..2b2c9253
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-05-git-dialogs-compose.md
@@ -0,0 +1,905 @@
+# Git dialogs to Compose Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Remove Material Components and the app's own AppCompat use, moving the last View dialogs and the snackbar to Compose, at minSdk 31.
+
+**Architecture:** An activity-scoped `Prompts` ViewModel holds pending dialogs and the current snackbar as Compose state; `PromptHost()` draws them at the root of `MainActivity` and `AutofillSaveActivity`. Git code reaches it through `activity.prompts`. Spec: `docs/superpowers/specs/2026-11-05-git-dialogs-compose-design.md`.
+
+**Tech Stack:** Kotlin, Jetpack Compose Material 3, AndroidX Lifecycle, Gradle. No new dependencies.
+
+Work on `master`. Commits are GPG signed; never disable signing. Do not push until the user asks. Run commands from the repository root. Prefix Gradle with `CI=1` where shown: Kotlin warnings become errors, and lint has `warningsAsErrors`.
+
+## File structure
+
+- Create `app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt`: state (`Prompts`, `Prompt`, `Choice`, `Snack`) and drawing (`PromptHost`).
+- Create `app/src/test/java/app/passwordstore/ui/prompts/PromptsTest.kt`.
+- Delete `app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt`, `app/src/main/res/values-v29/`, `app/src/main/res/layout/git_credential_layout.xml`.
+- Everything else is a modification listed per task.
+
+---
+
+### Task 1: minSdk 31 and dead branches
+
+**Files:**
+- Modify: `build-logic/src/main/kotlin/app/passwordstore/gradle/AndroidCommon.kt:23`
+- Modify: `app/src/main/java/app/passwordstore/injection/AutofillResponseBuilderModule.kt`
+- Modify: `app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt:54-71`
+- Modify: `ui/compose/src/main/kotlin/app/passwordstore/ui/compose/theme/Theme.kt:87`
+- Modify: `app/src/test/java/app/passwordstore/util/viewmodel/StrictDomainRegexTest.kt:19`
+- Modify: every `SDK_INT` site listed in Step 4, `app/src/main/res/values/arrays.xml`, `app/src/main/res/values/prefs.xml`, `strings.xml` in `values` and 10 locales
+- Delete: `Api26AutofillResponseBuilder.kt`, `app/src/main/res/values-v29/`
+
+- [ ] **Step 1: Raise minSdk**
+
+In `AndroidCommon.kt`: `defaultConfig.minSdk = 31`.
+
+- [ ] **Step 2: Drop the Api26 builder**
+
+`AutofillResponseBuilderModule.kt` body becomes:
+
+```kotlin
+ @Provides
+ @Reusable
+ fun provideAutofillResponseBuilder(): AutofillResponseBuilder.Factory =
+ Api30AutofillResponseBuilder.Factory
+```
+
+Remove the `Build` and `Api26AutofillResponseBuilder` imports, delete `Api26AutofillResponseBuilder.kt`, and remove `@RequiresApi(Build.VERSION_CODES.R)` from `Api30AutofillResponseBuilder.kt:34`.
+
+- [ ] **Step 3: Simplify the clipboard helper**
+
+In `CryptoExtensions.kt`:
+
+```kotlin
+/** Copies provided [text] to the clipboard, flagged sensitive. */
+fun FragmentActivity.copyTextToClipboard(text: String?) {
+ val clipboard = clipboard ?: return
+ val clip = ClipData.newPlainText("pgp_handler_result_pm", text)
+ clip.description.extras =
+ PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) }
+ clipboard.setPrimaryClip(clip)
+}
+```
+
+Remove imports that become unused (`Build`, `StringRes`, `snackbar`) if nothing else in the file needs them.
+
+- [ ] **Step 4: Remove obsolete version checks**
+
+Rule: a check against `P`, `Q`, `R` or `S` is now always true for `>=` and always false for `<`. Keep the taken branch, delete the other, and delete the condition. Checks against `TIRAMISU` and `UPSIDE_DOWN_CAKE` stay.
+
+Sites:
+
+- `app/src/main/java/app/passwordstore/`: `util/services/ClipboardService.kt:124`, `Application.kt:105,109,113,117`, `util/crypto/KeystorePreferences.kt:70,80,94`, `util/autofill/AutofillResponseBuilder.kt:44` (if line numbers moved, grep for `SDK_INT`), `util/services/OreoAutofillService.kt:77`, `util/git/sshj/SshKey.kt:160,201,321`, `util/autofill/AutofillViewUtils.kt:50,59`, `util/auth/BiometricAuthenticator.kt:32`, `ui/autofill/AutofillPublisherChangedActivity.kt:57`, `ui/autofill/AutofillFilterView.kt:77`, `ui/autofill/AutofillDecryptActivity.kt:371`, `ui/settings/ProxyViewModel.kt:23`, `ui/editor/EditorViewModel.kt:171`.
+- `autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/`: `PublicSuffixListCache.kt:56`, `AutofillHelper.kt:59,89,123`, `FeatureAndTrustDetection.kt:241`.
+- `ui/compose/.../Theme.kt:87`: the case becomes `dynamicColor -> {`.
+
+Note `FeatureAndTrustDetection.kt:241`: `SDK_INT < P` is false, so the whole `||`/`&&` expression must be reduced by hand, not just deleted. At `AutofillHelper.kt:123` the `P` case of the `when` becomes `else`, and any older `else` goes. `KeystorePreferences.kt:80` still returns early on `!strongBox`.
+
+Then remove the obsolete annotations:
+
+```bash
+grep -rl 'RequiresApi(Build.VERSION_CODES.O)' autofill-parser/src/main | xargs sed -i '/@RequiresApi(Build.VERSION_CODES.O)/d'
+```
+
+- [ ] **Step 5: Fold `values-v29` into `values`**
+
+In `app/src/main/res/values/arrays.xml`, the `app_theme_options` third item becomes `@string/pref_app_theme_value_follow_system` and the `app_theme_values` third item becomes `follow_system`. In `values/prefs.xml`, `app_theme_def` becomes `follow_system`. Then:
+
+```bash
+command rm -r app/src/main/res/values-v29
+grep -rl pref_app_theme_value_battery_saver app/src/main/res | xargs sed -i '/pref_app_theme_value_battery_saver/d'
+```
+
+- [ ] **Step 6: Fix the test below minSdk**
+
+In `StrictDomainRegexTest.kt` delete `@Config(sdk = [23])` and its import.
+
+- [ ] **Step 7: Verify**
+
+Run: `./gradlew spotlessApply && CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug lint metalavaCheckCompatibilityRelease`
+
+Expected: BUILD SUCCESSFUL. Lint failures name leftovers: `ObsoleteSdkInt` means a missed check, `UnusedResources` means a string or resource only the deleted code used; delete it, including its translations. If metalava reports an API change in `autofill-parser`, run `./gradlew metalavaGenerateRelease` and include the signature file.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add -A
+git commit -m "build: raise minSdk to 31
+
+The project targets GrapheneOS, which only runs current Android. Android 12
+gives the platform night mode API and removes the fingerprint dialog path that
+needs AppCompat. Devices below it stay on 0.2.0.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: Prompts state
+
+**Files:**
+- Create: `app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt`
+- Test: `app/src/test/java/app/passwordstore/ui/prompts/PromptsTest.kt`
+
+- [ ] **Step 1: Write the failing test**
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.prompts
+
+import androidx.compose.material3.SnackbarDuration
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertSame
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.async
+import kotlinx.coroutines.runBlocking
+
+class PromptsTest {
+
+ private fun Prompts.question() = current as Question
+
+ @Test
+ fun answerResumesAsk() = runBlocking {
+ val prompts = Prompts()
+ val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") }
+ prompts.answer(prompts.question(), Choice.Other)
+ assertEquals(Choice.Other, asked.await())
+ assertNull(prompts.current)
+ }
+
+ @Test
+ fun dismissingAnswersCancel() = runBlocking {
+ val prompts = Prompts()
+ val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") }
+ prompts.cancel(prompts.question())
+ assertEquals(Choice.Cancel, asked.await())
+ }
+
+ @Test
+ fun secondAnswerIsIgnored() = runBlocking {
+ val prompts = Prompts()
+ val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") }
+ val question = prompts.question()
+ prompts.answer(question, Choice.Confirm)
+ prompts.answer(question, Choice.Cancel)
+ assertEquals(Choice.Confirm, asked.await())
+ }
+
+ @Test
+ fun cancelledAskRemovesItsDialog() = runBlocking {
+ val prompts = Prompts()
+ val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") }
+ asked.cancel()
+ assertNull(prompts.current)
+ }
+
+ @Test
+ fun promptsShowOneAtATimeInOrder() = runBlocking {
+ val prompts = Prompts()
+ val first = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("1", "m", "ok") }
+ val second = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("2", "m", "ok") }
+ assertEquals("1", prompts.question().title)
+ prompts.answer(prompts.question(), Choice.Confirm)
+ assertEquals("2", prompts.question().title)
+ prompts.answer(prompts.question(), Choice.Confirm)
+ first.await()
+ second.await()
+ }
+
+ @Test
+ fun cancelAllAnswersAPendingCredentialWithNull() {
+ val prompts = Prompts()
+ val answers = mutableListOf<CredentialAnswer?>(CredentialAnswer("unset", false))
+ prompts.askCredential("m", "h", "r", null) { answers[0] = it }
+ prompts.cancelAll()
+ assertNull(answers[0])
+ assertNull(prompts.current)
+ }
+
+ @Test
+ fun newSnackbarReplacesTheCurrentOne() {
+ val prompts = Prompts()
+ val first = prompts.snackbar("one", SnackbarDuration.Indefinite)
+ val second = prompts.snackbar("two", SnackbarDuration.Short)
+ assertSame(second, prompts.snack)
+ // The finished operation dismisses its own snackbar only.
+ prompts.dismiss(first)
+ assertSame(second, prompts.snack)
+ prompts.dismiss(second)
+ assertNull(prompts.snack)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `./gradlew :app:testDebugUnitTest -PslimTests --tests 'app.passwordstore.ui.prompts.PromptsTest'`
+Expected: FAIL, compilation error, unresolved reference `Prompts`.
+
+- [ ] **Step 3: Write the state**
+
+`app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt`:
+
+```kotlin
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.prompts
+
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import kotlin.coroutines.resume
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+/** The button that answered a [Question]. Dismissing the dialog answers [Cancel]. */
+enum class Choice {
+ Confirm,
+ Other,
+ Cancel,
+}
+
+/** What the user entered in a [CredentialRequest]. */
+class CredentialAnswer(val text: String, val remember: Boolean)
+
+/** A dialog waiting for its answer; [cancelled] is the answer to dismissing it. */
+sealed class Prompt<T>(private val cancelled: T, private val deliver: (T) -> Unit) {
+ internal fun answer(value: T) = deliver(value)
+
+ internal fun cancel() = deliver(cancelled)
+}
+
+class Question(
+ val title: String,
+ val message: String,
+ val confirm: String,
+ val cancel: String?,
+ val other: String?,
+ deliver: (Choice) -> Unit,
+) : Prompt<Choice>(Choice.Cancel, deliver)
+
+class CredentialRequest(
+ val message: String,
+ val hint: String,
+ val remember: String,
+ val error: String?,
+ deliver: (CredentialAnswer?) -> Unit,
+) : Prompt<CredentialAnswer?>(null, deliver)
+
+/** A snackbar; each one is a distinct object, so showing the same text again shows it again. */
+class Snack(val message: String, val length: SnackbarDuration)
+
+/**
+ * The dialogs and the snackbar of one activity, for code with no composable of its own (git
+ * operations and store actions), drawn by [PromptHost]. Reach it through [prompts].
+ */
+class Prompts : ViewModel() {
+
+ private val queue = mutableStateListOf<Prompt<*>>()
+
+ /** The dialog on screen. The others wait for it to be answered. */
+ val current: Prompt<*>?
+ get() = queue.firstOrNull()
+
+ var snack by mutableStateOf<Snack?>(null)
+ private set
+
+ /** Shows a dialog and suspends until it is answered. Cancelling the caller closes it. */
+ suspend fun ask(
+ title: String,
+ message: String,
+ confirm: String,
+ cancel: String? = null,
+ other: String? = null,
+ ): Choice = suspendCancellableCoroutine { cont ->
+ val question = Question(title, message, confirm, cancel, other) { cont.resume(it) }
+ queue += question
+ cont.invokeOnCancellation { queue.remove(question) }
+ }
+
+ /**
+ * Asks for a credential. Not suspending: the SSH thread waits on [deliver], and no cancellation
+ * reaches it, so this dialog outlives a recreated activity and is drawn again by the new one.
+ */
+ fun askCredential(
+ message: String,
+ hint: String,
+ remember: String,
+ error: String?,
+ deliver: (CredentialAnswer?) -> Unit,
+ ) {
+ queue += CredentialRequest(message, hint, remember, error, deliver)
+ }
+
+ /** Answers [prompt], once: a double tap must not resume its caller twice. */
+ fun <T> answer(prompt: Prompt<T>, value: T) {
+ if (queue.remove(prompt)) prompt.answer(value)
+ }
+
+ fun cancel(prompt: Prompt<*>) {
+ if (queue.remove(prompt)) prompt.cancel()
+ }
+
+ fun cancelAll() {
+ queue.toList().forEach(::cancel)
+ }
+
+ /** Shows [message] in place of any current snackbar, as the View snackbar did. */
+ fun snackbar(message: String, length: SnackbarDuration = SnackbarDuration.Short): Snack =
+ Snack(message, length).also { snack = it }
+
+ /** Hides [shown], unless another snackbar replaced it. */
+ fun dismiss(shown: Snack) {
+ if (snack === shown) snack = null
+ }
+
+ override fun onCleared() = cancelAll()
+}
+
+/** The [Prompts] of this activity; it survives recreation. */
+val FragmentActivity.prompts: Prompts
+ get() = ViewModelProvider(this)[Prompts::class.java]
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `./gradlew :app:testDebugUnitTest -PslimTests --tests 'app.passwordstore.ui.prompts.PromptsTest'`
+Expected: PASS, 7 tests. No commit yet: nothing uses the file, Task 3 commits it with its callers.
+
+---
+
+### Task 3: PromptHost and all call sites
+
+**Files:**
+- Modify: `app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt`
+- Modify: `ui/main/MainActivity.kt`, `ui/autofill/AutofillSaveActivity.kt`
+- Modify: `util/extensions/AndroidExtensions.kt`, `util/git/GitCommandExecutor.kt`
+- Modify: `util/git/operation/CredentialFinder.kt`, `util/git/operation/BreakOutOfDetached.kt`, `util/git/operation/GitOperation.kt`
+- Modify: `ui/git/base/BaseGitActivity.kt`, `ui/list/StoreActions.kt`
+- Modify: `ui/setup/PgpKeysScreen.kt`, `ui/setup/GitServerScreen.kt`, `ui/settings/GitConfigScreen.kt`, `ui/editor/EditorDestination.kt`
+- Delete: `app/src/main/res/layout/git_credential_layout.xml`
+
+Paths without a root are under `app/src/main/java/app/passwordstore/`.
+
+- [ ] **Step 1: Add `PromptHost` to `Prompts.kt`**
+
+```kotlin
+/** Draws the [Prompts] of [activity]. Place it after the activity's content, inside `APSTheme`. */
+@Composable
+fun PromptHost(activity: FragmentActivity) {
+ val prompts = remember(activity) { activity.prompts }
+ val snackbars = remember { SnackbarHostState() }
+ val snack = prompts.snack
+ // A new snack cancels this effect, which takes the old one off the screen.
+ LaunchedEffect(snack) {
+ if (snack != null) {
+ snackbars.showSnackbar(snack.message, duration = snack.length)
+ prompts.dismiss(snack)
+ }
+ }
+ Box(Modifier.fillMaxSize().navigationBarsPadding().imePadding(), Alignment.BottomCenter) {
+ SnackbarHost(snackbars)
+ }
+ val prompt = prompts.current
+ // Keyed: a retry is a new request and starts with an empty field.
+ key(prompt) {
+ when (prompt) {
+ is Question -> QuestionDialog(prompt, prompts)
+ is CredentialRequest -> CredentialDialog(prompt, prompts)
+ null -> {}
+ }
+ }
+}
+
+@Composable
+private fun QuestionDialog(question: Question, prompts: Prompts) {
+ AlertDialog(
+ onDismissRequest = { prompts.cancel(question) },
+ title = { Text(question.title) },
+ text = { Text(question.message) },
+ confirmButton = {
+ TextButton(onClick = { prompts.answer(question, Choice.Confirm) }) {
+ Text(question.confirm)
+ }
+ },
+ dismissButton = {
+ // No Row: the dialog's button area wraps its children when they do not fit.
+ question.cancel?.let {
+ TextButton(onClick = { prompts.answer(question, Choice.Cancel) }) { Text(it) }
+ }
+ question.other?.let {
+ TextButton(onClick = { prompts.answer(question, Choice.Other) }) { Text(it) }
+ }
+ },
+ )
+}
+
+/** The credential lives only in `remember` and the dialog's window is FLAG_SECURE. */
+@Composable
+private fun CredentialDialog(request: CredentialRequest, prompts: Prompts) {
+ // remember, never rememberSaveable: the credential must not reach saved state.
+ var text by remember { mutableStateOf("") }
+ var keep by remember { mutableStateOf(true) }
+ var revealed by remember { mutableStateOf(false) }
+ var error by remember { mutableStateOf(request.error) }
+ val focus = remember { FocusRequester() }
+ fun submit() = prompts.answer(request, CredentialAnswer(text, keep))
+ AlertDialog(
+ onDismissRequest = { prompts.cancel(request) },
+ properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
+ title = { Text(stringResource(R.string.passphrase_dialog_title)) },
+ text = {
+ Column {
+ Text(request.message)
+ OutlinedTextField(
+ value = text,
+ onValueChange = {
+ text = it
+ error = null
+ },
+ modifier = Modifier.focusRequester(focus),
+ label = { Text(request.hint) },
+ supportingText = error?.let { { Text(it) } },
+ isError = error != null,
+ trailingIcon = {
+ IconButton(onClick = { revealed = !revealed }) {
+ Icon(
+ painterResource(
+ if (revealed) composeR.drawable.baseline_visibility_off_24
+ else composeR.drawable.baseline_visibility_24
+ ),
+ contentDescription =
+ stringResource(
+ if (revealed) R.string.entry_hide_password else R.string.entry_show_password
+ ),
+ )
+ }
+ },
+ visualTransformation =
+ if (revealed) VisualTransformation.None else PasswordVisualTransformation(),
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(onDone = { submit() }),
+ )
+ Row(
+ Modifier.toggleable(value = keep, role = Role.Checkbox, onValueChange = { keep = it }),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(checked = keep, onCheckedChange = null)
+ Text(request.remember)
+ }
+ }
+ LaunchedEffect(Unit) { focus.requestFocus() }
+ },
+ confirmButton = { TextButton(onClick = ::submit) { Text(stringResource(R.string.dialog_ok)) } },
+ dismissButton = {
+ TextButton(onClick = { prompts.cancel(request) }) {
+ Text(stringResource(R.string.dialog_cancel))
+ }
+ },
+ )
+}
+```
+
+Imports follow `ProxyScreen.kt` (including its `composeR` alias for the `ui:compose` R class) and `EntryDestination.kt`'s `PassphraseDialog`.
+
+- [ ] **Step 2: Host it**
+
+`MainActivity.kt`: directly after the `NavHost(...) { ... }` call, still inside `APSTheme { }`, add `PromptHost(this@MainActivity)`.
+
+`AutofillSaveActivity.kt`: after the `EditorDestination(...)` call inside `APSTheme { }`, add `PromptHost(this@AutofillSaveActivity)`, and change `activity = this` above it only if the compiler needs the label.
+
+- [ ] **Step 3: Replace the snackbar helper**
+
+In `AndroidExtensions.kt`:
+
+```kotlin
+/** Shows a snackbar through this activity's [prompts]; it must host a `PromptHost`. */
+fun FragmentActivity.snackbar(
+ message: String,
+ length: SnackbarDuration = SnackbarDuration.Short,
+): Snack = prompts.snackbar(message, length)
+```
+
+Remove the `Snackbar` and `View` imports if unused. At every caller replace `Snackbar.LENGTH_LONG` with `SnackbarDuration.Long` and the `com.google.android.material.snackbar.Snackbar` import with `androidx.compose.material3.SnackbarDuration`: `ui/setup/PgpKeysScreen.kt`, `ui/setup/GitServerScreen.kt`, `ui/settings/GitConfigScreen.kt`, `ui/editor/EditorDestination.kt`.
+
+In `GitCommandExecutor.kt`:
+
+```kotlin
+ val running =
+ activity.snackbar(
+ message = activity.resources.getString(R.string.git_operation_running),
+ length = SnackbarDuration.Indefinite,
+ )
+```
+
+and the end of `execute()` becomes `.also { activity.prompts.dismiss(running) }`.
+
+- [ ] **Step 4: Credential dialog**
+
+In `CredentialFinder.kt`, replace the whole `if (storedCredential == null) { ... }` branch body, keeping the `else`:
+
+```kotlin
+ if (storedCredential == null) {
+ val resources = callingActivity.resources
+ callingActivity.prompts.askCredential(
+ message = resources.getString(messageRes),
+ hint = resources.getString(hintRes),
+ remember = resources.getString(rememberRes),
+ error = if (isRetry) resources.getString(errorRes) else null,
+ ) { answer ->
+ if (answer?.remember == true) {
+ gitOperationPrefs.edit { putString(credentialPref, answer.text) }
+ }
+ cont.resume(answer?.text)
+ }
+ } else {
+```
+
+Remove the imports of `SuppressLint`, `LayoutInflater`, `WindowManager`, `doOnTextChanged` and the four Material classes. Delete `app/src/main/res/layout/git_credential_layout.xml`.
+
+- [ ] **Step 5: Operations**
+
+`GitOperation.kt`: `open suspend fun preExecute() = true`, and:
+
+```kotlin
+ private suspend fun onMissingSshKeyFile() {
+ val resources = callingActivity.resources
+ val choice =
+ callingActivity.prompts.ask(
+ title = resources.getString(R.string.ssh_preferences_dialog_title),
+ message = resources.getString(R.string.ssh_preferences_dialog_text),
+ confirm = resources.getString(R.string.button_label_import),
+ cancel = resources.getString(R.string.dialog_cancel),
+ other = resources.getString(R.string.ssh_preferences_dialog_generate),
+ )
+ when (choice) {
+ Choice.Confirm -> getSshKey(false)
+ Choice.Other -> getSshKey(true)
+ // Only closes the dialog: the calling activity is the app's own screen now.
+ Choice.Cancel -> {}
+ }
+ }
+```
+
+`BreakOutOfDetached.kt`:
+
+```kotlin
+ override suspend fun preExecute() =
+ if (!git.repository.repositoryState.isRebasing && !merging) {
+ val resources = callingActivity.resources
+ callingActivity.prompts.ask(
+ title = resources.getString(R.string.git_abort_and_push_title),
+ message = resources.getString(R.string.git_break_out_of_detached_unneeded),
+ confirm = resources.getString(R.string.dialog_ok),
+ )
+ false
+ } else {
+ true
+ }
+```
+
+`AndroidExtensions.kt:73`: `override suspend fun preExecute(): Boolean {`.
+
+- [ ] **Step 6: Error dialog**
+
+`BaseGitActivity.kt`:
+
+```kotlin
+ suspend fun promptOnErrorHandler(err: Throwable) {
+ val error = rootCauseException(err)
+ if (!isExplicitlyUserInitiatedError(error)) {
+ gitPrefs.edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
+ sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
+ logcat { error.asLog() }
+ prompts.ask(
+ title = getString(R.string.jgit_error_dialog_title),
+ message = ErrorMessages[error],
+ confirm = getString(R.string.dialog_ok),
+ )
+ }
+ }
+```
+
+No caller passes `onPromptDone`. Drop the `withContext` import if unused.
+
+- [ ] **Step 7: Store actions**
+
+`StoreActions.kt`, in `moveFile`, replacing the dialog and its `ponytail` comment:
+
+```kotlin
+ prompts.ask(
+ title = getString(R.string.password_move_error_title),
+ message = getString(R.string.password_move_error_message, source, destination),
+ confirm = getString(R.string.dialog_ok),
+ )
+```
+
+and `confirm`:
+
+```kotlin
+/** Shows [message] under [title] and suspends until answered; true when confirmed. */
+private suspend fun BaseGitActivity.confirm(title: String, message: String): Boolean =
+ prompts.ask(
+ title = title,
+ message = message,
+ confirm = getString(R.string.dialog_ok),
+ cancel = getString(R.string.dialog_cancel),
+ ) == Choice.Confirm
+```
+
+Remove `MaterialAlertDialogBuilder`, `resume` and `suspendCancellableCoroutine` imports. `Context` stays for `recordUse` and `moveHistory`.
+
+- [ ] **Step 8: Verify no Material dialog or snackbar is left**
+
+Run: `grep -rnE 'MaterialAlertDialog|material\.snackbar|material\.checkbox|material\.textfield' app/src/main`
+Expected: no output.
+
+Run: `./gradlew spotlessApply && CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug lint`
+Expected: BUILD SUCCESSFUL. Delete any string lint reports unused, with its translations.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add -A
+git commit -m "feat(app): move the git dialogs and the snackbar to Compose
+
+An activity-scoped Prompts ViewModel holds them, since git operations have no
+composable to draw in; PromptHost draws them. The credential dialog's window is
+now FLAG_SECURE, and a recreated activity no longer loses it and strands the
+SSH thread.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: AppCompat out of the code
+
+**Files:**
+- Modify: `ui/git/base/BaseGitActivity.kt`, `ui/main/LaunchActivity.kt`, `ui/autofill/AutofillDecryptActivity.kt`, `ui/autofill/AutofillFilterView.kt`, `ui/autofill/AutofillPublisherChangedActivity.kt`, `ui/autofill/AutofillSaveActivity.kt`
+- Modify: `util/git/operation/{Push,Pull,Sync,Clone,Gc,ResetToRemote,BreakOutOfDetached,GitOperation}*.kt`, `util/git/sshj/SshjSessionFactory.kt`
+- Modify: `Application.kt`
+- Modify: `ui/compose/src/main/kotlin/app/passwordstore/ui/compose/theme/Theme.kt`
+- Modify: `app/src/main/res/values/themes_material3.xml`, `app/src/main/res/values-night/themes_material3.xml`
+- Test: `app/src/test/java/app/passwordstore/ui/main/LaunchActivityTest.kt` must keep passing
+
+- [ ] **Step 1: Activities and operations**
+
+```bash
+grep -rl 'androidx.appcompat.app.AppCompatActivity' app/src | xargs sed -i \
+ -e 's/androidx\.appcompat\.app\.AppCompatActivity/androidx.fragment.app.FragmentActivity/' \
+ -e 's/\bAppCompatActivity\b/FragmentActivity/g'
+sed -i '/supportActionBar?.hide()/d' \
+ app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt \
+ app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt
+```
+
+Update the `MainActivity` class comment: "Extends [BaseGitActivity] (a FragmentActivity, which BiometricPrompt needs) because the git operations require one; it hosts no fragments."
+
+- [ ] **Step 2: Night mode**
+
+`Application.kt`, replacing the `AppCompatDelegate.*` import with `android.app.UiModeManager`, and removing the `DynamicColors` import and call:
+
+```kotlin
+ private fun setNightMode() {
+ getSystemService(UiModeManager::class.java)
+ .setApplicationNightMode(
+ when (prefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) {
+ "light" -> UiModeManager.MODE_NIGHT_NO
+ "dark" -> UiModeManager.MODE_NIGHT_YES
+ else -> UiModeManager.MODE_NIGHT_AUTO
+ }
+ )
+ }
+```
+
+- [ ] **Step 3: Window themes**
+
+`values/themes_material3.xml`, whole `resources` body. Style names stay: renaming them is churn.
+
+```xml
+ <style name="AppThemeM3" parent="APSThemeM3">
+ <item name="android:windowLightStatusBar">@bool/light_status_bar</item>
+ </style>
+
+ <!-- Activities whose whole UI is Compose -->
+ <style name="NoActionBarThemeM3" parent="AppThemeM3" />
+
+ <style name="NoBackgroundThemeM3" parent="@style/AppThemeM3">
+ <item name="android:background">@android:color/transparent</item>
+ <item name="android:backgroundDimEnabled">true</item>
+ <item name="android:navigationBarColor">@android:color/transparent</item>
+ <item name="android:statusBarColor">@android:color/transparent</item>
+ <item name="android:windowIsTranslucent">true</item>
+ <item name="android:windowContentOverlay">@null</item>
+ <item name="android:windowBackground">@android:color/transparent</item>
+ <item name="android:windowEnterAnimation">@android:anim/fade_in</item>
+ <item name="android:windowExitAnimation">@android:anim/fade_out</item>
+ </style>
+
+ <style name="DialogLikeThemeM3" parent="AppThemeM3">
+ <item name="android:windowIsFloating">true</item>
+ <item name="android:backgroundDimEnabled">true</item>
+ </style>
+
+ <style name="APSThemeM3" parent="android:Theme.Material.Light.NoActionBar" />
+```
+
+`values-night/themes_material3.xml`, whole `resources` body:
+
+```xml
+ <style name="APSThemeM3" parent="android:Theme.Material.NoActionBar" />
+```
+
+The `android:windowLightStatusBar` item needs `@bool/light_status_bar`, which `values-night/bools.xml` already flips.
+
+- [ ] **Step 4: System bars**
+
+`Theme.kt`, the existing `SideEffect`:
+
+```kotlin
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ val bars = WindowCompat.getInsetsController(window, view)
+ bars.isAppearanceLightStatusBars = !darkTheme
+ bars.isAppearanceLightNavigationBars = !darkTheme
+ paintBars(window, colorScheme.background.toArgb())
+ }
+ }
+```
+
+```kotlin
+/** Colors the system bars, except in windows that keep them see-through (autofill decryption). */
+@Suppress("DEPRECATION") // Deprecated with edge-to-edge, which targetSdk 34 does not force.
+private fun paintBars(window: Window, color: Int) {
+ if (window.statusBarColor == AndroidColor.TRANSPARENT) return
+ window.statusBarColor = color
+ window.navigationBarColor = color
+}
+```
+
+Imports: `android.graphics.Color as AndroidColor`, `android.view.Window`, `androidx.compose.ui.graphics.toArgb`.
+
+- [ ] **Step 5: Verify**
+
+Run: `grep -rn 'androidx.appcompat' app/src/main ui autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt`
+Expected: no output. (`FormField.kt` naming `androidx.appcompat.widget.AppCompatEditText` is a class name matched in other apps' forms and stays.)
+
+Run: `./gradlew spotlessApply && CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug lint`
+Expected: BUILD SUCCESSFUL. `metalava` is unaffected: the theme module's public API does not change.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A
+git commit -m "refactor(app): take the app's own code off AppCompat
+
+Activities extend FragmentActivity, window themes derive from the platform's
+Material theme and night mode uses UiModeManager. APSTheme colors the system
+bars, which DynamicColors did through the window theme. AppCompat stays in the
+APK through zxing.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: Dependencies, resources, docs
+
+**Files:**
+- Modify: `app/build.gradle.kts:60,65,71`, `gradle/libs.versions.toml:17,30`
+- Modify: `app/src/main/res/values/colors_material3.xml`, `AGENTS.md`, `TODO.md`
+- Possibly modify: `app/lint-baseline.xml`
+
+- [ ] **Step 1: Remove the dependencies**
+
+From `app/build.gradle.kts` remove `implementation(libs.androidx.appcompat)` and `implementation(libs.androidx.material)`, and change the fragment comment to `// FragmentActivity, which BiometricPrompt needs.` From `gradle/libs.versions.toml` remove `androidx-appcompat` and `androidx-material`.
+
+- [ ] **Step 2: Remove unused colors**
+
+`oreo_autofill_dataset.xml` uses `@color/md_theme_light_primary`, which stays because it is a `RemoteViews` layout. Delete every other color in `colors_material3.xml`.
+
+- [ ] **Step 3: Confirm the graph**
+
+Run: `./gradlew -q :app:dependencies --configuration releaseRuntimeClasspath | grep -E 'google.android.material|appcompat:appcompat' | sort -u`
+Expected: no `com.google.android.material` line; `appcompat` only below `zxing-android-embedded` and `biometric`.
+
+- [ ] **Step 4: Docs**
+
+`AGENTS.md`:
+- Replace the "Material Components (Views) stays after the Compose overhaul" bullet with: "Window themes derive from the platform `android:Theme.Material.*.NoActionBar`, activities from `FragmentActivity` (`BiometricPrompt` needs it). Git operations and store actions show dialogs and snackbars through the activity-scoped `Prompts` ViewModel (`activity.prompts`), drawn by `PromptHost`, which only `MainActivity` and `AutofillSaveActivity` host. AppCompat stays in the APK through zxing (`CaptureManager`, `zxing_CaptureTheme`) and `androidx.biometric`; `oreo_autofill_dataset.xml` stays a layout: fill suggestions are `RemoteViews` drawn by the system."
+- In "What this is", add: "minSdk is 31: GrapheneOS runs current Android only, and Android 12 has `UiModeManager.setApplicationNightMode`."
+
+`TODO.md`: replace the git dialogs item with:
+
+```markdown
+- QR scanner: `zxing-android-embedded` is the last thing keeping AppCompat in the APK
+ (`androidx.biometric` references it only on the fingerprint dialog path, unused at minSdk 31).
+ Replacing it would let AppCompat go.
+```
+
+- [ ] **Step 5: Full check**
+
+Run: `CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug :app:assembleRelease lint metalavaCheckCompatibilityRelease`
+Expected: BUILD SUCCESSFUL. A lint error about a baselined jar path means the entry in `app/lint-baseline.xml` needs its path updated (see `AGENTS.md`).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A
+git commit -m "build: remove Material Components and the AppCompat dependency
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: Emulator run
+
+No code unless a check fails. Use the setup in `AGENTS.md` ("End-to-end testing on an emulator"): `scripts/e2e-env.sh up`, AVD `aps-test`, `export ANDROID_SERIAL=emulator-5554`, animations off, `settings put secure autofill_service null`. `FLAG_SECURE` windows show black in `screencap`: read them from `uiautomator dump`.
+
+- [ ] **Step 1: Install and clone**
+
+Run `./gradlew :app:installDebug`, clone from the e2e server, import the PGP key, decrypt an entry.
+
+- [ ] **Step 2: Check each dialog**
+
+Each must behave as stated:
+
+| Check | Expected |
+|---|---|
+| SSH key with passphrase, wrong passphrase | Dialog returns with the error, cleared on typing, field empty |
+| Same, remember left ticked (the default), right passphrase | Next sync asks nothing |
+| Same, Cancel | Operation stops, no error dialog |
+| Tap outside the credential dialog | Same as Cancel |
+| Credential dialog opens | Keyboard comes up on the field |
+| Missing SSH key | Three buttons; outside tap only closes; Import and Generate open setup |
+| Unreachable server | Git error dialog |
+| Conflict, then "abort and push" | Recovery works; notice shows on a clean store |
+| Move over an existing entry | Confirm dialog, both answers |
+| "Operation running" snackbar | Shows, goes away at the end; "done" replaces it |
+
+- [ ] **Step 3: Check themes**
+
+Light, dark and system default in Settings: the app switches, status and navigation bars match the screen, icons readable. With "System default", `adb shell cmd uimode night yes` follows.
+
+- [ ] **Step 4: Check the rest**
+
+- Theme change with a credential dialog open: dialog comes back, answering works.
+- QR scanner opens from the editor.
+- Fingerprint unlock, per `AGENTS.md`.
+- Autofill windows via `am start -n` after `adb root` (picker, publisher warning, save editor): no action bar, bars look right.
+
+- [ ] **Step 5: Fix what failed**
+
+Each failure gets its own `fix(app):` commit, with the full check from Task 5 Step 5 first.
+
+- [ ] **Step 6: Ask the user for the device run**
+
+Autofill save and fill on a real device with Firefox cannot run on the emulator. The website docs live in another repository: check them for a stated minimum Android version and tell the user. Release notes for the next version must state Android 12 as the minimum.
+
+- [ ] **Step 7: Record the run**
+
+Update "Last full emulator run" in `AGENTS.md` with the date and what passed; commit as `docs: record the emulator run`.
diff --git a/docs/superpowers/specs/2026-11-05-git-dialogs-compose-design.md b/docs/superpowers/specs/2026-11-05-git-dialogs-compose-design.md
new file mode 100644
index 00000000..3ccf5fb1
--- /dev/null
+++ b/docs/superpowers/specs/2026-11-05-git-dialogs-compose-design.md
@@ -0,0 +1,145 @@
+# Git dialogs to Compose, Material and AppCompat out
+
+Last `TODO.md` item. Moves the remaining View dialogs and the snackbar helper to Compose, removes
+Material Components, raises minSdk to 31 and takes the app's own code off AppCompat.
+
+## Goals
+
+- `com.google.android.material:material` is gone from the dependency graph.
+- No `androidx.appcompat` import and no direct `appcompat` dependency in the app.
+- minSdk is 31 (Android 12) in every module.
+
+## Non-goals
+
+- Removing AppCompat from the APK. `zxing-android-embedded` still pulls it in: `CaptureManager`
+ uses its `AlertDialog` and `zxing_CaptureTheme` has parent `Theme.AppCompat.NoActionBar`.
+ Replacing the QR scanner is a new `TODO.md` item.
+- Renaming or restructuring the git operations beyond what the dialogs need.
+
+## Why minSdk 31
+
+The project targets GrapheneOS, which runs current Android only. `androidx.biometric`'s
+`FingerprintDialogFragment` builds an AppCompat `AlertDialog`, used on old Android only, so moving
+themes off AppCompat at minSdk 26 risks a crash on Android 8. At 31 that path is unused,
+`UiModeManager.setApplicationNightMode` exists, and dynamic colors are always available. Devices
+below Android 12 stay on 0.2.0; the next release notes must say so.
+
+## Design
+
+### Prompts
+
+`Prompts` is a ViewModel scoped to the activity, reached with the extension `activity.prompts`
+(`ViewModelProvider(activity)`), so no interface is needed. It offers:
+
+- `suspend fun ask(title, message, confirm, cancel = null, other = null): Choice`, which suspends
+ until a button is pressed or the dialog is dismissed. Dismissing answers `Choice.Cancel`, so a
+ tap outside the missing SSH key dialog can never mean "generate".
+- `askCredential(...)` for the credential dialog, delivering the entered text and the remember
+ choice, or null on cancel.
+- `snackbar(message, length)`, with `length` short, long or indefinite, returning a handle to
+ dismiss it.
+
+`PromptHost()` is a composable placed inside `APSTheme` at the root of `MainActivity` and
+`AutofillSaveActivity`, the only two activities that show these. It draws the current dialog and a
+`SnackbarHost` at the bottom of the window, above the navigation bar and keyboard.
+
+The state lives in the ViewModel. Git operations run in the activity's `lifecycleScope`, so a theme
+or locale recreation cancels them: `ask` removes its dialog when cancelled. The credential dialog
+blocks an SSH thread that cancellation cannot reach, so it stays and is redrawn by the new activity
+instead of leaving that thread waiting forever. When the activity finishes for good, `onCleared`
+answers every pending prompt with its cancelling answer.
+
+### Dialogs
+
+These all move to `ask`:
+
+- the git error dialog in `BaseGitActivity.promptOnErrorHandler`, whose unused `onPromptDone`
+ parameter goes
+- the missing SSH key dialog in `GitOperation` (import, generate, cancel)
+- `BreakOutOfDetached`'s "not needed" notice
+- `moveFile`'s error and `confirm` in `StoreActions`
+
+`GitOperation.preExecute` becomes suspend so the notice can wait for its answer.
+
+The credential dialog in `CredentialFinder` becomes a Compose `AlertDialog`: password field with
+the mode's hint, remember checkbox, retry error that clears on typing, keyboard open on show, and
+`SecureFlagPolicy.SecureOn` (the View dialog had no `FLAG_SECURE`). Storing a remembered credential
+stays in `CredentialFinder`. `InteractivePasswordFinder`'s blocking wait on the main dispatcher
+does not change. `git_credential_layout.xml` is deleted.
+
+### Snackbar
+
+`FragmentActivity.snackbar` keeps its name and now goes through `Prompts`, so call sites change
+only their `length` argument. A new snackbar replaces the current one, as the View snackbar did,
+rather than queueing behind it as `SnackbarHostState` does by default. `GitCommandExecutor` keeps
+"operation running" up until the operation ends, then dismisses it.
+
+### minSdk
+
+`AndroidCommon.kt` sets 31. Branches that become dead are deleted, including the below-Android-11
+snackbar in `copyTextToClipboard` and its parameters, and `Api26AutofillResponseBuilder`. Lint runs
+with `warningsAsErrors`, so `ObsoleteSdkInt` lists whatever is missed. `StrictDomainRegexTest`'s
+`@Config(sdk = [23])` goes, being below minSdk.
+
+### AppCompat out
+
+- `BaseGitActivity`, `LaunchActivity` and the autofill activities extend `FragmentActivity`, which
+ `BiometricPrompt` needs. `GitOperation` subclasses and `SshAuthMethod` take `FragmentActivity`.
+- `supportActionBar?.hide()` goes: the themes have no action bar.
+- Window themes derive from `android:Theme.Material.Light.NoActionBar`, and
+ `android:Theme.Material.NoActionBar` in `values-night`. AppCompat-only theme items are removed
+ or switched to their `android:` form. `zxing_CaptureTheme` is untouched.
+- `fragment-ktx` stays as the provider of `FragmentActivity`; its comment is updated.
+
+### Night mode
+
+`Application.setNightMode` calls `UiModeManager.setApplicationNightMode`: `light` is
+`MODE_NIGHT_NO`, `dark` is `MODE_NIGHT_YES`, everything else is `MODE_NIGHT_AUTO`.
+
+`values-v29` already selects `follow_system` and hides "Set by Battery Saver" on Android 10 and
+later, so nothing changes for users: its `arrays.xml` and `prefs.xml` replace the base ones, the
+folder goes, and the battery saver string leaves every locale. A stored `battery_saver` value could
+only come from a device that ran the app on Android 9 or older; it still maps to `MODE_NIGHT_AUTO`.
+
+### System bars
+
+`DynamicColors` tints the bars today through the window theme. Without it, `APSTheme` sets the
+status and navigation bar colors from `colorScheme.background` in its existing `SideEffect`,
+skipping windows whose theme has transparent bars (the autofill decrypt window). The deprecation
+on these setters at compileSdk 35 is suppressed at that line, since `CI=1` makes warnings errors.
+
+### Dependencies and resources
+
+`androidx-material` and `androidx-appcompat` leave `app/build.gradle.kts` and the catalog.
+`DynamicColors.applyToActivitiesIfAvailable` goes from `Application`. Color resources that lint
+reports as unused after this (`md_theme_*`) are deleted.
+
+## Testing
+
+- JVM test for `Prompts`: an answer resumes `ask`, dismissing gives the cancelling answer, a new
+ snackbar replaces the current one.
+- Full command from `AGENTS.md`: `CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug
+ :app:assembleRelease lint metalavaCheckCompatibilityRelease`.
+- Emulator, on the e2e store: credential dialog (wrong passphrase retry, remember, cancel), missing
+ SSH key dialog, git error dialog, conflict recovery, move error, delete and move confirmations,
+ every theme setting, bar colors in light and dark, QR scanner
+ opens, fingerprint unlock, a theme change while a dialog is open.
+- Real device with Firefox: autofill save, since its window now hosts `PromptHost`, and the
+ autofill windows' transparent bars.
+
+## Docs
+
+- `AGENTS.md`: the Material Components gotcha, the window theme parent, the autofill activities'
+ base class.
+- `TODO.md`: remove this item, add replacing the QR scanner so AppCompat can leave the APK.
+- The website docs, if they state a minimum Android version. That lives in the site repo and
+ deploys through production.
+
+## Commit order
+
+1. minSdk 31 and dead branches.
+2. `Prompts`, `PromptHost` and all call sites.
+3. AppCompat out: activities, themes, night mode, `DynamicColors`, system bars.
+4. Dependencies and unused resources out, docs.
+
+AppCompat goes before the Material dependency so the window themes change only once.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c0a37319..546b844c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -14,7 +14,6 @@ android-desugarJdkLibs = "com.android.tools:desugar_jdk_libs:2.0.4"
androidx-activity = { module = "androidx.activity:activity", version.ref = "androidxActivity" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" }
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-core-ktx = "androidx.core:core-ktx:1.13.1"
@@ -27,7 +26,6 @@ androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-ru
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"
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" }
@@ -49,6 +47,7 @@ compose-foundation-layout = { module = "androidx.compose.foundation:foundation-l
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-ui-core = { module = "androidx.compose.ui:ui" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
+compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
dagger-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
dagger-hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
dagger-hilt-core = { module = "com.google.dagger:hilt-core", version.ref = "hilt" }
diff --git a/media/banner.png b/media/banner.png
new file mode 100644
index 00000000..68d1770d
--- /dev/null
+++ b/media/banner.png
Binary files differ
diff --git a/media/kofi-button.png b/media/kofi-button.png
new file mode 100644
index 00000000..0649f3b9
--- /dev/null
+++ b/media/kofi-button.png
Binary files differ
diff --git a/media/play_store_banner.png b/media/play_store_banner.png
deleted file mode 100644
index 9586c075..00000000
--- a/media/play_store_banner.png
+++ /dev/null
Binary files differ
diff --git a/scripts/boot-emulator.sh b/scripts/boot-emulator.sh
index 910340f1..92a053f9 100755
--- a/scripts/boot-emulator.sh
+++ b/scripts/boot-emulator.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
-# Creates and boots a display-less emulator (defaulting to Android 8.1, our min SDK)
+# Creates and boots a display-less emulator (defaulting to Android 12, our min SDK)
set -euo pipefail
@@ -9,8 +9,8 @@ set -euo pipefail
exit 1
}
-[ -n "${ANDROID_API_LEVEL:-}" ] || { echo "ANDROID_API_LEVEL not defined; defaulting to 26"; }
-API_LEVEL="${ANDROID_API_LEVEL:-26}"
+[ -n "${ANDROID_API_LEVEL:-}" ] || { echo "ANDROID_API_LEVEL not defined; defaulting to 31"; }
+API_LEVEL="${ANDROID_API_LEVEL:-31}"
ARCH="x86_64"
if [[ "$(uname -m)" == "arm64" && "$(uname)" == "Darwin" ]]; then
diff --git a/scripts/setup-environment.sh b/scripts/setup-environment.sh
index 30dd0c19..1acda93c 100755
--- a/scripts/setup-environment.sh
+++ b/scripts/setup-environment.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Installs the latest command line tools and sets up the necessary packages for an Android emulator
-# for API level $ANDROID_API_LEVEL, or API 30 if unspecified.
+# for API level $ANDROID_API_LEVEL, or API 31 if unspecified.
set -euo pipefail
@@ -23,9 +23,9 @@ else
exit 1
fi
-[ -n "${ANDROID_API_LEVEL:-}" ] || { echo "ANDROID_API_LEVEL not defined; defaulting to 30"; }
+[ -n "${ANDROID_API_LEVEL:-}" ] || { echo "ANDROID_API_LEVEL not defined; defaulting to 31"; }
-API_LEVEL="${ANDROID_API_LEVEL:-30}"
+API_LEVEL="${ANDROID_API_LEVEL:-31}"
unzip -qo /tmp/tools.zip -d "${ANDROID_SDK_ROOT}/latest"
mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools"
@@ -39,4 +39,4 @@ export PATH="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${PATH}"
sdkmanager --install 'build-tools;30.0.3' platform-tools "platforms;android-${API_LEVEL}"
sdkmanager --install emulator
-sdkmanager --install "system-images;android-${API_LEVEL};google_apis;x86"
+sdkmanager --install "system-images;android-${API_LEVEL};google_apis;x86_64"
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 481b2706..689a6ec3 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -42,7 +42,6 @@ dependencyResolutionManagement {
includeGroupByRegex("androidx.*")
includeGroupByRegex("com.android.*")
includeGroup("com.google.android.gms")
- includeModule("com.google.android.material", "material")
}
}
maven("https://androidx.dev/storage/compose-compiler/repository") {
diff --git a/ui/compose/build.gradle.kts b/ui/compose/build.gradle.kts
index 81d1e4cd..10fb9065 100644
--- a/ui/compose/build.gradle.kts
+++ b/ui/compose/build.gradle.kts
@@ -21,6 +21,7 @@ dependencies {
api(libs.compose.material3)
api(libs.compose.ui.core)
implementation(libs.androidx.core.ktx)
- implementation(libs.compose.ui.tooling)
+ implementation(libs.compose.ui.tooling.preview)
+ debugImplementation(libs.compose.ui.tooling)
testImplementation(libs.bundles.testDependencies)
}
diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/theme/Theme.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/theme/Theme.kt
index 6d762fb4..957bf543 100644
--- a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/theme/Theme.kt
+++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/theme/Theme.kt
@@ -6,7 +6,8 @@
package app.passwordstore.ui.compose.theme
import android.app.Activity
-import android.os.Build
+import android.graphics.Color as AndroidColor
+import android.view.Window
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
@@ -15,6 +16,7 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
@@ -84,7 +86,7 @@ public fun APSTheme(
) {
val colorScheme =
when {
- dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ dynamicColor -> {
val context = LocalContext.current
if (darkTheme) {
dynamicDarkColorScheme(context)
@@ -98,8 +100,19 @@ public fun APSTheme(
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
- WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
+ val bars = WindowCompat.getInsetsController(window, view)
+ bars.isAppearanceLightStatusBars = !darkTheme
+ bars.isAppearanceLightNavigationBars = !darkTheme
+ paintBars(window, colorScheme.background.toArgb())
}
}
MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content)
}
+
+/** Colors the system bars, except in windows that keep them see-through (autofill decryption). */
+@Suppress("DEPRECATION") // Deprecated with edge-to-edge, which targetSdk 34 does not force.
+private fun paintBars(window: Window, color: Int) {
+ if (window.statusBarColor == AndroidColor.TRANSPARENT) return
+ window.statusBarColor = color
+ window.navigationBarColor = color
+}