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.md12
-rw-r--r--CHANGELOG.md61
-rw-r--r--CONTRIBUTING.md100
-rw-r--r--README.md19
-rw-r--r--SECURITY.md23
-rw-r--r--TODO.md20
-rw-r--r--app/build.gradle.kts3
-rw-r--r--app/lint.xml2
-rw-r--r--app/src/main/AndroidManifest.xml12
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt69
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt12
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt79
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt10
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt9
-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.kt36
-rw-r--r--app/src/main/java/app/passwordstore/ui/main/MainActivity.kt2
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt234
-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.kt14
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt30
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt2
-rw-r--r--app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt2
-rw-r--r--app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt2
-rw-r--r--app/src/main/java/app/passwordstore/util/log/LogcatLogger.kt9
-rw-r--r--app/src/main/java/app/passwordstore/util/services/CredentialService.kt134
-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_open_in_new_24dp.xml12
-rw-r--r--app/src/main/res/values-it/strings.xml24
-rw-r--r--app/src/main/res/values/strings.xml24
-rw-r--r--app/src/main/res/xml/credential_provider.xml10
-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/version.properties4
-rw-r--r--autofill-parser/src/main/assets/publicsuffixesbin148065 -> 148083 bytes
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt9
-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/ApplicationPlugin.kt20
-rw-r--r--gradle/libs.versions.toml2
-rw-r--r--media/kofi-button.pngbin0 -> 8935 bytes
-rw-r--r--ui/compose/build.gradle.kts3
49 files changed, 1029 insertions, 124 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 d0d8c52c..d93040af 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,7 +35,6 @@ minSdk is 31: GrapheneOS runs current Android only, and Android 12 has
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
@@ -180,6 +179,10 @@ signed minSdk 31 release build (fill, save, sync, abort rebase, hard reset), all
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.
@@ -201,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-05.
+Last run: 2026-11-09.
## Releases
@@ -216,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..6c9c6fee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,67 @@
All notable changes to this project will be documented in this file.
+## [Unreleased]
+
+### Added
+
+- About: "Report a security issue" links to the security policy, with the maintainer's OpenPGP key for private reports
+
+## [0.3.4] - 2026-11-09
+
+### Added
+
+- Credential Manager provider on Android 14 and later: apps that save or ask for a password through Android's password manager (e.g. Signal's backup key) can use HardenedPass, with the same matches and entry search as autofill
+
+## [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/CONTRIBUTING.md b/CONTRIBUTING.md
index f681cf52..97ea5dde 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,59 +1,101 @@
# Contribution Guidelines
-:tada: First off, thanks for taking the time to contribute! :tada:
-
-This document should be treated as suggestions, and not rules, on how to contribute to Android Password Store. All positive contribution is welcome, so please do not hesitate in pitching forth any ideas you have!
+Thanks for taking the time to contribute! This document is a set of suggestions, not rules,
+on how to contribute to HardenedPass. Ideas of any size are welcome.
## Table of contents
-- [Things to do before you start writing code](#things-to-do-before-you-start-writing-code)
+- [Before you start writing code](#before-you-start-writing-code)
- [Navigating the source code](#navigating-the-source-code)
- [Building the project](#building-the-project)
+- [Checks before pushing](#checks-before-pushing)
+- [Sending changes](#sending-changes)
-# Things to do before you start writing code
+## Before you start writing code
-If you're trying to fix a bug that already has an open issue, it's a good idea to drop a comment mentioning that you're working on a fix. If no open issue exists, please file one with the full details of what the bug is and how you intend to resolve it, and only start implementation once a maintainer has triaged the issue and has signed off on your approach. This allows maintainers to be able to review your pull request faster when it arrives, and saves you redundant effort of having to make potentially large changes in response to code review.
+If you want to fix a bug that already has an open
+[issue](https://github.com/danixland/HardenedPass/issues), comment there to say you are
+working on it. If there is no issue yet, open one with the details of the bug and how you
+intend to fix it, and wait for the maintainer to agree on the approach before you start. That
+makes review faster and saves you from large rework.
-If you want to add a new feature, please file an issue first to gauge maintainer and community interest. Sometimes the feature might need adjustments before it is accepted, or it might not be something we want to add to APS at all. Proposing the feature in detail before working on it ensures that you build exactly what is necessary and not waste time and effort on aspects that would get rejected during review.
+For a new feature, open an issue first too. It might need adjustments, or it might not fit the
+project's goal: a `pass` client that works flawlessly on GrapheneOS, with no Google Play
+Services and no telemetry. Agreeing on the details first means you build only what will be
+accepted.
-## Navigating the source code
+Security issues are the exception: do not open a public issue, follow
+[SECURITY.md](SECURITY.md) instead.
-The source code is split across 12 modules and 1 subproject.
+## Navigating the source code
-- `build-logic` and its modules host the Gradle build logic for the project.
-- `autofill-parser` is the aptly named parser for Android's Autofill structures that also deals with trust and feature detection for browsers.
-- `coroutine-utils` is a helper libraries that allow for effective usage and testing of [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-overview.html).
-- `crypto/common` is the foundation of our new, extensible cryptography APIs that adds the ability to introduce new cryptographic backends to APS with minimal effort.
-- `crypto/pgpainless` is the first of our new backends that implements the APIs defined in `crypto-common` to offer PGP cryptography through the [PGPainless](https://gh.pgpainless.org/) library.
-- `format/common` handles parsing the `pass` file format.
-- `passgen/diceware` is our new password generator that implements the [Diceware](https://theworld.com/~reinhold/diceware.html) algorithm.
+The source code is split across 9 modules, plus the `build-logic` included build.
+
+- `build-logic` hosts the Gradle build logic for the project.
+- `autofill-parser` parses Android's Autofill structures, and deals with trust and feature
+ detection for browsers.
+- `coroutine-utils` helps use and test
+ [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-overview.html).
+- `crypto/common` defines the cryptography APIs that backends implement.
+- `crypto/pgpainless` implements them with the [PGPainless](https://pgpainless.org/) library.
+- `format/common` parses the `pass` file format.
+- `passgen/diceware` is a password generator implementing the
+ [Diceware](https://theworld.com/~reinhold/diceware.html) algorithm.
- `passgen/random` contains the default password generator.
-- `ui/compose` has the theming code for building UI components in [Jetpack Compose](https://developer.android.com/jetpack/compose).
-- `app` is everything else that constitutes APS.
+- `ui/compose` has the theme and shared components for
+ [Jetpack Compose](https://developer.android.com/jetpack/compose).
+- `app` is everything else: the UI, git sync, the autofill service and the Credential Manager
+ provider.
-In most scenarios, the `app` directory is where you'd be contributing changes to. While most of the code has been rewritten and documented, there are still gnarly "legacy" parts that might be challenging to understand at a glance. Please get in touch via the [Discussions](https://github.com/android-password-store/Android-Password-Store/discussions) page with any questions you have, and we'd love to explain and improve things.
+Most changes land in `app`. Some older parts are hard to follow at a glance: ask on the
+[issue tracker](https://github.com/danixland/HardenedPass/issues) and they will be explained,
+and improved where possible.
-We bundle a [`ignore-revs-file`](https://git-scm.com/docs/git-blame#Documentation/git-blame.txt---ignore-revs-fileltfilegt) to ensure `git blame` is not affected by noisy changes. To make use of this, run `git config blame.ignoreRevsFile .git-blame-ignore-revs` from inside this repository. GitHub will automatically use this file for the blames it renders on the website.
+A [`ignore-revs-file`](https://git-scm.com/docs/git-blame#Documentation/git-blame.txt---ignore-revs-fileltfilegt)
+keeps `git blame` clear of noisy changes. To use it, run
+`git config blame.ignoreRevsFile .git-blame-ignore-revs` inside this repository. GitHub uses it
+automatically.
## Source code conventions
-- Unless you're absolutely sure what you're doing, always prefer the [`unsafeLazy`](https://github.com/android-password-store/Android-Password-Store/blob/f870dd49138d63d9803783458abf1356176851db/app/src/main/java/app/passwordstore/util/extensions/Extensions.kt#L68) method over Kotlin's inbuilt `lazy` for lazily evaluated properties.
-- The UI is Jetpack Compose (Material 3): screens are stateless composables driven by a ViewModel. Do not add XML layouts, Fragments or view binding.
+- Unless you are sure of what you are doing, prefer the `unsafeLazy` function in
+ `app/src/main/java/app/passwordstore/util/extensions/Extensions.kt` over Kotlin's `lazy` for
+ lazily evaluated properties.
+- The UI is Jetpack Compose (Material 3): screens are stateless composables driven by a
+ ViewModel. Do not add XML layouts, Fragments or view binding.
+- The Kotlin package stays `app.passwordstore`, while the application ID is
+ `xyz.danix.hardenedpass`. Do not rename the package.
+- No proprietary dependencies, no Google Play Services, no analytics or crash reporting.
## Building the project
-### Building with Gradle
+You need JDK 17 and the Android SDK. Set `sdk.dir` in `local.properties`, or use
+[Android Studio](https://developer.android.com/studio), which does it for you.
+
+Build a debug APK with:
+
+```shell
+./gradlew :app:assembleDebug
+```
+
+You can find it in `app/build/outputs/apk/debug/`. It installs as "HardenedPass Debug", next to
+the release app.
-This document assumes that you already have an Android development environment ready. If not, refer to Google's documentation on [installing Android Studio](https://developer.android.com/studio/preview). APS will build with all editions of Android Studio, but development typically happens with the Canary channel.
+## Checks before pushing
-Run the following command to generate a debug APK.
+CI runs these, with `CI=1` turning Kotlin warnings into errors. Run the same before you push:
```shell
-./gradlew collectDebugApks
+CI=1 ./gradlew spotlessCheck test -PslimTests :app:assembleDebug :app:assembleRelease \
+ lint metalavaCheckCompatibilityRelease
```
-You can find the generated APK at `app/outputs`.
+`./gradlew spotlessApply` fixes the formatting. To run most of these checks automatically on
+every push, install a Git pre-push hook with `./gradlew installGitHooks` (it has no effect if
+you set `core.hooksPath`).
-## Pre-push checks
+## Sending changes
-The project enforces code style conventions and library API stability by virtue of a carefully curated Gradle build. To setup a Git pre-push hook to run them automatically, run `./gradlew installGitHooks`.
+Open a pull request on [GitHub](https://github.com/danixland/HardenedPass/pulls). GitHub is a
+mirror of the main repository, so the maintainer applies accepted changes there, with your
+authorship kept, and closes the pull request instead of merging it.
diff --git a/README.md b/README.md
index 68b03bec..7eaf3f25 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,16 @@ Android Password Store authors.
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
@@ -45,6 +55,15 @@ Services.
Report bugs and feature requests on the
[issue tracker](https://github.com/danixland/HardenedPass/issues).
+Report security issues privately by email instead, as described in
+[SECURITY.md](SECURITY.md).
+
+## 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
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..d8c1f25c
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,23 @@
+# Security policy
+
+## Reporting a vulnerability
+
+Please report security issues privately, not on the public issue tracker.
+
+Email <danix@danix.xyz>, encrypted to the maintainer's OpenPGP key if you can:
+
+ 110E 1B11 1BA2 C3C7 CCD1 27B8 F96B 36CA CC44 17AE
+
+Fetch it with either of these, and check that the fingerprint matches:
+
+ gpg --locate-keys danix@danix.xyz
+ gpg --keyserver hkps://keys.openpgp.org --recv-keys 110E1B111BA2C3C7CCD127B8F96B36CACC4417AE
+
+Include the HardenedPass version (Settings, About), the Android version, and the
+steps to reproduce.
+
+## Supported versions
+
+Only the latest release gets fixes. No app store delivers HardenedPass updates:
+install the newest APK from
+[GitHub Releases](https://github.com/danixland/HardenedPass/releases/latest).
diff --git a/TODO.md b/TODO.md
index 610fa028..27ddf2da 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,12 +2,26 @@
Open work after 0.2.0. Remove an item in the commit that finishes it.
+## Bugs
+
+- 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.
+- Key import accepts GnuPG stub and smartcard keyrings, whose secret parts live on a card: they
+ pass `KeyUtils.isSecretKey` and only fail at decryption. Refuse them at import with a clear
+ message (check for a secret subkey usable for decryption).
+
## Features and UI
-- Push after save: saves, edits, moves and deletes commit locally but do not push, so a change can
- sit unpushed and collide with a later desktop change (a modify/delete conflict leaves the store
- in a stopped rebase). Push after each commit, or at least warn about unpushed commits.
+- 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.
+- OpenPGP card support (NFC and USB): requested on the password-store mailing list (2026-11-09).
+ Upstream reached cards through OpenKeychain until PGPainless replaced it in 2.0; PGPainless has
+ no card support, so decryption on the card needs its own OpenPGP card (ISO 7816) layer.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9ad30d32..b21d0aed 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -60,6 +60,7 @@ dependencies {
implementation(libs.androidx.autofill)
implementation(libs.androidx.biometricKtx)
implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.credentials)
implementation(libs.androidx.documentfile)
// FragmentActivity, which BiometricPrompt needs.
implementation(libs.androidx.fragment.ktx)
@@ -68,7 +69,7 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
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.xml b/app/lint.xml
index 42a888eb..6babd470 100644
--- a/app/lint.xml
+++ b/app/lint.xml
@@ -25,6 +25,8 @@
<ignore path="src/main/res/drawable/ic_miscellaneous_services_24px.xml" />
<ignore path="src/main/res/drawable/ic_miscellaneous_services_24px.xml" />
</issue>
+ <!-- The app is a Credential Manager provider (Android 14+), not a client: no Play Services. -->
+ <issue id="CredentialDependency" severity="ignore" />
<issue id="GradleDependency" severity="ignore" />
<issue id="ReportShortcutUsage" severity="ignore" />
<issue id="MissingTranslation" severity="ignore" />
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0eabbab4..026be6c8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -79,6 +79,18 @@
android:name="android.autofill"
android:resource="@xml/oreo_autofill_service" />
</service>
+ <service
+ android:name=".util.services.CredentialService"
+ android:exported="true"
+ android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE"
+ tools:targetApi="34">
+ <intent-filter>
+ <action android:name="android.service.credentials.CredentialProviderService" />
+ </intent-filter>
+ <meta-data
+ android:name="android.credentials.provider"
+ android:resource="@xml/credential_provider" />
+ </service>
<!-- ponytail: rotation, resizing and keyboard changes must not cancel a fill in progress;
other recreations (theme, locale) cancel it. -->
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 3b62de4c..38c86d86 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt
@@ -9,6 +9,7 @@ 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
@@ -23,6 +24,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.core.content.edit
+import androidx.credentials.GetCredentialResponse
+import androidx.credentials.PasswordCredential
+import androidx.credentials.provider.PendingIntentHandler
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import app.passwordstore.Application.Companion.screenWasOff
@@ -81,7 +85,8 @@ class AutofillDecryptActivity : FragmentActivity() {
private lateinit var directoryStructure: DirectoryStructure
private lateinit var filePath: String
- private lateinit var clientState: Bundle
+ /** The autofill request's state; null when Credential Manager asked. */
+ private var clientState: Bundle? = null
private lateinit var action: AutofillAction
private var clearCache = true
@@ -124,13 +129,15 @@ class AutofillDecryptActivity : FragmentActivity() {
finish()
return
}
- clientState =
- intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
- ?: run {
- logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
- finish()
- return
- }
+ val fromCredentialManager =
+ Build.VERSION.SDK_INT >= 34 &&
+ PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) != null
+ clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
+ if (clientState == null && !fromCredentialManager) {
+ logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
+ finish()
+ return
+ }
val isSearchAction = intent.getBooleanExtra(EXTRA_SEARCH_ACTION, true)
action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
directoryStructure = AutofillPreferences.directoryStructure(this)
@@ -278,26 +285,34 @@ class AutofillDecryptActivity : FragmentActivity() {
askPassphrase(identifiers, wrong = true)
return
}
- if (credentials == null) {
- setResult(RESULT_CANCELED)
- } else {
- val fillInDataset =
- AutofillResponseBuilder.makeFillInDataset(
- this@AutofillDecryptActivity,
- credentials,
- clientState,
- action,
- )
- withContext(dispatcherProvider.main()) {
- setResult(
- RESULT_OK,
- Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) },
- )
- }
+ val result = credentials?.let(::resultIntent)
+ withContext(dispatcherProvider.main()) {
+ if (result == null) setResult(RESULT_CANCELED) else setResult(RESULT_OK, result)
}
withContext(dispatcherProvider.main()) { finish() }
}
+ /** The fill dataset, or the password for Credential Manager; null for an entry without one. */
+ private fun resultIntent(credentials: Credentials): Intent? {
+ val state = clientState
+ if (state != null) {
+ val fillInDataset =
+ AutofillResponseBuilder.makeFillInDataset(this, credentials, state, action)
+ return Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
+ }
+ if (Build.VERSION.SDK_INT < 34) return null
+ val password = credentials.password?.takeUnless { it.isEmpty() } ?: return null
+ // PasswordCredential rejects an empty id: fall back to the entry's name.
+ val id =
+ credentials.username?.takeUnless { it.isEmpty() } ?: File(filePath).nameWithoutExtension
+ return Intent().also {
+ PendingIntentHandler.setGetCredentialResponse(
+ it,
+ GetCredentialResponse(PasswordCredential(id, password)),
+ )
+ }
+ }
+
private suspend fun decryptCredential(
file: File,
password: String,
@@ -357,6 +372,12 @@ class AutofillDecryptActivity : FragmentActivity() {
}
}
+ /** Decrypts [file] for a Credential Manager request, which the system adds to the intent. */
+ fun makeCredentialIntent(file: File, context: Context): Intent =
+ Intent(context, AutofillDecryptActivity::class.java)
+ .putExtra(EXTRA_SEARCH_ACTION, false)
+ .putExtra(EXTRA_FILE_PATH, file.absolutePath)
+
fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
val intent =
Intent(context, AutofillDecryptActivity::class.java).apply {
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 474005b5..799015cb 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt
@@ -8,6 +8,7 @@ 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
@@ -19,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.credentials.provider.PendingIntentHandler
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.passwordstore.R
@@ -58,6 +60,11 @@ class AutofillFilterView : FragmentActivity() {
"app.passwordstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP"
private var matchAndDecryptFileRequestCode = 1
+ /** The picker for a Credential Manager request, which the system adds to the intent. */
+ fun makeCredentialIntent(context: Context, formOrigin: FormOrigin.App): Intent =
+ Intent(context, AutofillFilterView::class.java)
+ .putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier)
+
fun makeMatchAndDecryptFileIntentSender(
context: Context,
formOrigin: FormOrigin,
@@ -107,7 +114,10 @@ class AutofillFilterView : FragmentActivity() {
params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt()
window.attributes = params
- if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) {
+ val fromCredentialManager =
+ Build.VERSION.SDK_INT >= 34 &&
+ PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) != null
+ if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true && !fromCredentialManager) {
logcat(ERROR) { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" }
finish()
return
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 2ed0adef..d32df6ca 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt
@@ -8,11 +8,15 @@ 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.WindowManager
import android.view.autofill.AutofillManager
import androidx.activity.compose.setContent
import androidx.core.os.bundleOf
+import androidx.credentials.CreatePasswordRequest
+import androidx.credentials.CreatePasswordResponse
+import androidx.credentials.provider.PendingIntentHandler
import androidx.fragment.app.FragmentActivity
import app.passwordstore.data.passfile.PasswordEntry
import app.passwordstore.ui.compose.theme.APSTheme
@@ -60,6 +64,24 @@ class AutofillSaveActivity : FragmentActivity() {
credentials: Credentials?,
formOrigin: FormOrigin,
): IntentSender {
+ val intent =
+ Intent(context, AutofillSaveActivity::class.java)
+ .putExtras(saveExtras(context, credentials, formOrigin))
+ return PendingIntent.getActivity(
+ context,
+ saveRequestCode++,
+ intent,
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ .intentSender
+ }
+
+ /** The editor's prefill: folder and name from [formOrigin] and the username. */
+ private fun saveExtras(
+ context: Context,
+ credentials: Credentials?,
+ formOrigin: FormOrigin,
+ ): Bundle {
val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false)
// Prevent directory traversals
val sanitizedIdentifier =
@@ -76,28 +98,14 @@ class AutofillSaveActivity : FragmentActivity() {
username = credentials?.username,
identifier = identifier,
)
- val intent =
- Intent(context, AutofillSaveActivity::class.java).apply {
- putExtras(
- bundleOf(
- EXTRA_FOLDER_NAME to folderName,
- EXTRA_NAME to fileName,
- EXTRA_PASSWORD to credentials?.password,
- EXTRA_SHOULD_MATCH_APP to
- formOrigin.identifier.takeIf { formOrigin is FormOrigin.App },
- EXTRA_SHOULD_MATCH_WEB to
- formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web },
- EXTRA_GENERATE_PASSWORD to (credentials == null),
- )
- )
- }
- return PendingIntent.getActivity(
- context,
- saveRequestCode++,
- intent,
- PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
- .intentSender
+ return bundleOf(
+ EXTRA_FOLDER_NAME to folderName,
+ EXTRA_NAME to fileName,
+ EXTRA_PASSWORD to credentials?.password,
+ EXTRA_SHOULD_MATCH_APP to formOrigin.identifier.takeIf { formOrigin is FormOrigin.App },
+ EXTRA_SHOULD_MATCH_WEB to formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web },
+ EXTRA_GENERATE_PASSWORD to (credentials == null),
+ )
}
}
@@ -117,8 +125,29 @@ class AutofillSaveActivity : FragmentActivity() {
@Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
+ /** Set when Credential Manager asked to save a password, which then answers it. */
+ private var fromCredentialManager = false
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ if (Build.VERSION.SDK_INT >= 34) {
+ PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)?.let { request ->
+ val create =
+ request.callingRequest as? CreatePasswordRequest
+ ?: run {
+ finish()
+ return
+ }
+ fromCredentialManager = true
+ intent.putExtras(
+ saveExtras(
+ this,
+ Credentials(create.id, create.password, null),
+ FormOrigin.App(request.callingAppInfo.packageName),
+ )
+ )
+ }
+ }
// The whole window is the editor: keep it out of screenshots and recents.
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
val initial =
@@ -149,7 +178,11 @@ class AutofillSaveActivity : FragmentActivity() {
private fun finishSaved(saved: SaveResult.Saved) {
formOrigin?.let { AutofillMatcher.addMatchFor(this, it, saved.file) }
val resultIntent =
- if (generate) {
+ if (fromCredentialManager && Build.VERSION.SDK_INT >= 34) {
+ Intent().also {
+ PendingIntentHandler.setCreateCredentialResponse(it, CreatePasswordResponse())
+ }
+ } else if (generate) {
val clientState =
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
?: run {
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 4a6813f7..626ec130 100644
--- a/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt
+++ b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt
@@ -48,12 +48,14 @@ fun getLongName(fullPath: String, repositoryPath: String, basename: String): Str
}
}
-/** Copies provided [text] to the clipboard, flagged sensitive. */
-fun FragmentActivity.copyTextToClipboard(text: String?) {
+/** 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) }
+ 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 8e433957..617d804a 100644
--- a/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt
+++ b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt
@@ -35,7 +35,9 @@ 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
@@ -148,12 +150,15 @@ fun EditorDestination(
}
// 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/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 cff0964c..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,7 +6,9 @@
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
@@ -20,12 +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.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.onSuccess
import java.io.File
import java.nio.file.Files
+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. */
@@ -64,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()
@@ -72,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) },
@@ -116,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]. */
@@ -151,7 +179,7 @@ private suspend fun BaseGitActivity.moveFile(source: File, destination: File): B
}
}
if (!moved) {
- logcat(ERROR) { "Something went wrong while moving $source to $destination." }
+ 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),
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 f3d7d3a0..5037e646 100644
--- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
@@ -30,6 +30,7 @@ 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
@@ -130,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)
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..6c20d3af
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt
@@ -0,0 +1,234 @@
+/*
+ * 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") }
+ LinkRow(stringResource(R.string.about_security)) {
+ onOpen("$REPOSITORY/blob/master/SECURITY.md")
+ }
+ // 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/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 d13d1cf5..2e7840ad 100644
--- a/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt
@@ -63,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,
@@ -258,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),
@@ -318,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/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/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/extensions/AndroidExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
index 382a5617..e24f96ca 100644
--- a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
+++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
@@ -71,7 +71,7 @@ suspend fun FragmentActivity.commitChange(message: String): Result<Unit, Throwab
)
override suspend fun preExecute(): Boolean {
- logcat { "Committing with message: '$message'" }
+ logcat { "Committing" }
return true
}
}
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/CredentialService.kt b/app/src/main/java/app/passwordstore/util/services/CredentialService.kt
new file mode 100644
index 00000000..97b2666f
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/util/services/CredentialService.kt
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package app.passwordstore.util.services
+
+import android.app.PendingIntent
+import android.content.Intent
+import android.os.CancellationSignal
+import android.os.OutcomeReceiver
+import androidx.annotation.RequiresApi
+import androidx.credentials.exceptions.ClearCredentialException
+import androidx.credentials.exceptions.CreateCredentialException
+import androidx.credentials.exceptions.GetCredentialException
+import androidx.credentials.provider.Action
+import androidx.credentials.provider.BeginCreateCredentialRequest
+import androidx.credentials.provider.BeginCreateCredentialResponse
+import androidx.credentials.provider.BeginCreatePasswordCredentialRequest
+import androidx.credentials.provider.BeginGetCredentialRequest
+import androidx.credentials.provider.BeginGetCredentialResponse
+import androidx.credentials.provider.BeginGetPasswordOption
+import androidx.credentials.provider.CallingAppInfo
+import androidx.credentials.provider.CreateEntry
+import androidx.credentials.provider.CredentialProviderService
+import androidx.credentials.provider.PasswordCredentialEntry
+import androidx.credentials.provider.ProviderClearCredentialStateRequest
+import app.passwordstore.R
+import app.passwordstore.ui.autofill.AutofillDecryptActivity
+import app.passwordstore.ui.autofill.AutofillFilterView
+import app.passwordstore.ui.autofill.AutofillSaveActivity
+import app.passwordstore.util.autofill.AutofillMatcher
+import app.passwordstore.util.autofill.makeFillMatchMetadata
+import com.github.androidpasswordstore.autofillparser.FormOrigin
+import com.github.michaelbull.result.getOrElse
+import logcat.LogPriority.WARN
+import logcat.logcat
+
+/**
+ * Credential Manager provider (Android 14+): apps that save or ask for a password through
+ * `CredentialManager` (e.g. Signal's backup key) reach the store here. It reuses the autofill
+ * matches, picker, decryption and save activities, keyed by the calling app's package.
+ */
+@RequiresApi(34)
+class CredentialService : CredentialProviderService() {
+
+ private var requestCode = 1
+
+ /**
+ * The calling app, or null for a browser asking for a website: its origin would need a
+ * privileged-browser allowlist, and autofill already covers browsers.
+ */
+ private fun appOrigin(info: CallingAppInfo?): FormOrigin.App? =
+ info?.takeUnless { it.isOriginPopulated() }?.let { FormOrigin.App(it.packageName) }
+
+ private fun pendingIntent(intent: Intent): PendingIntent =
+ PendingIntent.getActivity(
+ this,
+ requestCode++,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
+ )
+
+ override fun onBeginCreateCredentialRequest(
+ request: BeginCreateCredentialRequest,
+ cancellationSignal: CancellationSignal,
+ callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,
+ ) {
+ val origin = appOrigin(request.callingAppInfo)
+ if (request !is BeginCreatePasswordCredentialRequest || origin == null) {
+ callback.onResult(BeginCreateCredentialResponse())
+ return
+ }
+ // The username and password arrive in the activity's intent once the user picks this entry.
+ val entry =
+ CreateEntry.Builder(
+ applicationInfo.loadLabel(packageManager),
+ pendingIntent(Intent(this, AutofillSaveActivity::class.java)),
+ )
+ .build()
+ callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))
+ }
+
+ override fun onBeginGetCredentialRequest(
+ request: BeginGetCredentialRequest,
+ cancellationSignal: CancellationSignal,
+ callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,
+ ) {
+ val origin = appOrigin(request.callingAppInfo)
+ val option = request.beginGetCredentialOptions.filterIsInstance<BeginGetPasswordOption>()
+ if (origin == null || option.isEmpty()) {
+ callback.onResult(BeginGetCredentialResponse())
+ return
+ }
+ // ponytail: a changed publisher gets no entries at all; autofill shows a warning dataset
+ // instead. Offer AutofillPublisherChangedActivity here if this ever bites.
+ val files =
+ AutofillMatcher.getMatchesFor(this, origin).getOrElse {
+ logcat(WARN) { it.message.orEmpty() }
+ callback.onResult(BeginGetCredentialResponse())
+ return
+ }
+ val entries =
+ option.flatMap { opt ->
+ files.map { file ->
+ val metadata = makeFillMatchMetadata(this, file)
+ PasswordCredentialEntry.Builder(
+ this,
+ metadata.subtitle ?: metadata.title,
+ pendingIntent(AutofillDecryptActivity.makeCredentialIntent(file, this)),
+ opt,
+ )
+ .setDisplayName(metadata.title)
+ .build()
+ }
+ }
+ val search =
+ Action(
+ getString(R.string.oreo_autofill_search_in_store),
+ pendingIntent(AutofillFilterView.makeCredentialIntent(this, origin)),
+ )
+ callback.onResult(
+ BeginGetCredentialResponse(credentialEntries = entries, actions = listOf(search))
+ )
+ }
+
+ override fun onClearCredentialStateRequest(
+ request: ProviderClearCredentialStateRequest,
+ cancellationSignal: CancellationSignal,
+ callback: OutcomeReceiver<Void?, ClearCredentialException>,
+ ) {
+ // Nothing is signed in: there is no state to clear.
+ callback.onResult(null)
+ }
+}
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_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/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index e15b903f..18065976 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -288,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>
@@ -379,4 +386,21 @@
<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_security">Segnala un problema di sicurezza</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/strings.xml b/app/src/main/res/values/strings.xml
index 755fa9bc..ec2396be 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -126,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>
@@ -413,4 +420,21 @@
<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_security">Report a security issue</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/xml/credential_provider.xml b/app/src/main/res/xml/credential_provider.xml
new file mode 100644
index 00000000..d8e2d5b8
--- /dev/null
+++ b/app/src/main/res/xml/credential_provider.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8" ?><!--
+ ~ Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ ~ SPDX-License-Identifier: GPL-3.0-only
+ -->
+
+<credential-provider xmlns:android="http://schemas.android.com/apk/res/android">
+ <capabilities>
+ <capability name="android.credentials.TYPE_PASSWORD_CREDENTIAL" />
+ </capabilities>
+</credential-provider>
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/version.properties b/app/version.properties
index 77a7d569..cd3fecb2 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=304
+versioning-plugin.versionName=0.3.4
diff --git a/autofill-parser/src/main/assets/publicsuffixes b/autofill-parser/src/main/assets/publicsuffixes
index 84cf47df..0cf30bf1 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/FormField.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt
index b8df0664..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
@@ -141,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()
@@ -292,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/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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3439f128..03eb4c1b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -17,6 +17,7 @@ androidx-annotation = "androidx.annotation:annotation:1.8.2"
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"
+androidx-credentials = "androidx.credentials:credentials:1.5.0"
androidx-documentfile = "androidx.documentfile:documentfile:1.1.0"
androidx-fragment-ktx = "androidx.fragment:fragment-ktx:1.8.4"
androidx-hilt-navigation-compose = "androidx.hilt:hilt-navigation-compose:1.2.0"
@@ -47,6 +48,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/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/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)
}