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
+30 -10
View File
@@ -23,14 +23,16 @@ jobs:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
with: with:
submodules: true submodules: true
fetch-depth: 0 fetch-depth: 0
- name: Make script executable - name: Make script executable
run: chmod +x ./patch_submodule.sh run: chmod +x ./patch_submodule.sh
- name: Run patch submodule script - name: Run patch submodule script
run: ./patch_submodule.sh run: ./patch_submodule.sh
- name: set up secrets - name: set up secrets
run: | run: |
echo "${{ secrets.RELEASE_KEYSTORE }}" > keystore.asc echo "${{ secrets.RELEASE_KEYSTORE }}" > keystore.asc
@@ -45,28 +47,46 @@ jobs:
run: git checkout -B "$BRANCH" run: git checkout -B "$BRANCH"
- name: set up JDK - name: set up JDK
uses: actions/setup-java@v5 uses: actions/setup-java@v6
with: with:
java-version: 21 java-version: 21
distribution: "temurin" distribution: "temurin"
cache: 'gradle'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6 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 - name: Build APK
run: | run: |
VERSION_CODE=$(git rev-list --count HEAD) 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 - name: Upload APK
# uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
# with: with:
# name: app.apk path: app/build/outputs/apk/signedRelease/app-signedRelease.apk
# path: app/build/outputs/apk/release/app-release.apk compression-level: 0
archive: false
- name: Release - name: Release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v3
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
with: with:
files: | 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
+7 -2
View File
@@ -5,6 +5,8 @@
<p>A minimalist fidelity/loyalty card app with Keepass Database storage</p> <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-for-android.svg" alt="Built for Android">
<img src="https://forthebadge.com/images/badges/built-with-love.svg" alt="Built with love"> <img src="https://forthebadge.com/images/badges/built-with-love.svg" alt="Built with love">
<br> <br>
@@ -27,6 +29,9 @@
## ⭐ Features ## ⭐ 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 - Search entries in Keepass Database
- Scan & Create entries - Scan & Create entries
- Recently used history for fast access - Recently used history for fast access
@@ -40,7 +45,7 @@
<a href="https://apt.izzysoft.de/fdroid/index/apk/net.helcel.fidelity"> <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"> <img width="200" height="80" alt="Izzy Download" src=".github/images/izzy.png">
</a> </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"> <img width="200" height="84" alt="APK Download" src=".github/images/apk.png">
</a> </a>
</div> </div>
@@ -66,7 +71,7 @@ Thanks to all contributors, the developers of our dependencies, and our users.
## 📝 License ## 📝 License
``` ```
Copyright (C) 2024 Helcel Copyright (C) 2026 Helcel
Licensed under the Unlicense Licensed under the Unlicense
For more information, please refer to <https://unlicense.org> 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 { plugins {
id 'com.android.application' id 'com.android.application'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.21' id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.20'
id 'org.jetbrains.kotlin.plugin.compose' version '2.3.21' id 'org.jetbrains.kotlin.plugin.compose' version '2.4.20'
id 'jacoco'
} }
android { android {
namespace 'net.helcel.fidelity' namespace 'net.helcel.fidelity'
compileSdk = 37 compileSdk = 37
ndkVersion "25.2.9519653" //Match this to KeypassDX crypto NDK version
defaultConfig { defaultConfig {
applicationId 'net.helcel.fidelity' 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 versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
buildConfigField("String", "APP_NAME", "\"Keepass Fidelity\"") buildConfigField("String", "APP_NAME", "\"Keepass Fidelity\"")
manifestPlaceholders["APP_NAME"] = "Keepass Fidelity" manifestPlaceholders["APP_NAME"] = "Keepass Fidelity"
minSdk = 28 minSdk = 28
targetSdk = 37 targetSdk = 37
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs { signingConfigs {
@@ -54,6 +46,7 @@ android {
debuggable true debuggable true
initWith(buildTypes.release) initWith(buildTypes.release)
signingConfig signingConfigs.debug signingConfig signingConfigs.debug
enableUnitTestCoverage true
} }
release { release {
minifyEnabled true minifyEnabled true
@@ -91,6 +84,25 @@ android {
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "2.2.20" 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 { kotlin {
jvmToolchain(21) jvmToolchain(21)
} }
@@ -104,9 +116,9 @@ android {
dependencies { dependencies {
implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3:1.4.0' 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.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.preference:preference-ktx:1.2.1'
implementation "androidx.biometric:biometric:1.2.0-alpha05" implementation "androidx.biometric:biometric:1.2.0-alpha05"
@@ -116,9 +128,9 @@ dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5'
implementation 'androidx.camera:camera-lifecycle:1.6.1' implementation 'androidx.camera:camera-lifecycle:1.6.2'
implementation 'androidx.camera:camera-view:1.6.1' implementation 'androidx.camera:camera-view:1.6.2'
runtimeOnly 'androidx.camera:camera-camera2:1.6.1' runtimeOnly 'androidx.camera:camera-camera2:1.6.2'
implementation 'com.google.android.material:material:1.14.0' implementation 'com.google.android.material:material:1.14.0'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0' implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0'
@@ -127,13 +139,45 @@ dependencies {
implementation project(":database") implementation project(":database")
implementation project(":crypto") implementation project(":crypto")
implementation platform('androidx.compose:compose-bom:2026.05.01') implementation platform('androidx.compose:compose-bom:2026.09.00')
implementation 'androidx.compose.ui:ui-tooling:1.11.2' implementation 'androidx.compose.ui:ui-tooling:1.12.1'
implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.ui:ui-tooling-preview'
//Submodule //Submodule
//noinspection NewerVersionAvailable //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' 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'
])
} }
+27 -2
View File
@@ -1,20 +1,45 @@
<?xml version="1.0" encoding="utf-8"?> <?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-feature android:name="android.hardware.camera" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" /> <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 <application
android:icon="@mipmap/ic_launcher_round" android:icon="@mipmap/ic_launcher_round"
android:label="${APP_NAME}" android:label="${APP_NAME}"
android:supportsRtl="true"> android:supportsRtl="true">
<activity <activity
android:name=".activity.MainActivity" android:name=".activity.MainActivity"
android:exported="true"> android:exported="true"
android:launchMode="singleTask">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </manifest>
@@ -1,11 +1,19 @@
package net.helcel.fidelity.activity package net.helcel.fidelity.activity
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent 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.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.navigation.compose.NavHost 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.FileScanner
import net.helcel.fidelity.activity.fragment.InitialScreen import net.helcel.fidelity.activity.fragment.InitialScreen
import net.helcel.fidelity.activity.fragment.LauncherScreen 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.ScannerScreen
import net.helcel.fidelity.activity.fragment.ViewEntryScreen 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.loadEntries
import net.helcel.fidelity.tools.FidelityRepository.transientEntry
import net.helcel.fidelity.tools.KeePassStore.hasCredentials import net.helcel.fidelity.tools.KeePassStore.hasCredentials
import net.helcel.fidelity.tools.Kp2a
class MainActivity : FragmentActivity() { class MainActivity : FragmentActivity() {
// Entry pushed by Keepass2Android when the user opens this app from an entry.
private val incomingEntry = mutableStateOf<FidelityEntry?>(null)
@SuppressLint("SourceLockedOrientationActivity") @SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
actionBar?.hide() actionBar?.hide()
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
AppModeStore.load(this)
loadEntries(this.baseContext) loadEntries(this.baseContext)
incomingEntry.value = Kp2a.entryFromIntent(intent)
setContent { setContent {
SysTheme { SysTheme {
@@ -39,24 +59,49 @@ class MainActivity : FragmentActivity() {
if (!navController.popBackStack()) finish() if (!navController.popBackStack()) finish()
} }
LaunchedEffect(Unit) { 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 -> {}
} }
}
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") { NavHost(navController = navController, startDestination = "launcher") {
composable("exit") { finish() } composable("exit") { finish() }
composable("mode") { ModeSelectScreen(navController) }
composable("launcher") { LauncherScreen(navController) } composable("launcher") { LauncherScreen(navController) }
composable("init"){ InitialScreen (navController)} composable("init") { InitialScreen(navController) }
composable("scanCam") { ScannerScreen(navController) } composable("scanCam") { ScannerScreen(navController) }
composable("scanFile") { FileScanner(navController) } composable("scanFile") { FileScanner(navController) }
composable("edit"){ CreateEntryScreen(navController) } composable("edit") { CreateEntryScreen(navController) }
composable("view/{entryId}") { e -> composable("view/{entryId}") { e ->
val entry = entries.find { val entry = findEntry(e.arguments?.getString("entryId"))
it.uid == (e.arguments?.getString("entryId") ?: "")
}
if (entry == null) return@composable navController.navigate("launcher") if (entry == null) return@composable navController.navigate("launcher")
ViewEntryScreen(navController,entry) 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 package net.helcel.fidelity.activity.fragment
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width 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.Button
import androidx.compose.material.Checkbox import androidx.compose.material.Checkbox
import androidx.compose.material.CheckboxDefaults 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.ArrowDropDown
import androidx.compose.material.icons.filled.Camera import androidx.compose.material.icons.filled.Camera
import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -47,21 +52,27 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.google.zxing.FormatException 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.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.helcel.fidelity.R import net.helcel.fidelity.R
import net.helcel.fidelity.activity.ToastHelper 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.onCameraScan
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onFileScan 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.CreateEntryEventHandler.onSubmit
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onSave 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.BarcodeGenerator.generateBarcode
import net.helcel.fidelity.tools.FidelityEntry 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.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 import kotlin.time.Duration.Companion.milliseconds
@@ -79,6 +90,22 @@ fun CreateEntryScreen(navController: NavHostController?) {
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
val ctx = LocalContext.current val ctx = LocalContext.current
val scope = rememberCoroutineScope() 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) { LaunchedEffect(entry) {
isValidBarcode = false isValidBarcode = false
@@ -103,34 +130,29 @@ fun CreateEntryScreen(navController: NavHostController?) {
} }
if (showDialog) { if (showDialog) {
TreeSelectorDialog( TreeSelectorDialog(groupsOnly = true) {
onDismiss = {
showDialog = false showDialog = false
if(it!=null){ if (it is Group) group = it
entry = entry.copy(uid = it.nodeId?.id.toString())
if(it is Entry){
entry = entry.copy(title = it.title)
} }
} }
}
)
}
val formats = stringArrayResource(R.array.format_array) val formats = stringArrayResource(R.array.format_array)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colors.background) .background(MaterialTheme.colors.background)
.imePadding(),
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.padding(16.dp, 32.dp), .verticalScroll(rememberScrollState())
.padding(16.dp, 32.dp)
.padding(bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) )
{ {
OutlinedTextField( OutlinedTextField(
value = entry.title, value = entry.title,
enabled = entry.uid!=null,
onValueChange = { onValueChange = {
entry = entry.copy(title = it) entry = entry.copy(title = it)
errorTitle = "" errorTitle = ""
@@ -140,8 +162,7 @@ fun CreateEntryScreen(navController: NavHostController?) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
colors = TextFieldDefaults.textFieldColors( colors = TextFieldDefaults.textFieldColors(
textColor = if(entry.uid!=null)MaterialTheme.colors.onBackground textColor = MaterialTheme.colors.onBackground
else MaterialTheme.colors.secondary
), ),
) )
if (errorTitle.isNotEmpty()) { if (errorTitle.isNotEmpty()) {
@@ -201,6 +222,23 @@ fun CreateEntryScreen(navController: NavHostController?) {
Icon(Icons.Default.FileOpen, contentDescription = null) 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) { if (barcodeBitmap != null) {
Image( Image(
bitmap = barcodeBitmap!!.asImageBitmap(), bitmap = barcodeBitmap!!.asImageBitmap(),
@@ -229,41 +267,22 @@ fun CreateEntryScreen(navController: NavHostController?) {
errorCode = c errorCode = c
errorFormat = f errorFormat = f
}, },
isValidBarcode isValidBarcode,
) { ) {
if (FidelityRepository.getRoot() == null) { if (kp2a) {
isLoading = true onSaveKp2a(ctx, navController!!, entry)
scope.launch {
onRefresh(ctx, navController!!)
isLoading = false
if(entry.uid!=null){
addEntry(ctx,entry)
isLoading = true
onSave(ctx,navController)
isLoading = false
onSubmit(navController)
}else {
showDialog = true
}
}
} else { } else {
if(entry.uid!=null){
addEntry(ctx,entry)
isLoading = true isLoading = true
scope.launch { scope.launch {
onSave(ctx, navController!!) onSaveStandalone(ctx, navController!!, entry, group)
isLoading = false 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 tErr = ""
var cErr = "" var cErr = ""
var fErr = "" 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.code.isBlank()) cErr = "Code cannot be empty"
if (entry.format.isBlank()) fErr = "Format cannot be empty" if (entry.format.isBlank()) fErr = "Format cannot be empty"
@@ -358,6 +377,38 @@ private fun onSubmitIfValid(
} }
object CreateEntryEventHandler { 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){ fun onSubmit(navController: NavHostController){
navController.popBackStack() navController.popBackStack()
activeEntry.value = activeEntry.value.copy( 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 package net.helcel.fidelity.activity.fragment
import android.app.Activity
import android.content.Context import android.content.Context
import androidx.activity.compose.BackHandler 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items 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.FloatingActionButton
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TextFieldDefaults import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close 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.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.material.icons.filled.Search
import androidx.compose.material3.Card import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.fidelity.activity.ToastHelper import net.helcel.fidelity.activity.ToastHelper
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.isSearchVisible import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.isSearchVisible
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onAdd 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.onQuery
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onView import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onView
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.searchQuery 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.FidelityEntry
import net.helcel.fidelity.tools.FidelityRepository.activeEntry 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.entries
import net.helcel.fidelity.tools.FidelityRepository.genCredentials import net.helcel.fidelity.tools.KeepassDatabase
import net.helcel.fidelity.tools.FidelityRepository.importDB import net.helcel.fidelity.tools.FidelityRepository.loadEntries
import net.helcel.fidelity.tools.FidelityRepository.start import net.helcel.fidelity.tools.FidelityRepository.removeEntry
import net.helcel.fidelity.tools.KeePassStore.loadCredentials import net.helcel.fidelity.tools.Kp2a
@Preview @Preview
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -91,6 +83,20 @@ fun LauncherScreen(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() } 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) { BackHandler(enabled = isSearchVisible) {
onQuery() onQuery()
@@ -118,7 +124,9 @@ fun LauncherScreen(
onRefresh = { onRefresh = {
isRefreshingState = true isRefreshingState = true
scope.launch { 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 isRefreshingState = false
} }
}, },
@@ -126,6 +134,45 @@ fun LauncherScreen(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
Column(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) { if (isSearchVisible) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.requestFocus() focusRequester.requestFocus()
@@ -187,17 +234,19 @@ fun LauncherScreen(
) { ) {
Icon(Icons.Default.Add, contentDescription = "Add") Icon(Icons.Default.Add, contentDescription = "Add")
} }
if (kp2a)
FloatingActionButton( FloatingActionButton(
onClick = { onClick = { Kp2a.launchQuery(context, kp2aQueryLauncher) },
showHidden=!showHidden modifier = Modifier
}, modifier = Modifier .align(Alignment.BottomEnd)
.align(Alignment.BottomStart) .padding(end = 16.dp, bottom = 88.dp),
.padding(16.dp).size(24.dp), backgroundColor = MaterialTheme.colors.secondary,
backgroundColor = if(showHidden) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary,
) { ) {
Icon(Icons.Default.HideSource, Icon(
tint= if(showHidden) MaterialTheme.colors.background else MaterialTheme.colors.onSecondary, Icons.Default.Key,
contentDescription = "Show Hidden") tint = MaterialTheme.colors.onSecondary,
contentDescription = "Fetch from Keepass2Android"
)
} }
} }
@@ -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 { object LauncherEventHandlers {
var isSearchVisible by mutableStateOf(false) var isSearchVisible by mutableStateOf(false)
var searchQuery by mutableStateOf("") var searchQuery by mutableStateOf("")
var CRED: CredentialResult.Success? = null
fun onAdd(navController: NavHostController) { fun onAdd(navController: NavHostController) {
navController.navigate("edit") navController.navigate("edit")
@@ -325,48 +278,22 @@ object LauncherEventHandlers {
if (!isSearchVisible) searchQuery = "" if (!isSearchVisible) searchQuery = ""
} }
suspend fun onSave(context: Context, navController: NavHostController){ /** Standalone: writes the database back. Missing credentials send the user to setup. */
try { suspend fun onSave(context: Context, navController: NavHostController): Boolean {
if (CRED == null) { if (!KeepassDatabase.ensureCredentials(context)) {
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())
navController.navigate("init") navController.navigate("init")
return false
} }
return KeepassDatabase.save(context)
} }
suspend fun onRefresh(context: Context, navController: NavHostController) { /** Standalone: (re)opens the database and imports its cards. Missing credentials send the user to setup. */
try { suspend fun onRefresh(context: Context, navController: NavHostController): Boolean {
if (CRED == null) { if (!KeepassDatabase.ensureCredentials(context)) {
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())
navController.navigate("init") navController.navigate("init")
return false
} }
return KeepassDatabase.unlock(context)
} }
fun onView(navController: NavHostController, entry: FidelityEntry) { fun onView(navController: NavHostController, entry: FidelityEntry) {
@@ -389,6 +316,10 @@ object LauncherEventHandlers {
entries[index] = entry.copy(hidden = !entry.hidden) entries[index] = entry.copy(hidden = !entry.hidden)
} }
fun onRemove(context: Context, entry: FidelityEntry) {
removeEntry(context, entry)
}
fun onEdit(navController: NavHostController, entry: FidelityEntry){ fun onEdit(navController: NavHostController, entry: FidelityEntry){
activeEntry.value = entry activeEntry.value = entry
navController.navigate("edit") 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.Text
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Undo 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.ExpandMore
import androidx.compose.material.icons.filled.Folder
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -32,11 +34,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import com.kunzisoft.keepass.database.element.Group import com.kunzisoft.keepass.database.element.Group
import com.kunzisoft.keepass.database.element.node.Node 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 @Preview
@Composable @Composable
fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) { fun TreeSelectorDialog(groupsOnly: Boolean = false, onDismiss: (Node?) -> Unit = {}) {
Dialog( Dialog(
onDismissRequest = {onDismiss(null)}, onDismissRequest = {onDismiss(null)},
content = { content = {
@@ -46,8 +52,8 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
RoundedCornerShape(8.dp) RoundedCornerShape(8.dp)
) )
) { ) {
var currentRoot by remember { mutableStateOf(FidelityRepository.getRoot()) } var currentRoot by remember { mutableStateOf(KeepassDatabase.getRoot()) }
var selection by remember { mutableStateOf<Node?>(FidelityRepository.getRoot()) } var selection by remember { mutableStateOf<Node?>(KeepassDatabase.getRoot()) }
Column( Column(
modifier = Modifier.fillMaxWidth().padding(8.dp) modifier = Modifier.fillMaxWidth().padding(8.dp)
@@ -76,32 +82,29 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
LazyColumn(modifier = Modifier.fillMaxHeight(0.75f)) { LazyColumn(modifier = Modifier.fillMaxHeight(0.75f)) {
items(currentRoot?.getChildGroups() ?: emptyList()) { entry -> items(currentRoot?.getChildGroups() ?: emptyList()) { entry ->
val isSel = (entry.nodeId == selection?.nodeId) val isSel = (entry.nodeId == selection?.nodeId)
val hasChildren = entry.getChildGroups().isNotEmpty() ||
(!groupsOnly && entry.getChildEntries().isNotEmpty())
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(color = if (isSel) MaterialTheme.colors.primary else MaterialTheme.colors.background) .background(color = if (isSel) MaterialTheme.colors.primary else MaterialTheme.colors.background)
.clickable { .clickable {
if (entry.getChildEntries().isNotEmpty()) {
currentRoot = entry
selection = entry selection = entry
} else if (entry.getChildGroups().isNotEmpty()) { if (hasChildren) currentRoot = entry
currentRoot = entry
selection = entry
} else {
selection = entry
}
} }
.padding(8.dp) .padding(8.dp)
) { ) {
if (entry.getChildEntries().isNotEmpty() || entry.getChildGroups() Icon(
.isNotEmpty() imageVector = Icons.Default.Folder,
) { contentDescription = "group",
tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground
)
if (hasChildren)
Icon( Icon(
imageVector = Icons.Default.ExpandMore, imageVector = Icons.Default.ExpandMore,
contentDescription = null, contentDescription = null,
tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground
) )
}
Text( Text(
entry.title, entry.title,
modifier = Modifier.padding(start = 8.dp), 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) val isSel = (entry.nodeId == selection?.nodeId)
Row( Row(
modifier = Modifier modifier = Modifier
@@ -120,6 +123,11 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
} }
.padding(8.dp) .padding(8.dp)
) { ) {
Icon(
imageVector = Icons.Default.CreditCard,
contentDescription = "entry",
tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground
)
Text( Text(
entry.title, entry.title,
modifier = Modifier.padding(start = 8.dp), modifier = Modifier.padding(start = 8.dp),
@@ -28,6 +28,7 @@ import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TextFieldDefaults import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -52,9 +53,10 @@ import kotlinx.coroutines.withContext
import net.helcel.fidelity.activity.ToastHelper import net.helcel.fidelity.activity.ToastHelper
import net.helcel.fidelity.activity.fragment.SetupEventHandlers.onOpen import net.helcel.fidelity.activity.fragment.SetupEventHandlers.onOpen
import net.helcel.fidelity.tools.CredentialResult import net.helcel.fidelity.tools.CredentialResult
import net.helcel.fidelity.tools.FidelityRepository.genCredentials import net.helcel.fidelity.tools.KeepassDatabase
import net.helcel.fidelity.tools.FidelityRepository.importDB import net.helcel.fidelity.tools.KeepassDatabase.genCredentials
import net.helcel.fidelity.tools.FidelityRepository.start 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.loadCredentials
import net.helcel.fidelity.tools.KeePassStore.packCredentials import net.helcel.fidelity.tools.KeePassStore.packCredentials
import net.helcel.fidelity.tools.KeePassStore.saveCredentials import net.helcel.fidelity.tools.KeePassStore.saveCredentials
@@ -223,9 +225,6 @@ fun InitialScreen(
val res = onOpen(context, dbFile!!, password, keyFile) val res = onOpen(context, dbFile!!, password, keyFile)
if(res != null){ if(res != null){
ToastHelper.show(context, "Successful... Importing") ToastHelper.show(context, "Successful... Importing")
withContext(Dispatchers.IO) {
start(context, dbFile!!,genCredentials(context, res))
}
importDB(context) importDB(context)
navController!!.popBackStack() navController!!.popBackStack()
navController.navigate("launcher") navController.navigate("launcher")
@@ -238,6 +237,13 @@ fun InitialScreen(
) { ) {
Text("Continue") 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 Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -254,12 +260,16 @@ fun InitialScreen(
} }
object SetupEventHandlers { 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? { suspend fun onOpen(context: Context, db: Uri, p: String, key: Uri?): CredentialResult.Success? {
try { try {
val packCred = packCredentials(db, p, key) val packCred = packCredentials(db, p, key)
withContext(Dispatchers.IO) { val opened = withContext(Dispatchers.IO) {
start(context, db, genCredentials(context, packCred) 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) { val res = withContext(Dispatchers.Main) {
@@ -267,7 +277,10 @@ object SetupEventHandlers {
} }
return when (res) { return when (res) {
CredentialResult.AuthFailed, CredentialResult.NoData -> null CredentialResult.AuthFailed, CredentialResult.NoData -> null
is CredentialResult.Success -> res is CredentialResult.Success -> {
KeepassDatabase.credentials = res
res
}
} }
} catch (e: Exception) { } catch (e: Exception) {
ToastHelper.show(context, e.message.toString()) ToastHelper.show(context, e.message.toString())
@@ -20,6 +20,7 @@ import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -62,11 +63,19 @@ fun ViewEntryScreen(
activity?.window?.attributes = activity.window?.attributes?.apply { activity?.window?.attributes = activity.window?.attributes?.apply {
screenBrightness = if (isFull) 1f else BRIGHTNESS_OVERRIDE_NONE 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 { try {
bitmap = generateBarcode(entry.code, entry.format, 1024) bitmap = generateBarcode(entry.code, entry.format, 1024)
} catch (_: Exception) { } catch (e: Exception) {
bitmap = null 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 { 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.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties import android.security.keystore.KeyProperties
import android.util.Base64 import android.util.Base64
import android.widget.Toast
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import javax.crypto.Cipher import javax.crypto.Cipher
@@ -16,6 +19,7 @@ import com.kunzisoft.keepass.utils.parseUri
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import net.helcel.fidelity.activity.ToastHelper
import java.security.KeyStore import java.security.KeyStore
import javax.crypto.KeyGenerator import javax.crypto.KeyGenerator
import javax.crypto.SecretKey import javax.crypto.SecretKey
@@ -36,6 +40,17 @@ sealed class CredentialResult {
private const val KEY_ALIAS = "keepass_bio_key" 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 { fun getOrCreateBiometricKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey } keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
@@ -47,6 +62,12 @@ fun getOrCreateBiometricKey(): SecretKey {
).apply { ).apply {
setBlockModes(KeyProperties.BLOCK_MODE_GCM) setBlockModes(KeyProperties.BLOCK_MODE_GCM)
setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
if (deviceCredentialSupported)
setUserAuthenticationParameters(
0,
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
)
else
setUserAuthenticationRequired(true) setUserAuthenticationRequired(true)
setInvalidatedByBiometricEnrollment(true) setInvalidatedByBiometricEnrollment(true)
}.build() }.build()
@@ -108,6 +129,17 @@ object KeePassStore {
suspend fun showBiometricPrompt(activity: FragmentActivity, enc: Boolean): Cipher? { suspend fun showBiometricPrompt(activity: FragmentActivity, enc: Boolean): Cipher? {
val prefs = activity.securePrefs.data.first() val prefs = activity.securePrefs.data.first()
return suspendCancellableCoroutine { cont -> 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 executor = ContextCompat.getMainExecutor(activity)
val biometricPrompt = BiometricPrompt( val biometricPrompt = BiometricPrompt(
activity, activity,
@@ -119,20 +151,22 @@ suspend fun showBiometricPrompt(activity: FragmentActivity, enc: Boolean): Ciphe
override fun onAuthenticationError(code: Int, msg: CharSequence) { override fun onAuthenticationError(code: Int, msg: CharSequence) {
cont.resume(null) { _, _, _ -> } cont.resume(null) { _, _, _ -> }
} }
override fun onAuthenticationFailed() { // onAuthenticationFailed is a retryable miss: the prompt stays open, so the
cont.resume(null) { _, _, _ -> } // 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) } val iv = if(enc) null else prefs[KeePassKeys.IV]?.let { Base64.decode(it, Base64.DEFAULT) }
if (!enc && iv == null) { if (!enc && iv == null) {
cont.resume(null) { _, _, _ -> } cont.resume(null) { _, _, _ -> }
return@suspendCancellableCoroutine
} }
val cipher = getCipherForDecryption(getOrCreateBiometricKey(), iv) val cipher = getCipherForDecryption(getOrCreateBiometricKey(), iv)
val promptInfo = BiometricPrompt.PromptInfo.Builder() val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock KeePass") .setTitle("Unlock KeePass")
.setSubtitle("Authenticate to access your KeePass database") .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() .build()
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) 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.content.Context
import android.net.Uri 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.Database
import com.kunzisoft.keepass.database.element.Field import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.Group 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.database.element.security.ProtectedString
import com.kunzisoft.keepass.hardware.HardwareKey import com.kunzisoft.keepass.hardware.HardwareKey
import com.kunzisoft.keepass.utils.getBinaryDir 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.io.File
import java.util.UUID import java.util.UUID
object FidelityKeepassFields { /**
const val FIDELITYFORMAT = "FidelityFormat" * Standalone mode: the KDBX file opened through the bundled KeePassDX engine.
const val FIDELITYCODE = "FidelityCode" *
} * [unlock] and [save] are the session-level operations the screens use; the rest is the
* raw file handling underneath them.
@Serializable */
data class FidelityEntry( object KeepassDatabase {
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 {
private var db: Database = Database() private var db: Database = Database()
private var binaryDir: File? = null 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? { fun getRoot(): Group? {
return db.rootGroup 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 { fun start(ctx: Context, uri: Uri?, c: MasterCredential): Boolean {
if (binaryDir == null) binaryDir = ctx.getBinaryDir() if (binaryDir == null) binaryDir = ctx.getBinaryDir()
if (uri == null) return false 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) { fun importDB(context: Context) {
val seenID= arrayListOf<String>() val seenID= arrayListOf<String>()
fun importDBRec(group: Group) { fun importDBRec(group: Group) {
@@ -119,8 +150,6 @@ object FidelityRepository {
} else { } else {
entries.add(newEntry) entries.add(newEntry)
} }
} }
group.getChildGroups().forEach { importDBRec(it) } group.getChildGroups().forEach { importDBRec(it) }
} }
@@ -133,36 +162,19 @@ object FidelityRepository {
saveEntries(context) saveEntries(context)
} }
fun saveEntries(context: Context) { /**
val prefs = context.getSharedPreferences("fidelity_prefs", Context.MODE_PRIVATE) * Writes the card into the loaded database. [FidelityEntry.uid] is either the id of an
prefs.edit { putString("entries", Json.encodeToString( * existing entry, which is updated in place, or the id of the group to create it in.
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()))
}
}
}
fun addEntry(ctx: Context, entry: FidelityEntry) { fun addEntry(ctx: Context, entry: FidelityEntry) {
val dbEntry = db.getEntryById(NodeIdUUID(UUID.fromString(entry.uid))) ?: db.createEntry() val id = NodeIdUUID(UUID.fromString(entry.uid))
val dbParent = db.getGroupById(NodeIdUUID(UUID.fromString(entry.uid))) val existing = db.getEntryById(id)
dbEntry?.apply { 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( putExtraField(
Field( Field(
FidelityKeepassFields.FIDELITYCODE, FidelityKeepassFields.FIDELITYCODE,
@@ -175,11 +187,11 @@ object FidelityRepository {
ProtectedString(true, entry.format.toCharArray()) 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.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) 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 { plugins {
id 'com.android.application' version '9.2.1' apply false id 'com.android.application' version '9.3.2' apply false
id 'com.android.library' version '9.2.1' apply false id 'com.android.library' version '9.3.2' apply false
id 'org.jetbrains.kotlin.android' version '2.3.21' apply false id 'org.jetbrains.kotlin.android' version '2.3.21' apply false
id 'com.autonomousapps.dependency-analysis' version '3.13.0' apply true id 'com.autonomousapps.dependency-analysis' version '3.13.0' apply true
} }
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
retries=0 retries=0
retryBackOffMs=500 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>