Compare commits

...
108 Commits
Author SHA1 Message Date
sora 5a76638f27 Integrate Keepass2android functionality with standalone 2026-09-16 23:04:06 +02:00
sora d05b34573e Re-add keepass2android plugin 2026-09-16 22:01:33 +02:00
sora 732f9558f6 update build and gradle 2026-09-16 22:00:19 +02:00
bot 0d0b2e7091 Merge pull request 'Update plugin com.autonomousapps.dependency-analysis to v3.19.2' (#329) from renovate/com.autonomousapps.dependency-analysis-3.x into main 2026-09-15 04:02:17 +02:00
Renovate Bot 6340a7b686 Update plugin com.autonomousapps.dependency-analysis to v3.19.2 2026-09-15 02:02:14 +00:00
bot 1e30b1a930 Merge pull request 'Update dependency androidx.compose:compose-bom to v2026.09.00' (#328) from renovate/androidx.compose-compose-bom-2026.x into main 2026-09-11 04:02:25 +02:00
bot d39502a5a2 Merge pull request 'Update dependency androidx.navigation:navigation-compose to v2.10.1' (#327) from renovate/androidx.navigation-navigation-compose-2.x into main 2026-09-11 04:02:21 +02:00
Renovate Bot fee79f0030 Update dependency androidx.compose:compose-bom to v2026.09.00 2026-09-11 02:02:21 +00:00
Renovate Bot 06cc53f28b Update dependency androidx.navigation:navigation-compose to v2.10.1 2026-09-11 02:02:19 +00:00
bot 1ce8ac4a92 Merge pull request 'Update dependency androidx.compose.ui:ui-tooling to v1.12.1' (#326) from renovate/androidx.compose.ui-ui-tooling-1.x into main 2026-09-10 04:03:37 +02:00
bot 6d95a794cf Merge pull request 'Update dependency androidx.compose.material:material to v1.12.1' (#325) from renovate/androidx.compose.material-material-1.x into main 2026-09-10 04:03:17 +02:00
Renovate Bot d8d8bc72ed Update dependency androidx.compose.ui:ui-tooling to v1.12.1 2026-09-10 02:03:13 +00:00
Renovate Bot 639b16e317 Update dependency androidx.compose.material:material to v1.12.1 2026-09-10 02:03:06 +00:00
bot dcff45dcc4 Merge pull request 'Update plugin org.jetbrains.kotlin.plugin.serialization to v2.4.20' (#324) from renovate/org.jetbrains.kotlin.plugin.serialization-2.x into main 2026-09-09 04:01:50 +02:00
Renovate Bot d75ed155c8 Update plugin org.jetbrains.kotlin.plugin.serialization to v2.4.20 2026-09-09 02:01:47 +00:00
bot 790a3e91bb Merge pull request 'Update plugin org.jetbrains.kotlin.plugin.compose to v2.4.20' (#323) from renovate/org.jetbrains.kotlin.plugin.compose-2.x into main 2026-09-08 04:01:47 +02:00
bot d8f4774757 Merge pull request 'Update plugin org.jetbrains.kotlin.android to v2.4.20' (#322) from renovate/org.jetbrains.kotlin.android-2.x into main 2026-09-08 04:01:44 +02:00
Renovate Bot c462813cc4 Update plugin org.jetbrains.kotlin.plugin.compose to v2.4.20 2026-09-08 02:01:43 +00:00
Renovate Bot f5d7de95a2 Update plugin org.jetbrains.kotlin.android to v2.4.20 2026-09-08 02:01:41 +00:00
bot d83a7f81e6 Merge pull request 'Update plugin com.android.library to v9.4.0' (#321) from renovate/com.android.library-9.x into main 2026-09-03 04:03:09 +02:00
Renovate Bot 8e8d0f5398 Update plugin com.android.library to v9.4.0 2026-09-03 02:02:28 +00:00
bot 0d3c732999 Merge pull request 'Update plugin com.android.application to v9.4.0' (#320) from renovate/com.android.application-9.x into main 2026-09-02 04:07:33 +02:00
Renovate Bot b5b09d6cfe Update plugin com.android.application to v9.4.0 2026-09-02 02:07:00 +00:00
bot fa57d697ff Merge pull request 'Update actions/setup-java action to v6' (#315) from renovate/actions-setup-java-6.x into main 2026-08-29 04:03:04 +02:00
Renovate Bot 622af28e49 Update actions/setup-java action to v6 2026-08-29 02:03:02 +00:00
bot 1dc05e561e Merge pull request 'Update plugin com.android.library to v9.3.2' (#313) from renovate/com.android.library-9.x into main 2026-08-29 04:02:53 +02:00
Renovate Bot 4c779e5e80 Update plugin com.android.library to v9.3.2 2026-08-29 02:02:51 +00:00
bot 776fe395d8 Merge pull request 'Update dependency androidx.navigation:navigation-compose to v2.10.0' (#319) from renovate/androidx.navigation-navigation-compose-2.x into main 2026-08-28 04:02:23 +02:00
Renovate Bot 1703171b1a Update dependency androidx.navigation:navigation-compose to v2.10.0 2026-08-28 02:02:21 +00:00
bot e20f80c97e Merge pull request 'Update dependency androidx.camera:camera-view to v1.6.2' (#318) from renovate/androidx.camera-camera-view-1.x into main 2026-08-28 04:02:20 +02:00
Renovate Bot 28fbc9f050 Update dependency androidx.camera:camera-view to v1.6.2 2026-08-28 02:02:16 +00:00
bot 5f33dd6168 Merge pull request 'Update dependency androidx.camera:camera-lifecycle to v1.6.2' (#317) from renovate/androidx.camera-camera-lifecycle-1.x into main 2026-08-27 04:02:23 +02:00
bot e3812710b6 Merge pull request 'Update dependency androidx.camera:camera-camera2 to v1.6.2' (#316) from renovate/androidx.camera-camera-camera2-1.x into main 2026-08-27 04:02:19 +02:00
Renovate Bot 1b6140c7f7 Update dependency androidx.camera:camera-lifecycle to v1.6.2 2026-08-27 02:02:18 +00:00
Renovate Bot 191ba40623 Update dependency androidx.camera:camera-camera2 to v1.6.2 2026-08-27 02:02:15 +00:00
bot 4e81d7a625 Merge pull request 'Update plugin com.autonomousapps.dependency-analysis to v3.19.1' (#314) from renovate/com.autonomousapps.dependency-analysis-3.x into main 2026-08-26 04:03:55 +02:00
Renovate Bot fec817623f Update plugin com.autonomousapps.dependency-analysis to v3.19.1 2026-08-26 02:03:53 +00:00
bot 20d7916c16 Merge pull request 'Update plugin com.android.application to v9.3.2' (#312) from renovate/com.android.application-9.x into main 2026-08-25 04:02:26 +02:00
Renovate Bot 3928ce7a05 Update plugin com.android.application to v9.3.2 2026-08-25 02:02:23 +00:00
bot a9c7a7ca7d Merge pull request 'Update dependency gradle to v9.7.1' (#311) from renovate/gradle-9.x into main 2026-08-20 04:02:02 +02:00
Renovate Bot 93471f0827 Update dependency gradle to v9.7.1 2026-08-20 02:01:58 +00:00
bot ba70aa5982 Merge pull request 'Update dependency androidx.compose.material:material to v1.12.0' (#309) from renovate/androidx.compose.material-material-1.x into main 2026-08-15 04:04:32 +02:00
Renovate Bot 319e09df22 Update dependency androidx.compose.material:material to v1.12.0 2026-08-15 02:04:29 +00:00
bot a49f5413a5 Merge pull request 'Update dependency androidx.compose.ui:ui-tooling to v1.12.0' (#310) from renovate/androidx.compose.ui-ui-tooling-1.x into main 2026-08-14 04:03:40 +02:00
Renovate Bot 7846320df7 Update dependency androidx.compose.ui:ui-tooling to v1.12.0 2026-08-14 02:03:37 +00:00
bot 1b3f5ef7cf Merge pull request 'Update dependency androidx.compose:compose-bom to v2026.08.00' (#308) from renovate/androidx.compose-compose-bom-2026.x into main 2026-08-13 04:02:15 +02:00
Renovate Bot 730d5a3650 Update dependency androidx.compose:compose-bom to v2026.08.00 2026-08-13 02:02:12 +00:00
bot c147eafe90 Merge pull request 'Update dependency gradle to v9.7.0' (#307) from renovate/gradle-9.x into main 2026-08-07 04:02:18 +02:00
Renovate Bot dba7b7110f Update dependency gradle to v9.7.0 2026-08-07 02:02:14 +00:00
bot 1d199b7581 Merge pull request 'Update plugin com.autonomousapps.dependency-analysis to v3.18.0' (#306) from renovate/com.autonomousapps.dependency-analysis-3.x into main 2026-07-28 04:01:58 +02:00
Renovate Bot ff63b537f6 Update plugin com.autonomousapps.dependency-analysis to v3.18.0 2026-07-28 02:01:53 +00:00
bot 302b5bc1bd Merge pull request 'Update dependency joda-time:joda-time to v2.14.3' (#305) from renovate/joda-time-joda-time-2.x into main 2026-07-27 04:01:42 +02:00
Renovate Bot 156757d23a Update dependency joda-time:joda-time to v2.14.3 2026-07-27 02:01:37 +00:00
bot 5d39635c66 Merge pull request 'Update plugin com.android.library to v9.3.1' (#304) from renovate/com.android.library-9.x into main 2026-07-25 04:02:21 +02:00
Renovate Bot d93307086b Update plugin com.android.library to v9.3.1 2026-07-25 02:02:16 +00:00
bot e08cbe4f7b Merge pull request 'Update plugin com.android.application to v9.3.1' (#303) from renovate/com.android.application-9.x into main 2026-07-24 04:03:43 +02:00
Renovate Bot e768e92515 Update plugin com.android.application to v9.3.1 2026-07-24 02:03:37 +00:00
bot 78c5ca5a88 Merge pull request 'Update plugin com.android.application to v9.3.0' (#300) from renovate/com.android.application-9.x into main 2026-07-19 04:02:49 +02:00
Renovate Bot 21fe4a66b1 Update plugin com.android.application to v9.3.0 2026-07-18 02:03:14 +00:00
bot fd4a521d0f Merge pull request 'Update plugin com.autonomousapps.dependency-analysis to v3.17.0' (#302) from renovate/com.autonomousapps.dependency-analysis-3.x into main 2026-07-17 04:02:12 +02:00
bot ed1f05eed5 Merge pull request 'Update plugin com.android.library to v9.3.0' (#301) from renovate/com.android.library-9.x into main 2026-07-17 04:02:07 +02:00
Renovate Bot b8232c9a19 Update plugin com.autonomousapps.dependency-analysis to v3.17.0 2026-07-17 02:02:05 +00:00
Renovate Bot af52816559 Update plugin com.android.library to v9.3.0 2026-07-17 02:02:04 +00:00
bot 90ba769c44 Merge pull request 'Update plugin org.jetbrains.kotlin.plugin.serialization to v2.4.10' (#299) from renovate/org.jetbrains.kotlin.plugin.serialization-2.x into main 2026-07-16 04:01:58 +02:00
Renovate Bot 505ec55c50 Update plugin org.jetbrains.kotlin.plugin.serialization to v2.4.10 2026-07-16 02:01:55 +00:00
bot a352ebd21d Merge pull request 'Update plugin org.jetbrains.kotlin.plugin.compose to v2.4.10' (#298) from renovate/org.jetbrains.kotlin.plugin.compose-2.x into main 2026-07-15 04:02:37 +02:00
bot ee686c4e0c Merge pull request 'Update plugin org.jetbrains.kotlin.android to v2.4.10' (#297) from renovate/org.jetbrains.kotlin.android-2.x into main 2026-07-15 04:02:34 +02:00
Renovate Bot cefc7032bd Update plugin org.jetbrains.kotlin.plugin.compose to v2.4.10 2026-07-15 02:02:32 +00:00
Renovate Bot 26c1142375 Update plugin org.jetbrains.kotlin.android to v2.4.10 2026-07-15 02:02:29 +00:00
bot 20ef7ed04a Merge pull request 'Update plugin com.autonomousapps.dependency-analysis to v3.16.1' (#296) from renovate/com.autonomousapps.dependency-analysis-3.x into main 2026-07-08 04:02:24 +02:00
Renovate Bot 7f9749ef07 Update plugin com.autonomousapps.dependency-analysis to v3.16.1 2026-07-08 02:02:21 +00:00
bot a907102c19 Merge pull request 'Update dependency androidx.compose.ui:ui-tooling to v1.11.4' (#295) from renovate/androidx.compose.ui-ui-tooling-1.x into main 2026-07-03 04:02:25 +02:00
Renovate Bot 8c5f0f4d15 Update dependency androidx.compose.ui:ui-tooling to v1.11.4 2026-07-03 02:02:22 +00:00
bot 367697b66b Merge pull request 'Update dependency androidx.compose.material:material to v1.11.4' (#294) from renovate/androidx.compose.material-material-1.x into main 2026-07-02 04:03:48 +02:00
bot 000ad7f979 Merge pull request 'Update dependency androidx.compose:compose-bom to v2026.06.01' (#293) from renovate/androidx.compose-compose-bom-2026.x into main 2026-07-02 04:03:45 +02:00
Renovate Bot e320c22bce Update dependency androidx.compose.material:material to v1.11.4 2026-07-02 02:03:43 +00:00
Renovate Bot 72087c72e6 Update dependency androidx.compose:compose-bom to v2026.06.01 2026-07-02 02:03:41 +00:00
bot 5ef09f0f9b Merge pull request 'Update dependency gradle to v9.6.1' (#292) from renovate/gradle-9.x into main 2026-06-27 04:02:41 +02:00
Renovate Bot 4997107aa4 Update dependency gradle to v9.6.1 2026-06-27 02:02:39 +00:00
bot 7665723ea6 Merge pull request 'Update plugin com.autonomousapps.dependency-analysis to v3.16.0' (#291) from renovate/com.autonomousapps.dependency-analysis-3.x into main 2026-06-26 04:02:03 +02:00
Renovate Bot 62dc4cf0d5 Update plugin com.autonomousapps.dependency-analysis to v3.16.0 2026-06-26 02:02:01 +00:00
bot bdb15389d8 Merge pull request 'Update actions/checkout action to v7' (#290) from renovate/actions-checkout-7.x into main 2026-06-20 04:04:30 +02:00
Renovate Bot d2ca79ac57 Update actions/checkout action to v7 2026-06-20 02:04:27 +00:00
bot 3034f08bf4 Merge pull request 'Update dependency gradle to v9.6.0' (#289) from renovate/gradle-9.x into main 2026-06-19 04:03:32 +02:00
Renovate Bot 529fb2cf16 Update dependency gradle to v9.6.0 2026-06-19 02:03:19 +00:00
bot 27ead287ae Merge pull request 'Update dependency androidx.compose:compose-bom to v2026.06.00' (#288) from renovate/androidx.compose-compose-bom-2026.x into main 2026-06-19 04:03:01 +02:00
Renovate Bot a6dce9a524 Update dependency androidx.compose:compose-bom to v2026.06.00 2026-06-19 02:02:57 +00:00
bot 45bbc6d90b Merge pull request 'Update dependency androidx.compose.ui:ui-tooling to v1.11.3' (#287) from renovate/androidx.compose.ui-ui-tooling-1.x into main 2026-06-18 04:02:16 +02:00
bot fb62271321 Merge pull request 'Update dependency androidx.compose.material:material to v1.11.3' (#286) from renovate/androidx.compose.material-material-1.x into main 2026-06-18 04:02:14 +02:00
Renovate Bot 57885f9882 Update dependency androidx.compose.ui:ui-tooling to v1.11.3 2026-06-18 02:02:13 +00:00
Renovate Bot a04f0aa75a Update dependency androidx.compose.material:material to v1.11.3 2026-06-18 02:02:11 +00:00
bot 695f748be5 Merge pull request 'Update plugin com.autonomousapps.dependency-analysis to v3.15.0' (#285) from renovate/com.autonomousapps.dependency-analysis-3.x into main 2026-06-11 04:02:54 +02:00
Renovate Bot c85e9b6dfa Update plugin com.autonomousapps.dependency-analysis to v3.15.0 2026-06-11 02:02:46 +00:00
bot 283fa26763 Merge pull request 'Update plugin org.jetbrains.kotlin.plugin.serialization to v2.4.0' (#284) from renovate/org.jetbrains.kotlin.plugin.serialization-2.x into main 2026-06-05 04:15:35 +02:00
Renovate Bot cc1272f297 Update plugin org.jetbrains.kotlin.plugin.serialization to v2.4.0 2026-06-05 02:07:02 +00:00
bot 4510ff6f57 Merge pull request 'Update plugin org.jetbrains.kotlin.plugin.compose to v2.4.0' (#283) from renovate/org.jetbrains.kotlin.plugin.compose-2.x into main 2026-06-04 04:12:36 +02:00
bot 155595eca1 Merge pull request 'Update plugin org.jetbrains.kotlin.android to v2.4.0' (#282) from renovate/org.jetbrains.kotlin.android-2.x into main 2026-06-04 04:12:33 +02:00
Renovate Bot 25c6456bff Update plugin org.jetbrains.kotlin.plugin.compose to v2.4.0 2026-06-04 02:12:31 +00:00
Renovate Bot c153710f3d Update plugin org.jetbrains.kotlin.android to v2.4.0 2026-06-04 02:12:28 +00:00
bot 2154b74413 Merge pull request 'Update plugin com.autonomousapps.dependency-analysis to v3.14.1' (#281) from renovate/com.autonomousapps.dependency-analysis-3.x into main 2026-06-02 04:04:00 +02:00
Renovate Bot 132d1317a0 Update plugin com.autonomousapps.dependency-analysis to v3.14.1 2026-06-02 02:03:51 +00:00
sora 1468124c23 Update README.md 2026-05-29 18:49:59 +02:00
sora dae511c2a2 Update README.md 2026-05-29 18:49:47 +02:00
bot b89d49d407 Merge pull request 'Update plugin com.autonomousapps.dependency-analysis to v3.14.0' (#280) from renovate/com.autonomousapps.dependency-analysis-3.x into main 2026-05-29 04:02:42 +02:00
Renovate Bot 0fee2c31ab Update plugin com.autonomousapps.dependency-analysis to v3.14.0 2026-05-29 02:02:33 +00:00
soraefir eba6b40779 Add NDK version 2026-05-24 13:26:39 +02:00
sora 93442645fc Update README.md 2026-05-24 11:59:19 +02:00
sora f71399f6fa Update .github/workflows/build.yml 2026-05-23 15:44:10 +02:00
26 changed files with 1359 additions and 367 deletions
+31 -11
View File
@@ -23,14 +23,16 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: true
fetch-depth: 0
fetch-depth: 0
- name: Make script executable
run: chmod +x ./patch_submodule.sh
- name: Run patch submodule script
run: ./patch_submodule.sh
- name: set up secrets
run: |
echo "${{ secrets.RELEASE_KEYSTORE }}" > keystore.asc
@@ -45,28 +47,46 @@ jobs:
run: git checkout -B "$BRANCH"
- name: set up JDK
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
java-version: 21
distribution: "temurin"
cache: 'gradle'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
# Scoped to :app so the upstream KeePassDX submodule tests do not gate this build.
- name: Run unit tests
run: ./gradlew :app:testDebugUnitTest
- name: Build APK
run: |
VERSION_CODE=$(git rev-list --count HEAD)
./gradlew assemble -PVERSION_CODE=$VERSION_CODE
VERSION_BASE=$(git describe --tags --abbrev=0 | sed 's/^v//')
VERSION_DEV=$(git rev-list --count $(git describe --tags --abbrev=0)..HEAD)
if [ $VERSION_DEV -gt 0 ]; then
VERSION_NAME="${VERSION_BASE}.${VERSION_DEV}"
else
VERSION_NAME="${VERSION_BASE}"
fi
./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
# - name: Upload APK
# uses: actions/upload-artifact@v4
# with:
# name: app.apk
# path: app/build/outputs/apk/release/app-release.apk
- name: Upload APK
uses: actions/upload-artifact@v7
with:
path: app/build/outputs/apk/signedRelease/app-signedRelease.apk
compression-level: 0
archive: false
- name: Release
uses: softprops/action-gh-release@v3
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
app/build/outputs/apk/release/app-release.apk
app/build/outputs/apk/signedRelease/app-signedRelease.apk
prerelease: |
if [[ ${{ github.ref }} =~ ^refs/tags/[0-9]+\.[0-9]+[a-z]+$ ]]; then
echo "false"
else
echo "true"
fi
+8 -3
View File
@@ -4,7 +4,9 @@
<img width="100px" src="./metadata/en-US/images/icon.png" alt="Logo">
<p>A minimalist fidelity/loyalty card app with Keepass Database storage</p>
<a href="https://ko-fi.com/I2I615VP5M"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="ko-fi"></a>
<br>
<img src="https://forthebadge.com/images/badges/built-for-android.svg" alt="Built for Android">
<img src="https://forthebadge.com/images/badges/built-with-love.svg" alt="Built with love">
<br>
@@ -27,6 +29,9 @@
## ⭐ Features
- Two storage modes, chosen on first start (switchable later from the launcher):
- **Standalone**: opens a `.kdbx` database directly (bundled KeePassDX engine)
- **Keepass2Android plugin**: queries and creates entries through the installed Keepass2Android app
- Search entries in Keepass Database
- Scan & Create entries
- Recently used history for fast access
@@ -40,7 +45,7 @@
<a href="https://apt.izzysoft.de/fdroid/index/apk/net.helcel.fidelity">
<img width="200" height="80" alt="Izzy Download" src=".github/images/izzy.png">
</a>
<a href="https://github.com/choelzl/keepass-fidelity/releases/latest">
<a href="https://github.com/helcel-net/keepass-fidelity/releases/latest">
<img width="200" height="84" alt="APK Download" src=".github/images/apk.png">
</a>
</div>
@@ -66,7 +71,7 @@ Thanks to all contributors, the developers of our dependencies, and our users.
## 📝 License
```
Copyright (C) 2024 Helcel
Copyright (C) 2026 Helcel
Licensed under the Unlicense
For more information, please refer to <https://unlicense.org>
+68 -24
View File
@@ -1,34 +1,26 @@
def getCommitCount() {
try {
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git', 'rev-list', '--count', 'HEAD'
standardOutput = stdout
}
return stdout.toString().trim().toInteger()
} catch (ignored) {
return 1
}
}
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.21'
id 'org.jetbrains.kotlin.plugin.compose' version '2.3.21'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.20'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.20'
id 'jacoco'
}
android {
namespace 'net.helcel.fidelity'
compileSdk = 37
ndkVersion "25.2.9519653" //Match this to KeypassDX crypto NDK version
defaultConfig {
applicationId 'net.helcel.fidelity'
versionName "1.3b"
// CI derives both from git (see .github/workflows/build.yml); the fallbacks are for local builds.
versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1.3c"
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
buildConfigField("String", "APP_NAME", "\"Keepass Fidelity\"")
manifestPlaceholders["APP_NAME"] = "Keepass Fidelity"
minSdk = 28
targetSdk = 37
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
@@ -54,6 +46,7 @@ android {
debuggable true
initWith(buildTypes.release)
signingConfig signingConfigs.debug
enableUnitTestCoverage true
}
release {
minifyEnabled true
@@ -91,6 +84,25 @@ android {
composeOptions {
kotlinCompilerExtensionVersion = "2.2.20"
}
testOptions {
unitTests {
includeAndroidResources = true
all {
maxParallelForks = 2
forkEvery = 50
maxHeapSize = "1024m"
// Robolectric loads classes through its own sandbox classloader, which leaves them
// without a code-source location. Without this JaCoCo skips them entirely and the
// report only sees the handful of plain JVM classes.
jacoco {
includeNoLocationClasses = true
excludes = ["jdk.internal.*"]
}
}
}
}
kotlin {
jvmToolchain(21)
}
@@ -104,9 +116,9 @@ android {
dependencies {
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3:1.4.0'
implementation 'androidx.compose.material:material:1.11.2'
implementation 'androidx.compose.material:material:1.12.1'
implementation 'androidx.compose.material:material-icons-extended:1.7.8'
implementation 'androidx.navigation:navigation-compose:2.9.8'
implementation 'androidx.navigation:navigation-compose:2.10.1'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation "androidx.biometric:biometric:1.2.0-alpha05"
@@ -116,9 +128,9 @@ dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5'
implementation 'androidx.camera:camera-lifecycle:1.6.1'
implementation 'androidx.camera:camera-view:1.6.1'
runtimeOnly 'androidx.camera:camera-camera2:1.6.1'
implementation 'androidx.camera:camera-lifecycle:1.6.2'
implementation 'androidx.camera:camera-view:1.6.2'
runtimeOnly 'androidx.camera:camera-camera2:1.6.2'
implementation 'com.google.android.material:material:1.14.0'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0'
@@ -127,13 +139,45 @@ dependencies {
implementation project(":database")
implementation project(":crypto")
implementation platform('androidx.compose:compose-bom:2026.05.01')
implementation 'androidx.compose.ui:ui-tooling:1.11.2'
implementation platform('androidx.compose:compose-bom:2026.09.00')
implementation 'androidx.compose.ui:ui-tooling:1.12.1'
implementation 'androidx.compose.ui:ui-tooling-preview'
//Submodule
//noinspection NewerVersionAvailable
implementation 'joda-time:joda-time:2.14.2'
implementation 'joda-time:joda-time:2.14.3'
implementation 'org.joda:joda-convert:3.0.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.14.11'
testImplementation 'org.robolectric:robolectric:4.16.1'
testImplementation 'androidx.test:core:1.7.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0'
testImplementation 'androidx.compose.ui:ui-test-junit4'
androidTestImplementation platform('androidx.compose:compose-bom:2026.09.00')
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
androidTestImplementation 'io.mockk:mockk-android:1.14.11'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
}
tasks.register('jacocoTestReport', JacocoReport) {
dependsOn 'testDebugUnitTest'
reports {
xml.required = true
html.required = true
}
def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*']
def debugTree = fileTree(dir: "${project.layout.buildDirectory.asFile.get()}/intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes", excludes: fileFilter)
def mainSrc = "${project.projectDir}/src/main/java"
sourceDirectories.from = files([mainSrc])
classDirectories.from = files([debugTree])
executionData.from = fileTree(dir: project.layout.buildDirectory.asFile.get(), includes: [
'jacoco/testDebugUnitTest.exec', 'outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec'
])
}
+28 -3
View File
@@ -1,20 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.camera" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<!-- Keepass2Android plugin mode: make the KP2A activities resolvable from this app -->
<queries>
<intent>
<action android:name="keepass2android.ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE" />
</intent>
<intent>
<action android:name="keepass2android.ACTION_START_WITH_TASK" />
</intent>
<package android:name="keepass2android.keepass2android" />
<package android:name="keepass2android.keepass2android_nonet" />
</queries>
<application
android:icon="@mipmap/ic_launcher_round"
android:label="${APP_NAME}"
android:supportsRtl="true">
<activity
android:name=".activity.MainActivity"
android:exported="true">
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".pluginSDK.PluginAccessBroadcastReceiver"
android:exported="true"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="keepass2android.ACTION_TRIGGER_REQUEST_ACCESS" />
<action android:name="keepass2android.ACTION_RECEIVE_ACCESS" />
<action android:name="keepass2android.ACTION_REVOKE_ACCESS" />
</intent-filter>
</receiver>
</application>
</manifest>
</manifest>
@@ -1,11 +1,19 @@
package net.helcel.fidelity.activity
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentActivity
import androidx.navigation.compose.NavHost
@@ -15,20 +23,32 @@ import net.helcel.fidelity.activity.fragment.CreateEntryScreen
import net.helcel.fidelity.activity.fragment.FileScanner
import net.helcel.fidelity.activity.fragment.InitialScreen
import net.helcel.fidelity.activity.fragment.LauncherScreen
import net.helcel.fidelity.activity.fragment.ModeSelectScreen
import net.helcel.fidelity.activity.fragment.ScannerScreen
import net.helcel.fidelity.activity.fragment.ViewEntryScreen
import net.helcel.fidelity.tools.FidelityRepository.entries
import net.helcel.fidelity.tools.AppMode
import net.helcel.fidelity.tools.AppModeStore
import net.helcel.fidelity.tools.FidelityEntry
import net.helcel.fidelity.tools.FidelityRepository.cacheEntry
import net.helcel.fidelity.tools.FidelityRepository.findEntry
import net.helcel.fidelity.tools.FidelityRepository.loadEntries
import net.helcel.fidelity.tools.FidelityRepository.transientEntry
import net.helcel.fidelity.tools.KeePassStore.hasCredentials
import net.helcel.fidelity.tools.Kp2a
class MainActivity : FragmentActivity() {
// Entry pushed by Keepass2Android when the user opens this app from an entry.
private val incomingEntry = mutableStateOf<FidelityEntry?>(null)
@SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
actionBar?.hide()
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
AppModeStore.load(this)
loadEntries(this.baseContext)
incomingEntry.value = Kp2a.entryFromIntent(intent)
setContent {
SysTheme {
@@ -39,24 +59,49 @@ class MainActivity : FragmentActivity() {
if (!navController.popBackStack()) finish()
}
LaunchedEffect(Unit) {
if(!hasCredentials(context)) navController.navigate("init")
when (AppModeStore.mode.value) {
null -> navController.navigate("mode")
AppMode.STANDALONE ->
if (!hasCredentials(context)) navController.navigate("init")
AppMode.KP2A -> {}
}
}
NavHost(navController = navController, startDestination = "launcher") {
composable("exit") { finish() }
composable("launcher") { LauncherScreen(navController) }
composable("init"){ InitialScreen (navController)}
composable("scanCam") { ScannerScreen(navController) }
composable("scanFile") { FileScanner(navController) }
composable("edit"){ CreateEntryScreen(navController) }
composable("view/{entryId}") { e ->
val entry = entries.find {
it.uid == (e.arguments?.getString("entryId") ?: "")
LaunchedEffect(incomingEntry.value) {
val entry = incomingEntry.value ?: return@LaunchedEffect
incomingEntry.value = null
if (AppModeStore.isKp2a) cacheEntry(context, entry)
else transientEntry.value = entry
navController.navigate("view/${entry.uid}")
}
// The app draws edge-to-edge (targetSdk >= 35): keep every screen clear of the
// status and navigation bars, and paint the bars with the theme background.
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.systemBarsPadding()
) {
NavHost(navController = navController, startDestination = "launcher") {
composable("exit") { finish() }
composable("mode") { ModeSelectScreen(navController) }
composable("launcher") { LauncherScreen(navController) }
composable("init") { InitialScreen(navController) }
composable("scanCam") { ScannerScreen(navController) }
composable("scanFile") { FileScanner(navController) }
composable("edit") { CreateEntryScreen(navController) }
composable("view/{entryId}") { e ->
val entry = findEntry(e.arguments?.getString("entryId"))
if (entry == null) return@composable navController.navigate("launcher")
ViewEntryScreen(navController, entry)
}
if (entry == null) return@composable navController.navigate("launcher")
ViewEntryScreen(navController,entry)
}
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
Kp2a.entryFromIntent(intent)?.let { incomingEntry.value = it }
}
}
@@ -1,5 +1,6 @@
package net.helcel.fidelity.activity.fragment
import android.content.Context
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -13,8 +14,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.Checkbox
import androidx.compose.material.CheckboxDefaults
@@ -31,6 +35,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Camera
import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -47,21 +52,27 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.google.zxing.FormatException
import com.kunzisoft.keepass.database.element.Entry
import com.kunzisoft.keepass.database.element.Group
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import net.helcel.fidelity.R
import net.helcel.fidelity.activity.ToastHelper
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.ensureUnlocked
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onCameraScan
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onFileScan
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSaveKp2a
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSaveStandalone
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSubmit
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onSave
import net.helcel.fidelity.tools.AppModeStore
import net.helcel.fidelity.tools.BarcodeGenerator.generateBarcode
import net.helcel.fidelity.tools.FidelityEntry
import net.helcel.fidelity.tools.FidelityRepository
import net.helcel.fidelity.tools.FidelityRepository.activeEntry
import net.helcel.fidelity.tools.FidelityRepository.addEntry
import net.helcel.fidelity.tools.KeepassDatabase
import net.helcel.fidelity.tools.KeepassDatabase.addEntry
import net.helcel.fidelity.tools.FidelityRepository.cacheEntry
import net.helcel.fidelity.tools.Kp2a
import kotlin.time.Duration.Companion.milliseconds
@@ -79,6 +90,22 @@ fun CreateEntryScreen(navController: NavHostController?) {
var isLoading by remember { mutableStateOf(false) }
val ctx = LocalContext.current
val scope = rememberCoroutineScope()
val kp2a = AppModeStore.isKp2a
// Standalone only: where a new card is created (root unless changed). Existing cards
// (uid set) stay where they are.
var group by remember { mutableStateOf(KeepassDatabase.getRoot()) }
val showGroup = !kp2a && entry.uid == null
fun pickGroup() {
isLoading = true
scope.launch {
if (ensureUnlocked(ctx, navController!!)) {
group = group ?: KeepassDatabase.getRoot()
showDialog = true
}
isLoading = false
}
}
LaunchedEffect(entry) {
isValidBarcode = false
@@ -103,17 +130,10 @@ fun CreateEntryScreen(navController: NavHostController?) {
}
if (showDialog) {
TreeSelectorDialog(
onDismiss = {
showDialog = false
if(it!=null){
entry = entry.copy(uid = it.nodeId?.id.toString())
if(it is Entry){
entry = entry.copy(title = it.title)
}
}
}
)
TreeSelectorDialog(groupsOnly = true) {
showDialog = false
if (it is Group) group = it
}
}
val formats = stringArrayResource(R.array.format_array)
@@ -121,16 +141,18 @@ fun CreateEntryScreen(navController: NavHostController?) {
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.imePadding(),
) {
Column(
modifier = Modifier
.padding(16.dp, 32.dp),
.verticalScroll(rememberScrollState())
.padding(16.dp, 32.dp)
.padding(bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
)
{
OutlinedTextField(
value = entry.title,
enabled = entry.uid!=null,
onValueChange = {
entry = entry.copy(title = it)
errorTitle = ""
@@ -140,8 +162,7 @@ fun CreateEntryScreen(navController: NavHostController?) {
modifier = Modifier.fillMaxWidth(),
singleLine = true,
colors = TextFieldDefaults.textFieldColors(
textColor = if(entry.uid!=null)MaterialTheme.colors.onBackground
else MaterialTheme.colors.secondary
textColor = MaterialTheme.colors.onBackground
),
)
if (errorTitle.isNotEmpty()) {
@@ -201,6 +222,23 @@ fun CreateEntryScreen(navController: NavHostController?) {
Icon(Icons.Default.FileOpen, contentDescription = null)
}
}
if (showGroup) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text("Group: ", color = MaterialTheme.colors.onBackground)
Text(
group?.title ?: "(database root)",
color = MaterialTheme.colors.onBackground,
modifier = Modifier.weight(1f),
maxLines = 1,
)
Button(onClick = { pickGroup() }) {
Icon(Icons.Default.FolderOpen, contentDescription = "Choose group")
}
}
}
if (barcodeBitmap != null) {
Image(
bitmap = barcodeBitmap!!.asImageBitmap(),
@@ -229,41 +267,22 @@ fun CreateEntryScreen(navController: NavHostController?) {
errorCode = c
errorFormat = f
},
isValidBarcode
isValidBarcode,
) {
if (FidelityRepository.getRoot() == null) {
if (kp2a) {
onSaveKp2a(ctx, navController!!, entry)
} else {
isLoading = true
scope.launch {
onRefresh(ctx, navController!!)
onSaveStandalone(ctx, navController!!, entry, group)
isLoading = false
if(entry.uid!=null){
addEntry(ctx,entry)
isLoading = true
onSave(ctx,navController)
isLoading = false
onSubmit(navController)
}else {
showDialog = true
}
}
} else {
if(entry.uid!=null){
addEntry(ctx,entry)
isLoading = true
scope.launch {
onSave(ctx, navController!!)
isLoading = false
onSubmit(navController)
}
}else {
showDialog = true
}
}
}
},
enabled = isValidBarcode.and(entry.uid==null || entry.title.isNotEmpty()),
enabled = isValidBarcode && entry.title.isNotEmpty(),
) {
Text(if(entry.uid==null)"Select Entry" else "Save", style = MaterialTheme.typography.h6)
Text("Save", style = MaterialTheme.typography.h6)
}
}
@@ -346,7 +365,7 @@ private fun onSubmitIfValid(
var tErr = ""
var cErr = ""
var fErr = ""
if (entry.uid!=null && entry.title.isBlank()) tErr = "Title cannot be empty"
if (entry.title.isBlank()) tErr = "Title cannot be empty"
if (entry.code.isBlank()) cErr = "Code cannot be empty"
if (entry.format.isBlank()) fErr = "Format cannot be empty"
@@ -358,6 +377,38 @@ private fun onSubmitIfValid(
}
object CreateEntryEventHandler {
/** Standalone: the database must be open before groups can be browsed or cards written. */
suspend fun ensureUnlocked(context: Context, navController: NavHostController): Boolean =
KeepassDatabase.getRoot() != null || onRefresh(context, navController)
/** Standalone: updates the existing entry, or creates one in [group] (the root when null). */
suspend fun onSaveStandalone(
context: Context,
navController: NavHostController,
entry: FidelityEntry,
group: Group?,
) {
if (!ensureUnlocked(context, navController)) return
val root = KeepassDatabase.getRoot() ?: return
val target =
if (entry.uid != null) entry
else entry.copy(uid = (group ?: root).nodeId.id.toString())
addEntry(context, target)
if (onSave(context, navController)) onSubmit(navController)
}
/**
* KP2A creates the entry in its own task and never reports back, so the card is cached
* right away; a later fetch replaces the local uid with KP2A's.
*/
fun onSaveKp2a(context: Context, navController: NavHostController, entry: FidelityEntry) {
val kpEntry = entry.copy(uid = Kp2a.localUid(entry.title))
if (Kp2a.launchAdd(context, kpEntry)) {
cacheEntry(context, kpEntry)
onSubmit(navController)
}
}
fun onSubmit(navController: NavHostController){
navController.popBackStack()
activeEntry.value = activeEntry.value.copy(
@@ -0,0 +1,152 @@
package net.helcel.fidelity.activity.fragment
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.HideSource
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onEdit
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onHide
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onPin
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRemove
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onView
import net.helcel.fidelity.tools.AppModeStore
import net.helcel.fidelity.tools.FidelityEntry
/** One card in the launcher grid: tap to view, long-press for the actions menu. */
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun FidelityRow(
navController: NavHostController,
e: FidelityEntry
) {
var expanded by remember { mutableStateOf(false) }
val context = LocalContext.current
Box(modifier = Modifier.fillMaxWidth()) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp)
.combinedClickable(
onClick = { onView(navController, e) },
onLongClick = { expanded = true },
),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colors.primary,
contentColor = MaterialTheme.colors.background
),
) {
Box(modifier = Modifier.fillMaxSize().padding(2.dp)) {
Row(modifier = Modifier.padding(14.dp)) {
Text(
text = e.title,
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onPrimary
)
}
Row(modifier = Modifier.align(Alignment.TopEnd)) {
if (e.hidden)
Icon(
Icons.Default.HideSource, contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colors.onPrimary
)
if (e.hidden && e.pinned)
Spacer(modifier = Modifier.width(8.dp))
if (e.pinned)
Icon(
Icons.Default.PushPin, contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colors.onPrimary
)
}
}
}
DropdownMenu(
modifier = Modifier,
expanded = expanded,
onDismissRequest = { expanded = false }
) {
// KP2A entries can only be edited in KP2A itself; offer to drop the cached copy.
if (AppModeStore.isKp2a)
DropdownMenuItem(onClick = {
expanded = false
onRemove(context, e)
}) {
Icon(
Icons.Default.Delete,
contentDescription = "remove",
)
Spacer(modifier= Modifier.width(8.dp))
Text("Remove")
}
else
DropdownMenuItem(onClick = {
expanded = false
onEdit(navController, e)
}) {
Icon(
Icons.Default.Edit,
contentDescription = "edit",
)
Spacer(modifier= Modifier.width(8.dp))
Text("Edit")
}
DropdownMenuItem(onClick = {
expanded = false
onPin(e)
}) {
Icon(
Icons.Default.PushPin,
contentDescription = "pin",
)
Spacer(modifier= Modifier.width(8.dp))
if(e.pinned) Text("Unpin")
else Text("Pin")
}
DropdownMenuItem(onClick = {
expanded = false
onHide(e)
}) {
Icon(
Icons.Default.HideSource,
contentDescription = "hide",
)
Spacer(modifier= Modifier.width(8.dp))
if(e.hidden) Text("Unhide")
else Text("Hide")
}
}
}
}
@@ -1,10 +1,12 @@
package net.helcel.fidelity.activity.fragment
import android.app.Activity
import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -19,25 +21,20 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.HideSource
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
@@ -56,28 +53,23 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.fidelity.activity.ToastHelper
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.isSearchVisible
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onAdd
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onEdit
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onHide
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onPin
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onQuery
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onView
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.searchQuery
import net.helcel.fidelity.tools.CredentialResult
import net.helcel.fidelity.tools.AppModeStore
import net.helcel.fidelity.tools.FidelityEntry
import net.helcel.fidelity.tools.FidelityRepository.activeEntry
import net.helcel.fidelity.tools.FidelityRepository.end
import net.helcel.fidelity.tools.FidelityRepository.cacheEntry
import net.helcel.fidelity.tools.FidelityRepository.entries
import net.helcel.fidelity.tools.FidelityRepository.genCredentials
import net.helcel.fidelity.tools.FidelityRepository.importDB
import net.helcel.fidelity.tools.FidelityRepository.start
import net.helcel.fidelity.tools.KeePassStore.loadCredentials
import net.helcel.fidelity.tools.KeepassDatabase
import net.helcel.fidelity.tools.FidelityRepository.loadEntries
import net.helcel.fidelity.tools.FidelityRepository.removeEntry
import net.helcel.fidelity.tools.Kp2a
@Preview
@OptIn(ExperimentalMaterial3Api::class)
@@ -91,6 +83,20 @@ fun LauncherScreen(
val context = LocalContext.current
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
val kp2a = AppModeStore.isKp2a
val kp2aQueryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val entry = Kp2a.entryFromIntent(result.data)
if (entry == null) {
ToastHelper.show(context, "Entry has no fidelity code")
return@rememberLauncherForActivityResult
}
cacheEntry(context, entry)
onView(navController, entry)
}
BackHandler(enabled = isSearchVisible) {
onQuery()
@@ -118,7 +124,9 @@ fun LauncherScreen(
onRefresh = {
isRefreshingState = true
scope.launch {
onRefresh(context, navController)
// KP2A owns the database: nothing to sync, just reload the cache.
if (kp2a) loadEntries(context)
else onRefresh(context, navController)
isRefreshingState = false
}
},
@@ -126,6 +134,45 @@ fun LauncherScreen(
modifier = Modifier.fillMaxSize()
) {
Column(modifier = Modifier.fillMaxSize()) {
// Top bar: hidden-cards toggle on the left, storage mode on the right.
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val hiddenTint =
if (showHidden) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary
TextButton(onClick = { showHidden = !showHidden }) {
Icon(
Icons.Default.HideSource,
contentDescription = "Show Hidden",
modifier = Modifier.size(16.dp),
tint = hiddenTint
)
Spacer(modifier = Modifier.width(4.dp))
Text(
if (showHidden) "Showing hidden" else "Show hidden",
style = MaterialTheme.typography.caption,
color = hiddenTint
)
}
TextButton(onClick = { navController.navigate("mode") }) {
Icon(
Icons.Default.SwapHoriz,
contentDescription = "Switch mode",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colors.secondary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
if (kp2a) "Keepass2Android" else "Standalone",
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.secondary
)
}
}
if (isSearchVisible) {
LaunchedEffect(Unit) {
focusRequester.requestFocus()
@@ -187,18 +234,20 @@ fun LauncherScreen(
) {
Icon(Icons.Default.Add, contentDescription = "Add")
}
FloatingActionButton(
onClick = {
showHidden=!showHidden
}, modifier = Modifier
.align(Alignment.BottomStart)
.padding(16.dp).size(24.dp),
backgroundColor = if(showHidden) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary,
) {
Icon(Icons.Default.HideSource,
tint= if(showHidden) MaterialTheme.colors.background else MaterialTheme.colors.onSecondary,
contentDescription = "Show Hidden")
}
if (kp2a)
FloatingActionButton(
onClick = { Kp2a.launchQuery(context, kp2aQueryLauncher) },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 88.dp),
backgroundColor = MaterialTheme.colors.secondary,
) {
Icon(
Icons.Default.Key,
tint = MaterialTheme.colors.onSecondary,
contentDescription = "Fetch from Keepass2Android"
)
}
}
if (isRefreshingState)
@@ -216,105 +265,9 @@ fun LauncherScreen(
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun FidelityRow(
navController: NavHostController,
e: FidelityEntry
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp)
.combinedClickable(
onClick = { onView(navController, e) },
onLongClick = { expanded = true },
),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colors.primary,
contentColor = MaterialTheme.colors.background
),
) {
Box(modifier = Modifier.fillMaxSize().padding(2.dp)) {
Row(modifier = Modifier.padding(14.dp)) {
Text(
text = e.title,
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onPrimary
)
}
Row(modifier = Modifier.align(Alignment.TopEnd)) {
if (e.hidden)
Icon(
Icons.Default.HideSource, contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colors.onPrimary
)
if (e.hidden && e.pinned)
Spacer(modifier = Modifier.width(8.dp))
if (e.pinned)
Icon(
Icons.Default.PushPin, contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colors.onPrimary
)
}
}
}
DropdownMenu(
modifier = Modifier,
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(onClick = {
expanded = false
onEdit(navController, e)
}) {
Icon(
Icons.Default.Edit,
contentDescription = "edit",
)
Spacer(modifier= Modifier.width(8.dp))
Text("Edit")
}
DropdownMenuItem(onClick = {
expanded = false
onPin(e)
}) {
Icon(
Icons.Default.PushPin,
contentDescription = "pin",
)
Spacer(modifier= Modifier.width(8.dp))
if(e.pinned) Text("Unpin")
else Text("Pin")
}
DropdownMenuItem(onClick = {
expanded = false
onHide(e)
}) {
Icon(
Icons.Default.HideSource,
contentDescription = "hide",
)
Spacer(modifier= Modifier.width(8.dp))
if(e.hidden) Text("Unhide")
else Text("Hide")
}
}
}
}
object LauncherEventHandlers {
var isSearchVisible by mutableStateOf(false)
var searchQuery by mutableStateOf("")
var CRED: CredentialResult.Success? = null
fun onAdd(navController: NavHostController) {
navController.navigate("edit")
@@ -325,48 +278,22 @@ object LauncherEventHandlers {
if (!isSearchVisible) searchQuery = ""
}
suspend fun onSave(context: Context, navController: NavHostController){
try {
if (CRED == null) {
when (val res = loadCredentials(context)) {
CredentialResult.AuthFailed, CredentialResult.NoData -> ToastHelper.show(context, "Unable to Load Credentials")
is CredentialResult.Success -> CRED = res
}
}
CRED!!
val cred = withContext(Dispatchers.IO) {
genCredentials(context, CRED!!)
}
if (withContext(Dispatchers.IO) {
end(context, CRED!!.db, cred)
})
throw Exception("Error in saving")
} catch (e: Exception) {
println(e.toString())
/** Standalone: writes the database back. Missing credentials send the user to setup. */
suspend fun onSave(context: Context, navController: NavHostController): Boolean {
if (!KeepassDatabase.ensureCredentials(context)) {
navController.navigate("init")
return false
}
return KeepassDatabase.save(context)
}
suspend fun onRefresh(context: Context, navController: NavHostController) {
try {
if (CRED == null) {
when (val res = loadCredentials(context)) {
CredentialResult.AuthFailed, CredentialResult.NoData -> ToastHelper.show(context, "Unable to Load Credentials")
is CredentialResult.Success -> CRED = res
}
}
CRED!!
val cred = withContext(Dispatchers.IO) {
genCredentials(context, CRED!!)
}
if (withContext(Dispatchers.IO) {
start(context, CRED!!.db, cred)
})
importDB(context)
} catch (e: Exception) {
println(e.toString())
/** Standalone: (re)opens the database and imports its cards. Missing credentials send the user to setup. */
suspend fun onRefresh(context: Context, navController: NavHostController): Boolean {
if (!KeepassDatabase.ensureCredentials(context)) {
navController.navigate("init")
return false
}
return KeepassDatabase.unlock(context)
}
fun onView(navController: NavHostController, entry: FidelityEntry) {
@@ -389,8 +316,12 @@ object LauncherEventHandlers {
entries[index] = entry.copy(hidden = !entry.hidden)
}
fun onRemove(context: Context, entry: FidelityEntry) {
removeEntry(context, entry)
}
fun onEdit(navController: NavHostController, entry: FidelityEntry){
activeEntry.value = entry
navController.navigate("edit")
}
}
}
@@ -0,0 +1,154 @@
package net.helcel.fidelity.activity.fragment
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import kotlinx.coroutines.launch
import net.helcel.fidelity.tools.AppMode
import net.helcel.fidelity.tools.AppModeStore
import net.helcel.fidelity.tools.FidelityRepository.loadEntries
import net.helcel.fidelity.tools.KeePassStore.hasCredentials
import net.helcel.fidelity.tools.Kp2a
@Preview
@Composable
fun ModeSelectScreen(navController: NavHostController?) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val current = AppModeStore.mode.value
val kp2aInstalled = remember { Kp2a.isAvailable(context) }
BackHandler {
// Nothing to go back to before the first choice has been made.
if (current == null) navController!!.navigate("exit")
else navController!!.popBackStack()
}
fun select(mode: AppMode) {
if (mode != current) {
AppModeStore.set(context, mode)
loadEntries(context)
}
navController!!.popBackStack("launcher", inclusive = false)
scope.launch {
if (mode == AppMode.STANDALONE && !hasCredentials(context))
navController.navigate("init")
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center
) {
Text(
"Storage Mode",
style = MaterialTheme.typography.h5,
color = MaterialTheme.colors.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Where should your fidelity cards be stored?",
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground
)
Spacer(modifier = Modifier.height(16.dp))
ModeCard(
icon = Icons.Default.FolderOpen,
title = "Standalone",
description = "Open a KeePass database (.kdbx) directly. " +
"Entries are read and written by this app.",
selected = current == AppMode.STANDALONE,
) { select(AppMode.STANDALONE) }
Spacer(modifier = Modifier.height(12.dp))
ModeCard(
icon = Icons.Default.Extension,
title = "Keepass2Android plugin",
description = "Use the Keepass2Android app as the database. " +
"Entries are queried from and created in Keepass2Android.",
selected = current == AppMode.KP2A,
) { select(AppMode.KP2A) }
if (!kp2aInstalled) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"Keepass2Android does not seem to be installed.",
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.error
)
}
}
}
}
@Composable
private fun ModeCard(
icon: ImageVector,
title: String,
description: String,
selected: Boolean,
onClick: () -> Unit,
) {
val container = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.surface
val content = if (selected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = container,
contentColor = content
),
border = if (selected) null else CardDefaults.outlinedCardBorder(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp), tint = content)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(title, style = MaterialTheme.typography.h6, color = content)
Spacer(modifier = Modifier.height(4.dp))
Text(description, style = MaterialTheme.typography.body2, color = content)
}
}
}
}
@@ -19,7 +19,9 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Undo
import androidx.compose.material.icons.filled.CreditCard
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Folder
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -32,11 +34,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.kunzisoft.keepass.database.element.Group
import com.kunzisoft.keepass.database.element.node.Node
import net.helcel.fidelity.tools.FidelityRepository
import net.helcel.fidelity.tools.KeepassDatabase
/**
* Browses the loaded database. With [groupsOnly] entries are hidden, so the result is the group
* a new card is created in; otherwise an existing entry can be picked to attach the card to.
*/
@Preview
@Composable
fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
fun TreeSelectorDialog(groupsOnly: Boolean = false, onDismiss: (Node?) -> Unit = {}) {
Dialog(
onDismissRequest = {onDismiss(null)},
content = {
@@ -46,8 +52,8 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
RoundedCornerShape(8.dp)
)
) {
var currentRoot by remember { mutableStateOf(FidelityRepository.getRoot()) }
var selection by remember { mutableStateOf<Node?>(FidelityRepository.getRoot()) }
var currentRoot by remember { mutableStateOf(KeepassDatabase.getRoot()) }
var selection by remember { mutableStateOf<Node?>(KeepassDatabase.getRoot()) }
Column(
modifier = Modifier.fillMaxWidth().padding(8.dp)
@@ -76,32 +82,29 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
LazyColumn(modifier = Modifier.fillMaxHeight(0.75f)) {
items(currentRoot?.getChildGroups() ?: emptyList()) { entry ->
val isSel = (entry.nodeId == selection?.nodeId)
val hasChildren = entry.getChildGroups().isNotEmpty() ||
(!groupsOnly && entry.getChildEntries().isNotEmpty())
Row(
modifier = Modifier
.fillMaxWidth()
.background(color = if (isSel) MaterialTheme.colors.primary else MaterialTheme.colors.background)
.clickable {
if (entry.getChildEntries().isNotEmpty()) {
currentRoot = entry
selection = entry
} else if (entry.getChildGroups().isNotEmpty()) {
currentRoot = entry
selection = entry
} else {
selection = entry
}
selection = entry
if (hasChildren) currentRoot = entry
}
.padding(8.dp)
) {
if (entry.getChildEntries().isNotEmpty() || entry.getChildGroups()
.isNotEmpty()
) {
Icon(
imageVector = Icons.Default.Folder,
contentDescription = "group",
tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground
)
if (hasChildren)
Icon(
imageVector = Icons.Default.ExpandMore,
contentDescription = null,
tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground
)
}
Text(
entry.title,
modifier = Modifier.padding(start = 8.dp),
@@ -109,7 +112,7 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
)
}
}
items(currentRoot?.getChildEntries() ?: emptyList()) { entry ->
items(if (groupsOnly) emptyList() else currentRoot?.getChildEntries() ?: emptyList()) { entry ->
val isSel = (entry.nodeId == selection?.nodeId)
Row(
modifier = Modifier
@@ -120,6 +123,11 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
}
.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.CreditCard,
contentDescription = "entry",
tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground
)
Text(
entry.title,
modifier = Modifier.padding(start = 8.dp),
@@ -28,6 +28,7 @@ import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -52,9 +53,10 @@ import kotlinx.coroutines.withContext
import net.helcel.fidelity.activity.ToastHelper
import net.helcel.fidelity.activity.fragment.SetupEventHandlers.onOpen
import net.helcel.fidelity.tools.CredentialResult
import net.helcel.fidelity.tools.FidelityRepository.genCredentials
import net.helcel.fidelity.tools.FidelityRepository.importDB
import net.helcel.fidelity.tools.FidelityRepository.start
import net.helcel.fidelity.tools.KeepassDatabase
import net.helcel.fidelity.tools.KeepassDatabase.genCredentials
import net.helcel.fidelity.tools.KeepassDatabase.importDB
import net.helcel.fidelity.tools.KeepassDatabase.start
import net.helcel.fidelity.tools.KeePassStore.loadCredentials
import net.helcel.fidelity.tools.KeePassStore.packCredentials
import net.helcel.fidelity.tools.KeePassStore.saveCredentials
@@ -223,9 +225,6 @@ fun InitialScreen(
val res = onOpen(context, dbFile!!, password, keyFile)
if(res != null){
ToastHelper.show(context, "Successful... Importing")
withContext(Dispatchers.IO) {
start(context, dbFile!!,genCredentials(context, res))
}
importDB(context)
navController!!.popBackStack()
navController.navigate("launcher")
@@ -238,6 +237,13 @@ fun InitialScreen(
) {
Text("Continue")
}
TextButton(
enabled = !loading,
onClick = { navController!!.navigate("mode") },
modifier = Modifier.fillMaxWidth()
) {
Text("Use Keepass2Android instead", color = MaterialTheme.colors.secondary)
}
}
Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier
.fillMaxSize()
@@ -254,12 +260,16 @@ fun InitialScreen(
}
object SetupEventHandlers {
/** Opens the file with the given credentials, then stores them for later sessions. */
suspend fun onOpen(context: Context, db: Uri, p: String, key: Uri?): CredentialResult.Success? {
try {
val packCred = packCredentials(db, p, key)
withContext(Dispatchers.IO) {
start(context, db, genCredentials(context, packCred)
)
val opened = withContext(Dispatchers.IO) {
start(context, db, genCredentials(context, packCred))
}
if (!opened) {
ToastHelper.show(context, "Unable to open the database: check the password and key file")
return null
}
val res = withContext(Dispatchers.Main) {
@@ -267,7 +277,10 @@ object SetupEventHandlers {
}
return when (res) {
CredentialResult.AuthFailed, CredentialResult.NoData -> null
is CredentialResult.Success -> res
is CredentialResult.Success -> {
KeepassDatabase.credentials = res
res
}
}
} catch (e: Exception) {
ToastHelper.show(context, e.message.toString())
@@ -20,6 +20,7 @@ import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -62,11 +63,19 @@ fun ViewEntryScreen(
activity?.window?.attributes = activity.window?.attributes?.apply {
screenBrightness = if (isFull) 1f else BRIGHTNESS_OVERRIDE_NONE
}
}
// Generate once per entry (not on every recomposition) and surface zxing's reason,
// e.g. "Contents do not pass checksum" for a mistyped EAN.
LaunchedEffect(entry) {
try {
bitmap = generateBarcode(entry.code, entry.format, 1024)
} catch (_: Exception) {
} catch (e: Exception) {
bitmap = null
Toast.makeText(context, "Invalid barcode format", Toast.LENGTH_SHORT).show()
Toast.makeText(
context,
"Invalid barcode: " + (e.message ?: "unsupported format"),
Toast.LENGTH_LONG
).show()
}
}
BackHandler {
@@ -0,0 +1,89 @@
package net.helcel.fidelity.pluginSDK
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import org.json.JSONArray
import org.json.JSONException
object AccessManager {
private const val PREF_KEY_SCOPE = "scope"
private const val PREF_KEY_TOKEN = "token"
private const val PREF_HOSTS = "KP2A.PluginAccess.hosts"
private fun stringArrayToString(values: ArrayList<String?>): String? {
if (values.isEmpty()) return null
val a = JSONArray()
values.forEach { a.put(it) }
return a.toString()
}
private fun stringToStringArray(s: String?): ArrayList<String> {
val strings = ArrayList<String>()
if (s.isNullOrEmpty()) return strings
try {
val a = JSONArray(s)
for (i in 0 until a.length())
strings.add(a.optString(i))
} catch (e: JSONException) {
e.printStackTrace()
}
return strings
}
fun storeAccessToken(
ctx: Context,
hostPackage: String?,
accessToken: String?,
scopes: ArrayList<String?>
) {
getPrefsForHost(ctx, hostPackage).edit {
putString(PREF_KEY_TOKEN, accessToken)
putString(PREF_KEY_SCOPE, stringArrayToString(scopes))
}
val hostPrefs = ctx.getSharedPreferences(PREF_HOSTS, Context.MODE_PRIVATE)
if (!hostPrefs.contains(hostPackage))
hostPrefs.edit { putString(hostPackage, "") }
}
private fun getPrefsForHost(
ctx: Context,
hostPackage: String?
): SharedPreferences {
return ctx.getSharedPreferences("KP2A.PluginAccess.$hostPackage", Context.MODE_PRIVATE)
}
fun tryGetAccessToken(ctx: Context, hostPackage: String?, scopes: ArrayList<String?>): String? {
if (hostPackage.isNullOrEmpty()) return null
val prefs = getPrefsForHost(ctx, hostPackage)
val scopesString = prefs.getString(PREF_KEY_SCOPE, "")
val currentScope = stringToStringArray(scopesString)
if (!isSubset(scopes, currentScope))
return null
return prefs.getString(PREF_KEY_TOKEN, null)
}
private fun isSubset(
requiredScopes: ArrayList<String?>,
availableScopes: ArrayList<String>
): Boolean {
return availableScopes.containsAll(requiredScopes)
}
fun removeAccessToken(
ctx: Context, hostPackage: String?,
accessToken: String?
) {
val prefs = getPrefsForHost(ctx, hostPackage)
if (prefs.getString(PREF_KEY_TOKEN, "") == accessToken)
prefs.edit { clear() }
val hostPrefs = ctx.getSharedPreferences(PREF_HOSTS, Context.MODE_PRIVATE)
if (hostPrefs.contains(hostPackage))
hostPrefs.edit { remove(hostPackage) }
}
}
@@ -0,0 +1,9 @@
package net.helcel.fidelity.pluginSDK
@Suppress("unused")
object KeepassDef {
var TitleField: String = "Title"
var UserNameField: String = "UserName"
var PasswordField: String = "Password"
var UrlField: String = "URL"
}
@@ -0,0 +1,49 @@
package net.helcel.fidelity.pluginSDK
import android.content.Intent
import org.json.JSONException
import org.json.JSONObject
object Kp2aControl {
fun getAddEntryIntent(
fields: HashMap<String, String>,
protectedFields: ArrayList<String>?
): Intent {
val outputData = JSONObject((fields as Map<*, *>)).toString()
val startKp2aIntent = Intent(Strings.ACTION_START_WITH_TASK)
startKp2aIntent.addCategory(Intent.CATEGORY_DEFAULT)
startKp2aIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
startKp2aIntent.putExtra("KP2A_APPTASK", "CreateEntryThenCloseTask")
startKp2aIntent.putExtra("ShowUserNotifications", "true")
startKp2aIntent.putExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA, outputData)
if (protectedFields != null)
startKp2aIntent.putStringArrayListExtra(
Strings.EXTRA_PROTECTED_FIELDS_LIST,
protectedFields
)
return startKp2aIntent
}
fun getQueryEntryForOwnPackageIntent(): Intent {
return Intent(Strings.ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE)
}
fun getEntryFieldsFromIntent(intent: Intent?): HashMap<String, String> {
val res = HashMap<String, String>()
try {
val json = JSONObject(intent?.getStringExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA) ?: "")
val itr = json.keys()
while (itr.hasNext()) {
val key = itr.next()
val value = json[key].toString()
res[key] = value
}
} catch (e: JSONException) {
e.printStackTrace()
} catch (e: NullPointerException) {
e.printStackTrace()
}
return res
}
}
@@ -0,0 +1,51 @@
package net.helcel.fidelity.pluginSDK
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class PluginAccessBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val action = intent.action ?: return
when (action) {
Strings.ACTION_TRIGGER_REQUEST_ACCESS -> requestAccess(ctx, intent)
Strings.ACTION_RECEIVE_ACCESS -> receiveAccess(ctx, intent)
Strings.ACTION_REVOKE_ACCESS -> revokeAccess(ctx, intent)
else -> {}
}
}
private fun revokeAccess(ctx: Context, intent: Intent) {
val senderPackage = intent.getStringExtra(Strings.EXTRA_SENDER)
val accessToken = intent.getStringExtra(Strings.EXTRA_ACCESS_TOKEN)
AccessManager.removeAccessToken(ctx, senderPackage, accessToken)
}
private fun receiveAccess(ctx: Context, intent: Intent) {
val senderPackage = intent.getStringExtra(Strings.EXTRA_SENDER)
val accessToken = intent.getStringExtra(Strings.EXTRA_ACCESS_TOKEN)
AccessManager.storeAccessToken(ctx, senderPackage, accessToken, scopes)
}
private fun requestAccess(ctx: Context, intent: Intent) {
val senderPackage = intent.getStringExtra(Strings.EXTRA_SENDER)
val requestToken = intent.getStringExtra(Strings.EXTRA_REQUEST_TOKEN)
val rpi = Intent(Strings.ACTION_REQUEST_ACCESS)
rpi.setPackage(senderPackage)
rpi.putExtra(Strings.EXTRA_SENDER, ctx.packageName)
rpi.putExtra(Strings.EXTRA_REQUEST_TOKEN, requestToken)
val token: String? = AccessManager.tryGetAccessToken(ctx, senderPackage, scopes)
rpi.putExtra(Strings.EXTRA_ACCESS_TOKEN, token)
rpi.putStringArrayListExtra(Strings.EXTRA_SCOPES, scopes)
ctx.sendBroadcast(rpi)
}
private val scopes: ArrayList<String?> = ArrayList(
listOf(
Strings.SCOPE_QUERY_CREDENTIALS_FOR_OWN_PACKAGE,
)
)
}
@@ -0,0 +1,32 @@
package net.helcel.fidelity.pluginSDK
@Suppress("unused")
object Strings {
const val SCOPE_DATABASE_ACTIONS = "keepass2android.SCOPE_DATABASE_ACTIONS"
const val SCOPE_CURRENT_ENTRY = "keepass2android.SCOPE_CURRENT_ENTRY"
const val SCOPE_QUERY_CREDENTIALS_FOR_OWN_PACKAGE =
"keepass2android.SCOPE_QUERY_CREDENTIALS_FOR_OWN_PACKAGE"
const val SCOPE_QUERY_CREDENTIALS = "keepass2android.SCOPE_QUERY_CREDENTIALS"
const val EXTRA_SCOPES = "keepass2android.EXTRA_SCOPES"
const val EXTRA_PLUGIN_PACKAGE = "keepass2android.EXTRA_PLUGIN_PACKAGE"
const val EXTRA_SENDER = "keepass2android.EXTRA_SENDER"
const val EXTRA_REQUEST_TOKEN = "keepass2android.EXTRA_REQUEST_TOKEN"
const val ACTION_START_WITH_TASK = "keepass2android.ACTION_START_WITH_TASK"
const val ACTION_TRIGGER_REQUEST_ACCESS = "keepass2android.ACTION_TRIGGER_REQUEST_ACCESS"
const val ACTION_REQUEST_ACCESS = "keepass2android.ACTION_REQUEST_ACCESS"
const val ACTION_RECEIVE_ACCESS = "keepass2android.ACTION_RECEIVE_ACCESS"
const val ACTION_REVOKE_ACCESS = "keepass2android.ACTION_REVOKE_ACCESS"
const val EXTRA_ENTRY_OUTPUT_DATA = "keepass2android.EXTRA_ENTRY_OUTPUT_DATA"
const val EXTRA_PROTECTED_FIELDS_LIST = "keepass2android.EXTRA_PROTECTED_FIELDS_LIST"
const val EXTRA_ACCESS_TOKEN = "keepass2android.EXTRA_ACCESS_TOKEN"
const val EXTRA_ENTRY_ID = "keepass2android.EXTRA_ENTRY_ID"
const val ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE =
"keepass2android.ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE"
}
@@ -0,0 +1,39 @@
package net.helcel.fidelity.tools
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.edit
const val FIDELITY_PREFS = "fidelity_prefs"
enum class AppMode {
/** Opens a KDBX file directly through the bundled KeePassDX engine. */
STANDALONE,
/** Delegates storage to the Keepass2Android app through its plugin interface. */
KP2A,
}
object AppModeStore {
private const val KEY_MODE = "app_mode"
/** Null until the user has picked a mode on the selection screen. */
val mode = mutableStateOf<AppMode?>(null)
val isKp2a: Boolean
get() = mode.value == AppMode.KP2A
fun load(context: Context): AppMode? {
val prefs = context.getSharedPreferences(FIDELITY_PREFS, Context.MODE_PRIVATE)
mode.value = prefs.getString(KEY_MODE, null)?.let { name ->
AppMode.entries.firstOrNull { it.name == name }
}
return mode.value
}
fun set(context: Context, newMode: AppMode) {
context.getSharedPreferences(FIDELITY_PREFS, Context.MODE_PRIVATE)
.edit { putString(KEY_MODE, newMode.name) }
mode.value = newMode
}
}
@@ -2,9 +2,12 @@ package net.helcel.fidelity.tools
import android.content.Context
import android.net.Uri
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import android.widget.Toast
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import javax.crypto.Cipher
@@ -16,6 +19,7 @@ import com.kunzisoft.keepass.utils.parseUri
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.suspendCancellableCoroutine
import net.helcel.fidelity.activity.ToastHelper
import java.security.KeyStore
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
@@ -36,6 +40,17 @@ sealed class CredentialResult {
private const val KEY_ALIAS = "keepass_bio_key"
// Android 11+ can unlock a keystore key with the device PIN/pattern/password as well, which
// keeps standalone mode usable on devices without (enrolled) biometrics. Below that, a
// CryptoObject can only be released by a biometric.
private val deviceCredentialSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
private val allowedAuthenticators: Int
get() = if (deviceCredentialSupported)
BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
else
BiometricManager.Authenticators.BIOMETRIC_STRONG
fun getOrCreateBiometricKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
@@ -47,7 +62,13 @@ fun getOrCreateBiometricKey(): SecretKey {
).apply {
setBlockModes(KeyProperties.BLOCK_MODE_GCM)
setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
setUserAuthenticationRequired(true)
if (deviceCredentialSupported)
setUserAuthenticationParameters(
0,
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
)
else
setUserAuthenticationRequired(true)
setInvalidatedByBiometricEnrollment(true)
}.build()
@@ -108,6 +129,17 @@ object KeePassStore {
suspend fun showBiometricPrompt(activity: FragmentActivity, enc: Boolean): Cipher? {
val prefs = activity.securePrefs.data.first()
return suspendCancellableCoroutine { cont ->
val status = BiometricManager.from(activity).canAuthenticate(allowedAuthenticators)
if (status != BiometricManager.BIOMETRIC_SUCCESS) {
ToastHelper.show(
activity,
if (deviceCredentialSupported) "Set up a screen lock or fingerprint first"
else "Enroll a fingerprint first",
Toast.LENGTH_LONG
)
cont.resume(null) { _, _, _ -> }
return@suspendCancellableCoroutine
}
val executor = ContextCompat.getMainExecutor(activity)
val biometricPrompt = BiometricPrompt(
activity,
@@ -119,20 +151,22 @@ suspend fun showBiometricPrompt(activity: FragmentActivity, enc: Boolean): Ciphe
override fun onAuthenticationError(code: Int, msg: CharSequence) {
cont.resume(null) { _, _, _ -> }
}
override fun onAuthenticationFailed() {
cont.resume(null) { _, _, _ -> }
}
// onAuthenticationFailed is a retryable miss: the prompt stays open, so the
// coroutine must stay suspended until it either succeeds or errors out.
}
)
val iv = if(enc) null else prefs[KeePassKeys.IV]?.let { Base64.decode(it, Base64.DEFAULT) }
if (!enc && iv == null) {
cont.resume(null) { _, _, _ -> }
return@suspendCancellableCoroutine
}
val cipher = getCipherForDecryption(getOrCreateBiometricKey(), iv)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock KeePass")
.setSubtitle("Authenticate to access your KeePass database")
.setNegativeButtonText("Cancel")
.setAllowedAuthenticators(allowedAuthenticators)
// A negative button is mandatory without, and forbidden with, device credential.
.apply { if (!deviceCredentialSupported) setNegativeButtonText("Cancel") }
.build()
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
@@ -0,0 +1,29 @@
package net.helcel.fidelity.tools
import kotlinx.serialization.Serializable
/** Names of the custom KeePass fields a card is stored in. */
object FidelityKeepassFields {
const val FIDELITYFORMAT = "FidelityFormat"
const val FIDELITYCODE = "FidelityCode"
}
/**
* A loyalty card as shown by the app.
*
* [uid] is the KeePass entry UUID in standalone mode, or the id Keepass2Android reports
* (falling back to a title-derived one) in plugin mode. The last three fields are
* per-device preferences that never reach the database.
*/
@Serializable
data class FidelityEntry(
val uid: String? = null,
val title: String = "",
val code: String = "",
val format: String = "",
val protected: Boolean = false,
val hidden: Boolean = false,
val pinned: Boolean = false,
val lastUse: Int = 0,
)
@@ -0,0 +1,82 @@
package net.helcel.fidelity.tools
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.edit
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
/**
* The on-device list of cards shown by the launcher, persisted in shared preferences.
*
* In standalone mode it mirrors the cards found in the KDBX file (see
* [KeepassDatabase.importDB]); in Keepass2Android mode it is a cache of the entries the
* user has fetched so far.
*/
object FidelityRepository {
val entries = mutableStateListOf<FidelityEntry>()
/** Card being edited on the create screen. */
val activeEntry = mutableStateOf(FidelityEntry())
/** Entry handed over by Keepass2Android for viewing without being cached. */
val transientEntry = mutableStateOf<FidelityEntry?>(null)
// Each mode keeps its own cache: standalone uids are KDBX UUIDs, KP2A ones are not.
private fun entriesKey() = if (AppModeStore.isKp2a) "entries_kp2a" else "entries"
fun saveEntries(context: Context) {
val prefs = context.getSharedPreferences(FIDELITY_PREFS, Context.MODE_PRIVATE)
// In KP2A mode "protected" means the code must never leave the KP2A database.
val persisted = if (AppModeStore.isKp2a) entries.filter { !it.protected } else entries
prefs.edit { putString(entriesKey(), Json.encodeToString(
ListSerializer(FidelityEntry.serializer()),
persisted
)) }
}
fun loadEntries(context: Context) {
val prefs = context.getSharedPreferences(FIDELITY_PREFS, Context.MODE_PRIVATE)
entries.clear()
try {
val json = prefs.getString(entriesKey(), null) ?: return
val list = Json.decodeFromString(
ListSerializer(FidelityEntry.serializer()),
json
)
entries.addAll(list)
}catch(_: Exception){
prefs.edit{ putString(entriesKey(),Json.encodeToString(
ListSerializer(FidelityEntry.serializer()),emptyList()))
}
}
}
/**
* Upserts an entry coming from Keepass2Android into the local cache, keeping the
* per-device flags (pin/hide/last use) of the entry it replaces. Matching also
* falls back to title+code because an optimistically cached entry gets a local uid
* until KP2A returns it with its own.
*/
fun cacheEntry(context: Context, entry: FidelityEntry) {
val idx = entries.indexOfFirst {
it.uid == entry.uid || (it.title == entry.title && it.code == entry.code)
}
if (idx >= 0) {
val old = entries[idx]
entries[idx] = entry.copy(pinned = old.pinned, hidden = old.hidden, lastUse = old.lastUse)
} else {
entries.add(entry)
}
saveEntries(context)
}
fun removeEntry(context: Context, entry: FidelityEntry) {
entries.removeIf { it.uid == entry.uid }
saveEntries(context)
}
fun findEntry(uid: String?): FidelityEntry? =
entries.find { it.uid == uid } ?: transientEntry.value?.takeIf { it.uid == uid }
}
@@ -2,12 +2,6 @@ package net.helcel.fidelity.tools
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import kotlinx.serialization.Serializable
import java.io.ByteArrayInputStream
import kotlinx.serialization.json.Json
import androidx.core.content.edit
import com.kunzisoft.keepass.database.element.Database
import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.Group
@@ -17,39 +11,75 @@ import com.kunzisoft.keepass.database.element.node.NodeIdUUID
import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.hardware.HardwareKey
import com.kunzisoft.keepass.utils.getBinaryDir
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.helcel.fidelity.activity.ToastHelper
import net.helcel.fidelity.tools.FidelityRepository.entries
import net.helcel.fidelity.tools.FidelityRepository.saveEntries
import net.helcel.fidelity.tools.KeePassStore.loadCredentials
import java.io.ByteArrayInputStream
import java.io.File
import java.util.UUID
object FidelityKeepassFields {
const val FIDELITYFORMAT = "FidelityFormat"
const val FIDELITYCODE = "FidelityCode"
}
@Serializable
data class FidelityEntry(
val uid: String? = null,
val title: String = "",
val code: String = "",
val format: String = "",
val protected: Boolean = false,
val hidden: Boolean = false,
val pinned: Boolean = false,
val lastUse: Int = 0,
)
object FidelityRepository {
/**
* Standalone mode: the KDBX file opened through the bundled KeePassDX engine.
*
* [unlock] and [save] are the session-level operations the screens use; the rest is the
* raw file handling underneath them.
*/
object KeepassDatabase {
private var db: Database = Database()
private var binaryDir: File? = null
val entries = mutableStateListOf<FidelityEntry>()
val activeEntry = mutableStateOf(FidelityEntry())
/** Credentials released by the user for this process, so authentication happens once. */
var credentials: CredentialResult.Success? = null
fun getRoot(): Group? {
return db.rootGroup
}
/** Makes sure [credentials] are available, asking the user to authenticate if needed. */
suspend fun ensureCredentials(context: Context): Boolean {
if (credentials != null) return true
return when (val res = loadCredentials(context)) {
is CredentialResult.Success -> {
credentials = res
true
}
CredentialResult.AuthFailed, CredentialResult.NoData -> {
ToastHelper.show(context, "Unable to Load Credentials")
false
}
}
}
/** Opens the file with [credentials] and imports its cards into [FidelityRepository]. */
suspend fun unlock(context: Context): Boolean {
val cred = credentials ?: return false
val opened = withContext(Dispatchers.IO) {
start(context, cred.db, genCredentials(context, cred))
}
if (!opened) {
ToastHelper.show(context, "Unable to open the database")
return false
}
importDB(context)
return true
}
/** Writes the database back to its file. */
suspend fun save(context: Context): Boolean {
val cred = credentials ?: return false
val saved = try {
withContext(Dispatchers.IO) { end(context, cred.db, genCredentials(context, cred)) }
} catch (e: Exception) {
println(e)
false
}
if (!saved) ToastHelper.show(context, "Unable to save the database")
return saved
}
fun start(ctx: Context, uri: Uri?, c: MasterCredential): Boolean {
if (binaryDir == null) binaryDir = ctx.getBinaryDir()
if (uri == null) return false
@@ -90,6 +120,7 @@ object FidelityRepository {
)
}
/** Replaces the card list with the cards found in the database, keeping per-device flags. */
fun importDB(context: Context) {
val seenID= arrayListOf<String>()
fun importDBRec(group: Group) {
@@ -119,8 +150,6 @@ object FidelityRepository {
} else {
entries.add(newEntry)
}
}
group.getChildGroups().forEach { importDBRec(it) }
}
@@ -133,36 +162,19 @@ object FidelityRepository {
saveEntries(context)
}
fun saveEntries(context: Context) {
val prefs = context.getSharedPreferences("fidelity_prefs", Context.MODE_PRIVATE)
prefs.edit { putString("entries", Json.encodeToString(
ListSerializer(FidelityEntry.serializer()),
entries
)) }
}
fun loadEntries(context: Context) {
val prefs = context.getSharedPreferences("fidelity_prefs", Context.MODE_PRIVATE)
try {
val json = prefs.getString("entries", null) ?: return
val list = Json.decodeFromString(
ListSerializer(FidelityEntry.serializer()),
json
)
entries.clear()
entries.addAll(list)
}catch(_: Exception){
prefs.edit{ putString("entries",Json.encodeToString(
ListSerializer(FidelityEntry.serializer()),emptyList()))
}
}
}
/**
* Writes the card into the loaded database. [FidelityEntry.uid] is either the id of an
* existing entry, which is updated in place, or the id of the group to create it in.
*/
fun addEntry(ctx: Context, entry: FidelityEntry) {
val dbEntry = db.getEntryById(NodeIdUUID(UUID.fromString(entry.uid))) ?: db.createEntry()
val dbParent = db.getGroupById(NodeIdUUID(UUID.fromString(entry.uid)))
dbEntry?.apply {
val id = NodeIdUUID(UUID.fromString(entry.uid))
val existing = db.getEntryById(id)
val dbEntry = existing ?: db.createEntry() ?: return
dbEntry.apply {
title = entry.title
// Keepass2Android lists entries for this app by URL, so cards written here stay
// reachable from KP2A mode as well.
if (url.isBlank()) url = Kp2a.appUrl(ctx)
putExtraField(
Field(
FidelityKeepassFields.FIDELITYCODE,
@@ -175,11 +187,11 @@ object FidelityRepository {
ProtectedString(true, entry.format.toCharArray())
)
)
if(dbParent!=null) title = entry.title
dbParent?.addChildEntry(dbEntry)
}
if (existing != null) db.updateEntry(dbEntry)
else db.addEntryTo(dbEntry, db.getGroupById(id) ?: db.rootGroup ?: return)
entries.removeIf {it.uid == entry.uid}
entries.add(entry.copy(uid=dbEntry?.nodeId?.id.toString()))
entries.add(entry.copy(uid=dbEntry.nodeId.id.toString()))
saveEntries(ctx)
}
}
@@ -0,0 +1,109 @@
package net.helcel.fidelity.tools
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import net.helcel.fidelity.activity.ToastHelper
import net.helcel.fidelity.pluginSDK.KeepassDef
import net.helcel.fidelity.pluginSDK.Kp2aControl
import net.helcel.fidelity.pluginSDK.Strings
import org.json.JSONArray
import org.json.JSONException
import java.util.UUID
/**
* Bridge between [FidelityEntry] and the Keepass2Android plugin interface.
*
* Entries live in the KP2A database; this app only keeps a local cache of the
* non-protected ones (see [FidelityRepository.cacheEntry]).
*/
object Kp2a {
private const val CODE_FIELD = FidelityKeepassFields.FIDELITYCODE
private const val FORMAT_FIELD = FidelityKeepassFields.FIDELITYFORMAT
// Legacy flag written by pre-1.3 versions: "true" means "do not cache locally".
private const val PROTECT_CODE_FIELD = "FidelityProtectedCode"
private const val NOT_INSTALLED = "Keepass2Android Not Installed"
/** URL that marks a database entry as belonging to this app. */
fun appUrl(context: Context): String = "androidapp://" + context.packageName
fun isAvailable(context: Context): Boolean =
Kp2aControl.getQueryEntryForOwnPackageIntent()
.resolveActivity(context.packageManager) != null
/** Asks KP2A to pick one of the entries whose URL points at this app. */
fun launchQuery(context: Context, launcher: ActivityResultLauncher<Intent>): Boolean {
return try {
launcher.launch(Kp2aControl.getQueryEntryForOwnPackageIntent())
true
} catch (_: ActivityNotFoundException) {
ToastHelper.show(context, NOT_INSTALLED)
false
}
}
/**
* Opens KP2A on a pre-filled "new entry" form. KP2A runs in its own task, so no
* activity result comes back: the caller must cache the entry optimistically.
*/
fun launchAdd(context: Context, entry: FidelityEntry): Boolean {
val fields = HashMap<String, String>()
fields[KeepassDef.TitleField] = entry.title
fields[KeepassDef.UrlField] = appUrl(context)
fields[CODE_FIELD] = entry.code
fields[FORMAT_FIELD] = entry.format
fields[PROTECT_CODE_FIELD] = entry.protected.toString()
val protectedFields = if (entry.protected) arrayListOf(CODE_FIELD) else null
return try {
context.startActivity(Kp2aControl.getAddEntryIntent(fields, protectedFields))
true
} catch (_: ActivityNotFoundException) {
ToastHelper.show(context, NOT_INSTALLED)
false
}
}
/** Stable id for entries KP2A did not tag with its own UUID. */
fun localUid(title: String): String =
UUID.nameUUIDFromBytes("kp2a:$title".toByteArray(Charsets.UTF_8)).toString()
/**
* Extracts a fidelity entry from an intent produced by KP2A (query result, or the
* launch intent when the user opens this app from an entry). Returns null when the
* intent carries no fidelity data.
*/
fun entryFromIntent(intent: Intent?): FidelityEntry? {
if (intent?.hasExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA) != true) return null
val fields = Kp2aControl.getEntryFieldsFromIntent(intent)
val code = fields[CODE_FIELD] ?: return null
val format = fields[FORMAT_FIELD] ?: return null
val title = fields[KeepassDef.TitleField] ?: ""
val protected = fields[PROTECT_CODE_FIELD]?.toBooleanStrictOrNull()
?: protectedFieldsFromIntent(intent).contains(CODE_FIELD)
return FidelityEntry(
uid = intent.getStringExtra(Strings.EXTRA_ENTRY_ID) ?: localUid(title),
title = title,
code = code,
format = format,
protected = protected,
)
}
// KP2A sends the protected field names either as a string list or a JSON array.
private fun protectedFieldsFromIntent(intent: Intent): List<String> {
intent.getStringArrayListExtra(Strings.EXTRA_PROTECTED_FIELDS_LIST)?.let { return it }
val json = intent.getStringExtra(Strings.EXTRA_PROTECTED_FIELDS_LIST) ?: return emptyList()
return try {
val a = JSONArray(json)
List(a.length()) { a.optString(it) }
} catch (_: JSONException) {
emptyList()
}
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ buildscript {
}
plugins {
id 'com.android.application' version '9.2.1' apply false
id 'com.android.library' version '9.2.1' apply false
id 'com.android.application' version '9.3.2' apply false
id 'com.android.library' version '9.3.2' apply false
id 'org.jetbrains.kotlin.android' version '2.3.21' apply false
id 'com.autonomousapps.dependency-analysis' version '3.13.0' apply true
}
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
+1 -1
View File
@@ -1 +1 @@
<p><i>Keepass-Fidelity</i> adds an interface to view&save barcodes (QR included) with a Keepass Database.</p><p><br></p><ul><li><b>Launcher:</b> view and launch recent entries (a per entry flag can disable this behaviour)</li><li><b>View:</b> view entries from loaded from the database</li><li><b>Create:</b> add entries from the camera, an image of by filling out a form. The entry is then created in the database</li><li><b>Data:</b> the app uses the following data Title (entry name), barcode type (QR, UPC, ...), barcode content (number/text content) and a "secure" flag (enable/disable caching the entry).</li></ul>
<p><i>Keepass-Fidelity</i> adds an interface to view&save barcodes (QR included) with a Keepass Database, either by opening a KDBX file directly (standalone) or through the Keepass2Android plugin interface.</p><p><br></p><ul><li><b>Launcher:</b> view and launch recent entries (a per entry flag can disable this behaviour)</li><li><b>View:</b> view entries from loaded from the database</li><li><b>Create:</b> add entries from the camera, an image of by filling out a form. The entry is then created in the database</li><li><b>Data:</b> the app uses the following data Title (entry name), barcode type (QR, UPC, ...), barcode content (number/text content) and a "secure" flag (enable/disable caching the entry).</li></ul>