Compare commits
311
Commits
aa29f61e8f
..
1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a76638f27
|
||
|
|
d05b34573e
|
||
|
|
732f9558f6
|
||
|
|
0d0b2e7091 | ||
|
|
6340a7b686 | ||
|
|
1e30b1a930 | ||
|
|
d39502a5a2 | ||
|
|
fee79f0030 | ||
|
|
06cc53f28b | ||
|
|
1ce8ac4a92 | ||
|
|
6d95a794cf | ||
|
|
d8d8bc72ed | ||
|
|
639b16e317 | ||
|
|
dcff45dcc4 | ||
|
|
d75ed155c8 | ||
|
|
790a3e91bb | ||
|
|
d8f4774757 | ||
|
|
c462813cc4 | ||
|
|
f5d7de95a2 | ||
|
|
d83a7f81e6 | ||
|
|
8e8d0f5398 | ||
|
|
0d3c732999 | ||
|
|
b5b09d6cfe | ||
|
|
fa57d697ff | ||
|
|
622af28e49 | ||
|
|
1dc05e561e | ||
|
|
4c779e5e80 | ||
|
|
776fe395d8 | ||
|
|
1703171b1a | ||
|
|
e20f80c97e | ||
|
|
28fbc9f050 | ||
|
|
5f33dd6168 | ||
|
|
e3812710b6 | ||
|
|
1b6140c7f7 | ||
|
|
191ba40623 | ||
|
|
4e81d7a625 | ||
|
|
fec817623f | ||
|
|
20d7916c16 | ||
|
|
3928ce7a05 | ||
|
|
a9c7a7ca7d | ||
|
|
93471f0827 | ||
|
|
ba70aa5982 | ||
|
|
319e09df22 | ||
|
|
a49f5413a5 | ||
|
|
7846320df7 | ||
|
|
1b3f5ef7cf | ||
|
|
730d5a3650 | ||
|
|
c147eafe90 | ||
|
|
dba7b7110f | ||
|
|
1d199b7581 | ||
|
|
ff63b537f6 | ||
|
|
302b5bc1bd | ||
|
|
156757d23a | ||
|
|
5d39635c66 | ||
|
|
d93307086b | ||
|
|
e08cbe4f7b | ||
|
|
e768e92515 | ||
|
|
78c5ca5a88 | ||
|
|
21fe4a66b1 | ||
|
|
fd4a521d0f | ||
|
|
ed1f05eed5 | ||
|
|
b8232c9a19 | ||
|
|
af52816559 | ||
|
|
90ba769c44 | ||
|
|
505ec55c50 | ||
|
|
a352ebd21d | ||
|
|
ee686c4e0c | ||
|
|
cefc7032bd | ||
|
|
26c1142375 | ||
|
|
20ef7ed04a | ||
|
|
7f9749ef07 | ||
|
|
a907102c19 | ||
|
|
8c5f0f4d15 | ||
|
|
367697b66b | ||
|
|
000ad7f979 | ||
|
|
e320c22bce | ||
|
|
72087c72e6 | ||
|
|
5ef09f0f9b | ||
|
|
4997107aa4 | ||
|
|
7665723ea6 | ||
|
|
62dc4cf0d5 | ||
|
|
bdb15389d8 | ||
|
|
d2ca79ac57 | ||
|
|
3034f08bf4 | ||
|
|
529fb2cf16 | ||
|
|
27ead287ae | ||
|
|
a6dce9a524 | ||
|
|
45bbc6d90b | ||
|
|
fb62271321 | ||
|
|
57885f9882 | ||
|
|
a04f0aa75a | ||
|
|
695f748be5 | ||
|
|
c85e9b6dfa | ||
|
|
283fa26763 | ||
|
|
cc1272f297 | ||
|
|
4510ff6f57 | ||
|
|
155595eca1 | ||
|
|
25c6456bff | ||
|
|
c153710f3d | ||
|
|
2154b74413 | ||
|
|
132d1317a0 | ||
|
|
1468124c23 | ||
|
|
dae511c2a2 | ||
|
|
b89d49d407 | ||
|
|
0fee2c31ab | ||
|
|
eba6b40779
|
||
|
|
93442645fc | ||
|
|
f71399f6fa | ||
|
|
0593eb8fbf | ||
|
|
54dadd5bf2 | ||
|
|
c191eb6248 | ||
|
|
35366a5a09 | ||
|
|
07eb4eefdd | ||
|
|
2a76ef340b
|
||
|
|
249b281ae1
|
||
|
|
82ab401862 | ||
|
|
1cc4533617 | ||
|
|
9b903ce12b | ||
|
|
122d03c971 | ||
|
|
25c993354e | ||
|
|
6eefb809d8 | ||
|
|
3ae56fdbdf | ||
|
|
e4a7413534 | ||
|
|
6c8cfaf128 | ||
|
|
3c71988d12 | ||
|
|
beca592f01 | ||
|
|
ecfaa44756 | ||
|
|
1e12c6e0db | ||
|
|
cb71570834 | ||
|
|
1a2d785d82 | ||
|
|
8e0c8b7eaa | ||
|
|
f96363306d | ||
|
|
ab32a2ecd2 | ||
|
|
85ae80696e | ||
|
|
41bba56d5b | ||
|
|
6e1466f1a3 | ||
|
|
d082c6b1bd | ||
|
|
dfa97108d4 | ||
|
|
ef1f4d071e | ||
|
|
9a64cd2cb6 | ||
|
|
9971360e7e | ||
|
|
7c30df4436 | ||
|
|
6bde0f2129 | ||
|
|
28d23034b0 | ||
|
|
962bf1ca7c | ||
|
|
9364cced34 | ||
|
|
9916804e36 | ||
|
|
7764b1aa06 | ||
|
|
355a5342f4 | ||
|
|
29b28d4935 | ||
|
|
a7833beb8d | ||
|
|
1984406048 | ||
|
|
5224337b13 | ||
|
|
1d15f25cd7 | ||
|
|
d7ffbc40ae | ||
|
|
3f1d809855 | ||
|
|
db9bd281d7 | ||
|
|
1b217ef01d | ||
|
|
6e3487f905 | ||
|
|
e25d6da3aa | ||
|
|
57ab225705 | ||
|
|
43147f0600 | ||
|
|
bdd1fe2323 | ||
|
|
de62af173f | ||
|
|
c9c5009092 | ||
|
|
fa8e9f390f | ||
|
|
7ce9d0f36a | ||
|
|
6066b25d15 | ||
|
|
9262c737c3 | ||
|
|
5b03dc4c7b | ||
|
|
f32ae1a8e8 | ||
|
|
4220a334fd | ||
|
|
c2be61f1a6 | ||
|
|
c8bae4838e | ||
|
|
e60c4cd35b | ||
|
|
c230c8ea72 | ||
|
|
4e2a3de7d2 | ||
|
|
1b7f068976 | ||
|
|
4e24f9e73e | ||
|
|
4b471f5e56 | ||
|
|
463007c80e | ||
|
|
aedb9d0ab4 | ||
|
|
a48fc9a374 | ||
|
|
541c79db7f | ||
|
|
7cad346bb7 | ||
|
|
dc5237652e | ||
|
|
f0bd4100dc | ||
|
|
9903790e87 | ||
|
|
06053b7ec8 | ||
|
|
ab6073d415 | ||
|
|
4f1897af2f | ||
|
|
867e438857 | ||
|
|
b41ff5868e | ||
|
|
ca84a36630 | ||
|
|
5736164692 | ||
|
|
864927e8eb | ||
|
|
8d4267608b | ||
|
|
7e41b10394 | ||
|
|
0f9bfa8f29 | ||
|
|
2322a02e0d | ||
|
|
b73cd5e9f7 | ||
|
|
bcf848f667 | ||
|
|
01a627af79 | ||
|
|
e542c38eb9 | ||
|
|
f1fdd91025 | ||
|
|
741c8eeb80 | ||
|
|
f3c5b80122 | ||
|
|
dd03b7797f | ||
|
|
a705efbeb9 | ||
|
|
6865c0513f | ||
|
|
385484eb5c | ||
|
|
8cc07d2570 | ||
|
|
e876d470ed | ||
|
|
f2a8a9b179 | ||
|
|
a03e9553c3 | ||
|
|
6f5fc5733e | ||
|
|
0cff562c76 | ||
|
|
a9e5e0c969 | ||
|
|
ee1ef7b2de | ||
|
|
b22c24aa54 | ||
|
|
0ceee3ab67 | ||
|
|
5c4b20d57c | ||
|
|
5c4b3c7df0 | ||
|
|
933578fca0 | ||
|
|
ec12827b21 | ||
|
|
3655987082 | ||
|
|
28ec153a0c | ||
|
|
f3d7059221 | ||
|
|
c83ef3d3c4 | ||
|
|
4dfb171d41 | ||
|
|
ee6a8dc35a | ||
|
|
8e22babdc2 | ||
|
|
a770f0d381 | ||
|
|
7976c05e76 | ||
|
|
af8d718058 | ||
|
|
ccf9da6e98 | ||
|
|
6c27af1404 | ||
|
|
daef958340 | ||
|
|
1cea5b6c3f | ||
|
|
4d52634e18 | ||
|
|
6f6d220f4f | ||
|
|
34858d23d5 | ||
|
|
8ddd042579 | ||
|
|
7136f8c883 | ||
|
|
372274cfc3 | ||
|
|
54ff955670 | ||
|
|
f4f18258d1 | ||
|
|
b43d1a53e2 | ||
|
|
63c1bd1d7e | ||
|
|
4e33f88f4f | ||
|
|
9893c8ddae | ||
|
|
3a5a9ca273 | ||
|
|
2b4823206c | ||
|
|
60bc918153 | ||
|
|
9454b612ce | ||
|
|
f5f472d39e | ||
|
|
58182a51db | ||
|
|
1db246eab9 | ||
|
|
67ade9909c | ||
|
|
e412cee462 | ||
|
|
46c9aedb7c | ||
|
|
089de53398 | ||
|
|
dcc18bc26e | ||
|
|
89d9f0274e | ||
|
|
126cfe08a3 | ||
|
|
7866c074ed | ||
|
|
d8d91f091a | ||
|
|
2b3d01a680 | ||
|
|
99ffa3226e | ||
|
|
a0af4a3d24 | ||
|
|
aacedd40e0 | ||
|
|
d9e0cb0502 | ||
|
|
08cfbc1e53 | ||
|
|
f24e15a9f3 | ||
|
|
adcd5f50f3 | ||
|
|
bb4dd58bbb | ||
|
|
25c7ddae1c | ||
|
|
40f28ceca9 | ||
|
|
6622ef0d94 | ||
|
|
950678e279 | ||
|
|
5544289d0f | ||
|
|
d7f32d0841 | ||
|
|
b1fc257786 | ||
|
|
140ef2cb64 | ||
|
|
55b730f73e | ||
|
|
41d6ba0e87 | ||
|
|
8124ff0f55 | ||
|
|
0c862b4756 | ||
|
|
6453e56e18 | ||
|
|
ddf56d86ba | ||
|
|
5cc072921d | ||
|
|
247da0b8b6 | ||
|
|
993524e641 | ||
|
|
c17026509c | ||
|
|
595524f2d1 | ||
|
|
f322e35fcb | ||
|
|
045ad6eb89 | ||
|
|
8c5f9b988d | ||
|
|
4803c2d59a | ||
|
|
ce827e9c0d | ||
|
|
4ee6bf0bcf | ||
|
|
80fbb71bfd | ||
|
|
c4d5eb2831 | ||
|
|
958f23803e | ||
|
|
78afa1163a | ||
|
|
767723f642 | ||
|
|
43e462256b | ||
|
|
9986b58fc4 | ||
|
|
86cd3e89eb | ||
|
|
36ad7a18b3 | ||
|
|
7412fef388 |
+41
-14
@@ -23,9 +23,16 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Make script executable
|
||||
run: chmod +x ./patch_submodule.sh
|
||||
- name: Run patch submodule script
|
||||
run: ./patch_submodule.sh
|
||||
|
||||
- name: set up secrets
|
||||
run: |
|
||||
echo "${{ secrets.RELEASE_KEYSTORE }}" > keystore.asc
|
||||
@@ -33,8 +40,6 @@ jobs:
|
||||
gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch keystore.asc > app/keystore.properties
|
||||
gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch key.asc > app/key.jks
|
||||
|
||||
- uses: gradle/wrapper-validation-action@v3
|
||||
|
||||
- name: create and checkout branch
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
@@ -42,24 +47,46 @@ jobs:
|
||||
run: git checkout -B "$BRANCH"
|
||||
|
||||
- name: set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v6
|
||||
with:
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
distribution: "temurin"
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
# Scoped to :app so the upstream KeePassDX submodule tests do not gate this build.
|
||||
- name: Run unit tests
|
||||
run: ./gradlew :app:testDebugUnitTest
|
||||
|
||||
- name: Build APK
|
||||
run: ./gradlew assemble
|
||||
run: |
|
||||
VERSION_CODE=$(git rev-list --count HEAD)
|
||||
VERSION_BASE=$(git describe --tags --abbrev=0 | sed 's/^v//')
|
||||
VERSION_DEV=$(git rev-list --count $(git describe --tags --abbrev=0)..HEAD)
|
||||
if [ $VERSION_DEV -gt 0 ]; then
|
||||
VERSION_NAME="${VERSION_BASE}.${VERSION_DEV}"
|
||||
else
|
||||
VERSION_NAME="${VERSION_BASE}"
|
||||
fi
|
||||
./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
|
||||
|
||||
# - name: Upload APK
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: app.apk
|
||||
# path: app/build/outputs/apk/release/app-release.apk
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
path: app/build/outputs/apk/signedRelease/app-signedRelease.apk
|
||||
compression-level: 0
|
||||
archive: false
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: |
|
||||
app/build/outputs/apk/release/app-release.apk
|
||||
app/build/outputs/apk/signedRelease/app-signedRelease.apk
|
||||
prerelease: |
|
||||
if [[ ${{ github.ref }} =~ ^refs/tags/[0-9]+\.[0-9]+[a-z]+$ ]]; then
|
||||
echo "false"
|
||||
else
|
||||
echo "true"
|
||||
fi
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
<h1>Keepass Fidelity</h1>
|
||||
<img width="100px" src="./metadata/en-US/images/icon.png" alt="Logo">
|
||||
|
||||
<p>A minimalist fidelity/loyalty card plugin</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-with-love.svg" alt="Built with love">
|
||||
<br>
|
||||
<a href="https://github.com/choelzl/keepass-fidelity/actions/workflows/build.yml">
|
||||
<img src="https://github.com/choelzl/keepass-fidelity/actions/workflows/build.yml/badge.svg?branch=main" alt="Build Status">
|
||||
<a href="https://github.com/helcel-net/keepass-fidelity/actions/workflows/build.yml">
|
||||
<img src="https://github.com/helcel-net/keepass-fidelity/actions/workflows/build.yml/badge.svg?branch=main" alt="Build Status">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +29,10 @@
|
||||
|
||||
## ⭐ Features
|
||||
|
||||
- Search entries in [Keepass2Android](https://github.com/PhilippC/keepass2android/)
|
||||
- Two storage modes, chosen on first start (switchable later from the launcher):
|
||||
- **Standalone**: opens a `.kdbx` database directly (bundled KeePassDX engine)
|
||||
- **Keepass2Android plugin**: queries and creates entries through the installed Keepass2Android app
|
||||
- Search entries in Keepass Database
|
||||
- Scan & Create entries
|
||||
- Recently used history for fast access
|
||||
- Protect entries from caching
|
||||
@@ -40,7 +45,7 @@
|
||||
<a href="https://apt.izzysoft.de/fdroid/index/apk/net.helcel.fidelity">
|
||||
<img width="200" height="80" alt="Izzy Download" src=".github/images/izzy.png">
|
||||
</a>
|
||||
<a href="https://github.com/choelzl/keepass-fidelity/releases/latest">
|
||||
<a href="https://github.com/helcel-net/keepass-fidelity/releases/latest">
|
||||
<img width="200" height="84" alt="APK Download" src=".github/images/apk.png">
|
||||
</a>
|
||||
</div>
|
||||
@@ -66,7 +71,7 @@ Thanks to all contributors, the developers of our dependencies, and our users.
|
||||
## 📝 License
|
||||
|
||||
```
|
||||
Copyright (C) 2024 Helcel
|
||||
Copyright (C) 2026 Helcel
|
||||
|
||||
Licensed under the Unlicense
|
||||
For more information, please refer to <https://unlicense.org>
|
||||
|
||||
+79
-24
@@ -1,27 +1,30 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.21'
|
||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.2.21'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.20'
|
||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.20'
|
||||
id 'jacoco'
|
||||
}
|
||||
|
||||
|
||||
android {
|
||||
namespace 'net.helcel.fidelity'
|
||||
compileSdk 36
|
||||
compileSdk = 37
|
||||
ndkVersion "25.2.9519653" //Match this to KeypassDX crypto NDK version
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'net.helcel.fidelity'
|
||||
versionName "1.0d"
|
||||
// CI derives both from git (see .github/workflows/build.yml); the fallbacks are for local builds.
|
||||
versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1.3c"
|
||||
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
|
||||
buildConfigField("String", "APP_NAME", "\"Keepass Fidelity\"")
|
||||
manifestPlaceholders["APP_NAME"] = "Keepass Fidelity"
|
||||
minSdk 28
|
||||
targetSdk 36
|
||||
minSdk = 28
|
||||
targetSdk = 37
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
register("release") {
|
||||
try {
|
||||
def keystorePropertiesFile = rootProject.file("app/keystore.properties")
|
||||
def keystoreProperties = new Properties()
|
||||
@@ -41,10 +44,13 @@ android {
|
||||
buildTypes {
|
||||
debug {
|
||||
debuggable true
|
||||
initWith(buildTypes.release)
|
||||
signingConfig signingConfigs.debug
|
||||
enableUnitTestCoverage true
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources false
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
signedRelease {
|
||||
@@ -78,6 +84,25 @@ android {
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "2.2.20"
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
includeAndroidResources = true
|
||||
all {
|
||||
maxParallelForks = 2
|
||||
forkEvery = 50
|
||||
maxHeapSize = "1024m"
|
||||
// Robolectric loads classes through its own sandbox classloader, which leaves them
|
||||
// without a code-source location. Without this JaCoCo skips them entirely and the
|
||||
// report only sees the handful of plain JVM classes.
|
||||
jacoco {
|
||||
includeNoLocationClasses = true
|
||||
excludes = ["jdk.internal.*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
@@ -86,43 +111,73 @@ android {
|
||||
disable 'UsingMaterialAndMaterial3Libraries'
|
||||
disable 'PreviewAnnotationInFunctionWithParameters'
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3:1.4.0'
|
||||
implementation 'androidx.compose.material:material:1.9.5'
|
||||
implementation 'androidx.compose.material:material:1.12.1'
|
||||
implementation 'androidx.compose.material:material-icons-extended:1.7.8'
|
||||
implementation 'androidx.navigation:navigation-compose:2.9.6'
|
||||
implementation 'androidx.navigation:navigation-compose:2.10.1'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
|
||||
implementation "androidx.biometric:biometric:1.2.0-alpha05"
|
||||
implementation "androidx.security:security-crypto:1.1.0"
|
||||
implementation "androidx.datastore:datastore-preferences:1.1.7"
|
||||
implementation "androidx.datastore:datastore-preferences:1.2.1"
|
||||
implementation "androidx.security:security-crypto:1.1.0"
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5'
|
||||
|
||||
implementation 'androidx.camera:camera-lifecycle:1.5.1'
|
||||
implementation 'androidx.camera:camera-view:1.5.1'
|
||||
runtimeOnly 'androidx.camera:camera-camera2:1.5.1'
|
||||
implementation 'androidx.camera:camera-lifecycle:1.6.2'
|
||||
implementation 'androidx.camera:camera-view:1.6.2'
|
||||
runtimeOnly 'androidx.camera:camera-camera2:1.6.2'
|
||||
|
||||
implementation 'com.google.android.material:material:1.13.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0'
|
||||
implementation 'com.google.android.material:material:1.14.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0'
|
||||
implementation 'com.google.zxing:core:3.5.4'
|
||||
|
||||
implementation project(":database")
|
||||
implementation project(":crypto")
|
||||
|
||||
implementation platform('androidx.compose:compose-bom:2025.11.01')
|
||||
implementation 'androidx.compose.ui:ui-tooling:1.9.5'
|
||||
implementation platform('androidx.compose:compose-bom:2026.09.00')
|
||||
implementation 'androidx.compose.ui:ui-tooling:1.12.1'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
|
||||
//Submodule
|
||||
//noinspection NewerVersionAvailable
|
||||
implementation 'joda-time:joda-time:2.14.0'
|
||||
implementation 'joda-time:joda-time:2.14.3'
|
||||
implementation 'org.joda:joda-convert:3.0.1'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'io.mockk:mockk:1.14.11'
|
||||
testImplementation 'org.robolectric:robolectric:4.16.1'
|
||||
testImplementation 'androidx.test:core:1.7.0'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0'
|
||||
testImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
|
||||
androidTestImplementation platform('androidx.compose:compose-bom:2026.09.00')
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
|
||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
androidTestImplementation 'io.mockk:mockk-android:1.14.11'
|
||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||
}
|
||||
|
||||
tasks.register('jacocoTestReport', JacocoReport) {
|
||||
dependsOn 'testDebugUnitTest'
|
||||
|
||||
reports {
|
||||
xml.required = true
|
||||
html.required = true
|
||||
}
|
||||
|
||||
def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*']
|
||||
def debugTree = fileTree(dir: "${project.layout.buildDirectory.asFile.get()}/intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes", excludes: fileFilter)
|
||||
def mainSrc = "${project.projectDir}/src/main/java"
|
||||
|
||||
sourceDirectories.from = files([mainSrc])
|
||||
classDirectories.from = files([debugTree])
|
||||
executionData.from = fileTree(dir: project.layout.buildDirectory.asFile.get(), includes: [
|
||||
'jacoco/testDebugUnitTest.exec', 'outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec'
|
||||
])
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -3,5 +3,6 @@
|
||||
-keepattributes Signature
|
||||
|
||||
-keep class org.joda.convert.** { *; }
|
||||
-dontwarn org.threeten.bp.**
|
||||
# Optional. For using GSON @Expose annotation
|
||||
-keepattributes AnnotationDefault,RuntimeVisibleAnnotations
|
||||
@@ -1,20 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-feature android:name="android.hardware.camera" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
||||
|
||||
<!-- Keepass2Android plugin mode: make the KP2A activities resolvable from this app -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="keepass2android.ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="keepass2android.ACTION_START_WITH_TASK" />
|
||||
</intent>
|
||||
<package android:name="keepass2android.keepass2android" />
|
||||
<package android:name="keepass2android.keepass2android_nonet" />
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher_round"
|
||||
android:label="${APP_NAME}"
|
||||
android:supportsRtl="true">
|
||||
<activity
|
||||
android:name=".activity.MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".pluginSDK.PluginAccessBroadcastReceiver"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="keepass2android.ACTION_TRIGGER_REQUEST_ACCESS" />
|
||||
<action android:name="keepass2android.ACTION_RECEIVE_ACCESS" />
|
||||
<action android:name="keepass2android.ACTION_REVOKE_ACCESS" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,11 +1,19 @@
|
||||
package net.helcel.fidelity.activity
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.navigation.compose.NavHost
|
||||
@@ -15,20 +23,32 @@ import net.helcel.fidelity.activity.fragment.CreateEntryScreen
|
||||
import net.helcel.fidelity.activity.fragment.FileScanner
|
||||
import net.helcel.fidelity.activity.fragment.InitialScreen
|
||||
import net.helcel.fidelity.activity.fragment.LauncherScreen
|
||||
import net.helcel.fidelity.activity.fragment.ModeSelectScreen
|
||||
import net.helcel.fidelity.activity.fragment.ScannerScreen
|
||||
import net.helcel.fidelity.activity.fragment.ViewEntryScreen
|
||||
import net.helcel.fidelity.tools.FidelityRepository.entries
|
||||
import net.helcel.fidelity.tools.AppMode
|
||||
import net.helcel.fidelity.tools.AppModeStore
|
||||
import net.helcel.fidelity.tools.FidelityEntry
|
||||
import net.helcel.fidelity.tools.FidelityRepository.cacheEntry
|
||||
import net.helcel.fidelity.tools.FidelityRepository.findEntry
|
||||
import net.helcel.fidelity.tools.FidelityRepository.loadEntries
|
||||
import net.helcel.fidelity.tools.FidelityRepository.transientEntry
|
||||
import net.helcel.fidelity.tools.KeePassStore.hasCredentials
|
||||
import net.helcel.fidelity.tools.Kp2a
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
|
||||
// Entry pushed by Keepass2Android when the user opens this app from an entry.
|
||||
private val incomingEntry = mutableStateOf<FidelityEntry?>(null)
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
actionBar?.hide()
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
AppModeStore.load(this)
|
||||
loadEntries(this.baseContext)
|
||||
incomingEntry.value = Kp2a.entryFromIntent(intent)
|
||||
|
||||
setContent {
|
||||
SysTheme {
|
||||
@@ -39,24 +59,49 @@ class MainActivity : FragmentActivity() {
|
||||
if (!navController.popBackStack()) finish()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
if(!hasCredentials(context)) navController.navigate("init")
|
||||
when (AppModeStore.mode.value) {
|
||||
null -> navController.navigate("mode")
|
||||
AppMode.STANDALONE ->
|
||||
if (!hasCredentials(context)) navController.navigate("init")
|
||||
AppMode.KP2A -> {}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(incomingEntry.value) {
|
||||
val entry = incomingEntry.value ?: return@LaunchedEffect
|
||||
incomingEntry.value = null
|
||||
if (AppModeStore.isKp2a) cacheEntry(context, entry)
|
||||
else transientEntry.value = entry
|
||||
navController.navigate("view/${entry.uid}")
|
||||
}
|
||||
// The app draws edge-to-edge (targetSdk >= 35): keep every screen clear of the
|
||||
// status and navigation bars, and paint the bars with the theme background.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
.systemBarsPadding()
|
||||
) {
|
||||
NavHost(navController = navController, startDestination = "launcher") {
|
||||
composable("exit") { finish() }
|
||||
composable("mode") { ModeSelectScreen(navController) }
|
||||
composable("launcher") { LauncherScreen(navController) }
|
||||
composable("init"){ InitialScreen (navController)}
|
||||
composable("init") { InitialScreen(navController) }
|
||||
composable("scanCam") { ScannerScreen(navController) }
|
||||
composable("scanFile") { FileScanner(navController) }
|
||||
composable("edit"){ CreateEntryScreen(navController) }
|
||||
composable("edit") { CreateEntryScreen(navController) }
|
||||
composable("view/{entryId}") { e ->
|
||||
val entry = entries.find {
|
||||
it.uid == (e.arguments?.getString("entryId") ?: "")
|
||||
}
|
||||
val entry = findEntry(e.arguments?.getString("entryId"))
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -13,8 +14,11 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Checkbox
|
||||
import androidx.compose.material.CheckboxDefaults
|
||||
@@ -31,6 +35,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Camera
|
||||
import androidx.compose.material.icons.filled.FileOpen
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -47,21 +52,28 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import com.google.zxing.FormatException
|
||||
import com.kunzisoft.keepass.database.element.Entry
|
||||
import com.kunzisoft.keepass.database.element.Group
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import net.helcel.fidelity.R
|
||||
import net.helcel.fidelity.activity.ToastHelper
|
||||
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.ensureUnlocked
|
||||
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onCameraScan
|
||||
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onFileScan
|
||||
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSaveKp2a
|
||||
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSaveStandalone
|
||||
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSubmit
|
||||
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh
|
||||
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onSave
|
||||
import net.helcel.fidelity.tools.AppModeStore
|
||||
import net.helcel.fidelity.tools.BarcodeGenerator.generateBarcode
|
||||
import net.helcel.fidelity.tools.FidelityEntry
|
||||
import net.helcel.fidelity.tools.FidelityRepository
|
||||
import net.helcel.fidelity.tools.FidelityRepository.activeEntry
|
||||
import net.helcel.fidelity.tools.FidelityRepository.addEntry
|
||||
import net.helcel.fidelity.tools.KeepassDatabase
|
||||
import net.helcel.fidelity.tools.KeepassDatabase.addEntry
|
||||
import net.helcel.fidelity.tools.FidelityRepository.cacheEntry
|
||||
import net.helcel.fidelity.tools.Kp2a
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
@Preview
|
||||
@@ -78,10 +90,26 @@ fun CreateEntryScreen(navController: NavHostController?) {
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
val ctx = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val kp2a = AppModeStore.isKp2a
|
||||
// Standalone only: where a new card is created (root unless changed). Existing cards
|
||||
// (uid set) stay where they are.
|
||||
var group by remember { mutableStateOf(KeepassDatabase.getRoot()) }
|
||||
val showGroup = !kp2a && entry.uid == null
|
||||
|
||||
fun pickGroup() {
|
||||
isLoading = true
|
||||
scope.launch {
|
||||
if (ensureUnlocked(ctx, navController!!)) {
|
||||
group = group ?: KeepassDatabase.getRoot()
|
||||
showDialog = true
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(entry) {
|
||||
isValidBarcode = false
|
||||
delay(500)
|
||||
delay(500.milliseconds)
|
||||
if (entry.code.isEmpty()) return@LaunchedEffect
|
||||
try {
|
||||
val bmp = generateBarcode(entry.code, entry.format, 600)
|
||||
@@ -102,34 +130,29 @@ fun CreateEntryScreen(navController: NavHostController?) {
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
TreeSelectorDialog(
|
||||
onDismiss = {
|
||||
TreeSelectorDialog(groupsOnly = true) {
|
||||
showDialog = false
|
||||
if(it!=null){
|
||||
entry = entry.copy(uid = it.nodeId?.id.toString())
|
||||
if(it is Entry){
|
||||
entry = entry.copy(title = it.title)
|
||||
if (it is Group) group = it
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
val formats = stringArrayResource(R.array.format_array)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
.imePadding(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp, 32.dp),
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp, 32.dp)
|
||||
.padding(bottom = 120.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
)
|
||||
{
|
||||
OutlinedTextField(
|
||||
value = entry.title,
|
||||
enabled = entry.uid!=null,
|
||||
onValueChange = {
|
||||
entry = entry.copy(title = it)
|
||||
errorTitle = ""
|
||||
@@ -139,8 +162,7 @@ fun CreateEntryScreen(navController: NavHostController?) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.textFieldColors(
|
||||
textColor = if(entry.uid!=null)MaterialTheme.colors.onBackground
|
||||
else MaterialTheme.colors.secondary
|
||||
textColor = MaterialTheme.colors.onBackground
|
||||
),
|
||||
)
|
||||
if (errorTitle.isNotEmpty()) {
|
||||
@@ -158,8 +180,9 @@ fun CreateEntryScreen(navController: NavHostController?) {
|
||||
),
|
||||
label = { Text("Code") },
|
||||
isError = errorCode.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
maxLines = 5,
|
||||
singleLine = false,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (errorCode.isNotEmpty()) {
|
||||
Text(errorCode, color = MaterialTheme.colors.error)
|
||||
@@ -199,6 +222,23 @@ fun CreateEntryScreen(navController: NavHostController?) {
|
||||
Icon(Icons.Default.FileOpen, contentDescription = null)
|
||||
}
|
||||
}
|
||||
if (showGroup) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Group: ", color = MaterialTheme.colors.onBackground)
|
||||
Text(
|
||||
group?.title ?: "(database root)",
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
)
|
||||
Button(onClick = { pickGroup() }) {
|
||||
Icon(Icons.Default.FolderOpen, contentDescription = "Choose group")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (barcodeBitmap != null) {
|
||||
Image(
|
||||
bitmap = barcodeBitmap!!.asImageBitmap(),
|
||||
@@ -227,41 +267,22 @@ fun CreateEntryScreen(navController: NavHostController?) {
|
||||
errorCode = c
|
||||
errorFormat = f
|
||||
},
|
||||
isValidBarcode
|
||||
isValidBarcode,
|
||||
) {
|
||||
if (FidelityRepository.getRoot() == null) {
|
||||
isLoading = true
|
||||
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
|
||||
}
|
||||
}
|
||||
if (kp2a) {
|
||||
onSaveKp2a(ctx, navController!!, entry)
|
||||
} else {
|
||||
if(entry.uid!=null){
|
||||
addEntry(ctx,entry)
|
||||
isLoading = true
|
||||
scope.launch {
|
||||
onSave(ctx, navController!!)
|
||||
onSaveStandalone(ctx, navController!!, entry, group)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,7 +365,7 @@ private fun onSubmitIfValid(
|
||||
var tErr = ""
|
||||
var cErr = ""
|
||||
var fErr = ""
|
||||
if (entry.uid!=null && entry.title.isBlank()) tErr = "Title cannot be empty"
|
||||
if (entry.title.isBlank()) tErr = "Title cannot be empty"
|
||||
if (entry.code.isBlank()) cErr = "Code cannot be empty"
|
||||
if (entry.format.isBlank()) fErr = "Format cannot be empty"
|
||||
|
||||
@@ -356,9 +377,47 @@ private fun onSubmitIfValid(
|
||||
}
|
||||
|
||||
object CreateEntryEventHandler {
|
||||
/** Standalone: the database must be open before groups can be browsed or cards written. */
|
||||
suspend fun ensureUnlocked(context: Context, navController: NavHostController): Boolean =
|
||||
KeepassDatabase.getRoot() != null || onRefresh(context, navController)
|
||||
|
||||
/** Standalone: updates the existing entry, or creates one in [group] (the root when null). */
|
||||
suspend fun onSaveStandalone(
|
||||
context: Context,
|
||||
navController: NavHostController,
|
||||
entry: FidelityEntry,
|
||||
group: Group?,
|
||||
) {
|
||||
if (!ensureUnlocked(context, navController)) return
|
||||
val root = KeepassDatabase.getRoot() ?: return
|
||||
val target =
|
||||
if (entry.uid != null) entry
|
||||
else entry.copy(uid = (group ?: root).nodeId.id.toString())
|
||||
addEntry(context, target)
|
||||
if (onSave(context, navController)) onSubmit(navController)
|
||||
}
|
||||
|
||||
/**
|
||||
* KP2A creates the entry in its own task and never reports back, so the card is cached
|
||||
* right away; a later fetch replaces the local uid with KP2A's.
|
||||
*/
|
||||
fun onSaveKp2a(context: Context, navController: NavHostController, entry: FidelityEntry) {
|
||||
val kpEntry = entry.copy(uid = Kp2a.localUid(entry.title))
|
||||
if (Kp2a.launchAdd(context, kpEntry)) {
|
||||
cacheEntry(context, kpEntry)
|
||||
onSubmit(navController)
|
||||
}
|
||||
}
|
||||
|
||||
fun onSubmit(navController: NavHostController){
|
||||
navController.popBackStack()
|
||||
activeEntry.value = activeEntry.value.copy(null,"","","",false)
|
||||
activeEntry.value = activeEntry.value.copy(
|
||||
uid = null,
|
||||
title = "",
|
||||
code = "",
|
||||
format = "",
|
||||
protected = false
|
||||
)
|
||||
}
|
||||
|
||||
fun onFileScan(navController: NavHostController){
|
||||
|
||||
@@ -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,12 +1,16 @@
|
||||
package net.helcel.fidelity.activity.fragment
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -17,25 +21,24 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.DropdownMenu
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.FloatingActionButton
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.HideSource
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material.icons.filled.SwapHoriz
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -44,29 +47,29 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.helcel.fidelity.activity.ToastHelper
|
||||
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.isSearchVisible
|
||||
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onAdd
|
||||
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onEdit
|
||||
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onHide
|
||||
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onPin
|
||||
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onQuery
|
||||
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh
|
||||
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onView
|
||||
import net.helcel.fidelity.tools.CredentialResult
|
||||
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.searchQuery
|
||||
import net.helcel.fidelity.tools.AppModeStore
|
||||
import net.helcel.fidelity.tools.FidelityEntry
|
||||
import net.helcel.fidelity.tools.FidelityRepository.activeEntry
|
||||
import net.helcel.fidelity.tools.FidelityRepository.end
|
||||
import net.helcel.fidelity.tools.FidelityRepository.cacheEntry
|
||||
import net.helcel.fidelity.tools.FidelityRepository.entries
|
||||
import net.helcel.fidelity.tools.FidelityRepository.genCredentials
|
||||
import net.helcel.fidelity.tools.FidelityRepository.importDB
|
||||
import net.helcel.fidelity.tools.FidelityRepository.start
|
||||
import net.helcel.fidelity.tools.KeePassStore.loadCredentials
|
||||
import net.helcel.fidelity.tools.KeepassDatabase
|
||||
import net.helcel.fidelity.tools.FidelityRepository.loadEntries
|
||||
import net.helcel.fidelity.tools.FidelityRepository.removeEntry
|
||||
import net.helcel.fidelity.tools.Kp2a
|
||||
|
||||
@Preview
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -79,9 +82,32 @@ fun LauncherScreen(
|
||||
var showHidden by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val sortedEntries = remember(entries) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val kp2a = AppModeStore.isKp2a
|
||||
|
||||
val kp2aQueryLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||
val entry = Kp2a.entryFromIntent(result.data)
|
||||
if (entry == null) {
|
||||
ToastHelper.show(context, "Entry has no fidelity code")
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
cacheEntry(context, entry)
|
||||
onView(navController, entry)
|
||||
}
|
||||
|
||||
BackHandler(enabled = isSearchVisible) {
|
||||
onQuery()
|
||||
}
|
||||
|
||||
val sortedEntries = remember(entries, showHidden, searchQuery) {
|
||||
derivedStateOf {
|
||||
entries.filter{showHidden || !it.hidden}.sortedWith(
|
||||
entries.filter {
|
||||
(showHidden || !it.hidden) &&
|
||||
(searchQuery.isEmpty() || it.title.contains(searchQuery, ignoreCase = true))
|
||||
}.sortedWith(
|
||||
compareByDescending<FidelityEntry> { it.pinned }
|
||||
.thenBy { it.hidden }
|
||||
.thenByDescending { it.lastUse }
|
||||
@@ -98,13 +124,83 @@ fun LauncherScreen(
|
||||
onRefresh = {
|
||||
isRefreshingState = true
|
||||
scope.launch {
|
||||
onRefresh(context, navController)
|
||||
// KP2A owns the database: nothing to sync, just reload the cache.
|
||||
if (kp2a) loadEntries(context)
|
||||
else onRefresh(context, navController)
|
||||
isRefreshingState = false
|
||||
}
|
||||
},
|
||||
isRefreshing = isRefreshingState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Top bar: hidden-cards toggle on the left, storage mode on the right.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val hiddenTint =
|
||||
if (showHidden) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary
|
||||
TextButton(onClick = { showHidden = !showHidden }) {
|
||||
Icon(
|
||||
Icons.Default.HideSource,
|
||||
contentDescription = "Show Hidden",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = hiddenTint
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
if (showHidden) "Showing hidden" else "Show hidden",
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = hiddenTint
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { navController.navigate("mode") }) {
|
||||
Icon(
|
||||
Icons.Default.SwapHoriz,
|
||||
contentDescription = "Switch mode",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
if (kp2a) "Keepass2Android" else "Standalone",
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isSearchVisible) {
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
colors = TextFieldDefaults.textFieldColors(
|
||||
textColor = MaterialTheme.colors.onBackground
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.focusRequester(focusRequester),
|
||||
label = { Text("Search") },
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Clear",
|
||||
modifier = Modifier.clickable {
|
||||
searchQuery = ""
|
||||
onQuery()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier
|
||||
@@ -118,6 +214,7 @@ fun LauncherScreen(
|
||||
FidelityRow(navController, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
FloatingActionButton(
|
||||
onClick = { onQuery() },
|
||||
modifier = Modifier
|
||||
@@ -137,17 +234,19 @@ fun LauncherScreen(
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add")
|
||||
}
|
||||
if (kp2a)
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
showHidden=!showHidden
|
||||
}, modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(16.dp).size(24.dp),
|
||||
backgroundColor = if(showHidden) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary,
|
||||
onClick = { Kp2a.launchQuery(context, kp2aQueryLauncher) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = 88.dp),
|
||||
backgroundColor = MaterialTheme.colors.secondary,
|
||||
) {
|
||||
Icon(Icons.Default.HideSource,
|
||||
tint= if(showHidden) MaterialTheme.colors.background else MaterialTheme.colors.onSecondary,
|
||||
contentDescription = "Show Hidden")
|
||||
Icon(
|
||||
Icons.Default.Key,
|
||||
tint = MaterialTheme.colors.onSecondary,
|
||||
contentDescription = "Fetch from Keepass2Android"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,156 +265,35 @@ fun LauncherScreen(
|
||||
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun FidelityRow(
|
||||
navController: NavHostController,
|
||||
e: FidelityEntry
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(2.dp)
|
||||
.combinedClickable(
|
||||
onClick = { onView(navController, e) },
|
||||
onLongClick = { expanded = true },
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colors.primary,
|
||||
contentColor = MaterialTheme.colors.background
|
||||
),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(2.dp)) {
|
||||
Row(modifier = Modifier.padding(14.dp)) {
|
||||
Text(
|
||||
text = e.title,
|
||||
style = MaterialTheme.typography.h6,
|
||||
color = MaterialTheme.colors.onPrimary
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||
if (e.hidden)
|
||||
Icon(
|
||||
Icons.Default.HideSource, contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colors.onPrimary
|
||||
)
|
||||
if (e.hidden && e.pinned)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
if (e.pinned)
|
||||
Icon(
|
||||
Icons.Default.PushPin, contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colors.onPrimary
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
DropdownMenu(
|
||||
modifier = Modifier,
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
DropdownMenuItem(onClick = {
|
||||
expanded = false
|
||||
onEdit(navController, e)
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "edit",
|
||||
)
|
||||
Spacer(modifier= Modifier.width(8.dp))
|
||||
Text("Edit")
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
expanded = false
|
||||
onPin(e)
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.PushPin,
|
||||
contentDescription = "pin",
|
||||
)
|
||||
Spacer(modifier= Modifier.width(8.dp))
|
||||
if(e.pinned) Text("Unpin")
|
||||
else Text("Pin")
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
expanded = false
|
||||
onHide(e)
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.HideSource,
|
||||
contentDescription = "hide",
|
||||
)
|
||||
Spacer(modifier= Modifier.width(8.dp))
|
||||
if(e.hidden) Text("Unhide")
|
||||
else Text("Hide")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object LauncherEventHandlers {
|
||||
var isSearchVisible by mutableStateOf(false)
|
||||
var searchQuery by mutableStateOf("")
|
||||
|
||||
fun onAdd(navController: NavHostController) {
|
||||
navController.navigate("edit")
|
||||
}
|
||||
|
||||
fun onQuery() {
|
||||
//TODO
|
||||
isSearchVisible = !isSearchVisible
|
||||
if (!isSearchVisible) searchQuery = ""
|
||||
}
|
||||
var CRED: CredentialResult.Success? = null
|
||||
|
||||
suspend fun onSave(context: Context, navController: NavHostController){
|
||||
try {
|
||||
if (CRED == null) {
|
||||
val res = loadCredentials(context)
|
||||
when (res) {
|
||||
CredentialResult.AuthFailed, CredentialResult.NoData -> null
|
||||
is CredentialResult.Success -> CRED = res
|
||||
}
|
||||
}
|
||||
CRED!!
|
||||
val cred = withContext(Dispatchers.IO) {
|
||||
genCredentials(context, CRED!!)
|
||||
}
|
||||
if (withContext(Dispatchers.IO) {
|
||||
end(context, CRED!!.db, cred)
|
||||
})
|
||||
throw Exception("Error in saving")
|
||||
} catch (e: Exception) {
|
||||
println(e.toString())
|
||||
/** Standalone: writes the database back. Missing credentials send the user to setup. */
|
||||
suspend fun onSave(context: Context, navController: NavHostController): Boolean {
|
||||
if (!KeepassDatabase.ensureCredentials(context)) {
|
||||
navController.navigate("init")
|
||||
return false
|
||||
}
|
||||
return KeepassDatabase.save(context)
|
||||
}
|
||||
|
||||
suspend fun onRefresh(context: Context, navController: NavHostController) {
|
||||
try {
|
||||
if (CRED == null) {
|
||||
val res = loadCredentials(context)
|
||||
when (res) {
|
||||
CredentialResult.AuthFailed, CredentialResult.NoData -> null
|
||||
is CredentialResult.Success -> CRED = res
|
||||
|
||||
}
|
||||
}
|
||||
CRED!!
|
||||
val cred = withContext(Dispatchers.IO) {
|
||||
genCredentials(context, CRED!!)
|
||||
}
|
||||
if (withContext(Dispatchers.IO) {
|
||||
start(context, CRED!!.db, cred)
|
||||
})
|
||||
importDB(context)
|
||||
} catch (e: Exception) {
|
||||
println(e.toString())
|
||||
/** Standalone: (re)opens the database and imports its cards. Missing credentials send the user to setup. */
|
||||
suspend fun onRefresh(context: Context, navController: NavHostController): Boolean {
|
||||
if (!KeepassDatabase.ensureCredentials(context)) {
|
||||
navController.navigate("init")
|
||||
return false
|
||||
}
|
||||
return KeepassDatabase.unlock(context)
|
||||
}
|
||||
|
||||
fun onView(navController: NavHostController, entry: FidelityEntry) {
|
||||
@@ -338,6 +316,10 @@ object LauncherEventHandlers {
|
||||
entries[index] = entry.copy(hidden = !entry.hidden)
|
||||
}
|
||||
|
||||
fun onRemove(context: Context, entry: FidelityEntry) {
|
||||
removeEntry(context, entry)
|
||||
}
|
||||
|
||||
fun onEdit(navController: NavHostController, entry: FidelityEntry){
|
||||
activeEntry.value = entry
|
||||
navController.navigate("edit")
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package net.helcel.fidelity.activity.fragment
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import kotlinx.coroutines.launch
|
||||
import net.helcel.fidelity.tools.AppMode
|
||||
import net.helcel.fidelity.tools.AppModeStore
|
||||
import net.helcel.fidelity.tools.FidelityRepository.loadEntries
|
||||
import net.helcel.fidelity.tools.KeePassStore.hasCredentials
|
||||
import net.helcel.fidelity.tools.Kp2a
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ModeSelectScreen(navController: NavHostController?) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val current = AppModeStore.mode.value
|
||||
val kp2aInstalled = remember { Kp2a.isAvailable(context) }
|
||||
|
||||
BackHandler {
|
||||
// Nothing to go back to before the first choice has been made.
|
||||
if (current == null) navController!!.navigate("exit")
|
||||
else navController!!.popBackStack()
|
||||
}
|
||||
|
||||
fun select(mode: AppMode) {
|
||||
if (mode != current) {
|
||||
AppModeStore.set(context, mode)
|
||||
loadEntries(context)
|
||||
}
|
||||
navController!!.popBackStack("launcher", inclusive = false)
|
||||
scope.launch {
|
||||
if (mode == AppMode.STANDALONE && !hasCredentials(context))
|
||||
navController.navigate("init")
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
"Storage Mode",
|
||||
style = MaterialTheme.typography.h5,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
"Where should your fidelity cards be stored?",
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
ModeCard(
|
||||
icon = Icons.Default.FolderOpen,
|
||||
title = "Standalone",
|
||||
description = "Open a KeePass database (.kdbx) directly. " +
|
||||
"Entries are read and written by this app.",
|
||||
selected = current == AppMode.STANDALONE,
|
||||
) { select(AppMode.STANDALONE) }
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ModeCard(
|
||||
icon = Icons.Default.Extension,
|
||||
title = "Keepass2Android plugin",
|
||||
description = "Use the Keepass2Android app as the database. " +
|
||||
"Entries are queried from and created in Keepass2Android.",
|
||||
selected = current == AppMode.KP2A,
|
||||
) { select(AppMode.KP2A) }
|
||||
if (!kp2aInstalled) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"Keepass2Android does not seem to be installed.",
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = MaterialTheme.colors.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeCard(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
description: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val container = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.surface
|
||||
val content = if (selected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = container,
|
||||
contentColor = content
|
||||
),
|
||||
border = if (selected) null else CardDefaults.outlinedCardBorder(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp), tint = content)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column {
|
||||
Text(title, style = MaterialTheme.typography.h6, color = content)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(description, style = MaterialTheme.typography.body2, color = content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,9 @@ import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Undo
|
||||
import androidx.compose.material.icons.filled.CreditCard
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -32,11 +34,15 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.kunzisoft.keepass.database.element.Group
|
||||
import com.kunzisoft.keepass.database.element.node.Node
|
||||
import net.helcel.fidelity.tools.FidelityRepository
|
||||
import net.helcel.fidelity.tools.KeepassDatabase
|
||||
|
||||
/**
|
||||
* Browses the loaded database. With [groupsOnly] entries are hidden, so the result is the group
|
||||
* a new card is created in; otherwise an existing entry can be picked to attach the card to.
|
||||
*/
|
||||
@Preview
|
||||
@Composable
|
||||
fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
|
||||
fun TreeSelectorDialog(groupsOnly: Boolean = false, onDismiss: (Node?) -> Unit = {}) {
|
||||
Dialog(
|
||||
onDismissRequest = {onDismiss(null)},
|
||||
content = {
|
||||
@@ -46,8 +52,8 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
) {
|
||||
var currentRoot by remember { mutableStateOf(FidelityRepository.getRoot()) }
|
||||
var selection by remember { mutableStateOf<Node?>(FidelityRepository.getRoot()) }
|
||||
var currentRoot by remember { mutableStateOf(KeepassDatabase.getRoot()) }
|
||||
var selection by remember { mutableStateOf<Node?>(KeepassDatabase.getRoot()) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp)
|
||||
@@ -76,32 +82,29 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
|
||||
LazyColumn(modifier = Modifier.fillMaxHeight(0.75f)) {
|
||||
items(currentRoot?.getChildGroups() ?: emptyList()) { entry ->
|
||||
val isSel = (entry.nodeId == selection?.nodeId)
|
||||
val hasChildren = entry.getChildGroups().isNotEmpty() ||
|
||||
(!groupsOnly && entry.getChildEntries().isNotEmpty())
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = if (isSel) MaterialTheme.colors.primary else MaterialTheme.colors.background)
|
||||
.clickable {
|
||||
if (entry.getChildEntries().isNotEmpty()) {
|
||||
currentRoot = entry
|
||||
selection = entry
|
||||
} else if (entry.getChildGroups().isNotEmpty()) {
|
||||
currentRoot = entry
|
||||
selection = entry
|
||||
} else {
|
||||
selection = entry
|
||||
}
|
||||
if (hasChildren) currentRoot = entry
|
||||
}
|
||||
.padding(8.dp)
|
||||
) {
|
||||
if (entry.getChildEntries().isNotEmpty() || entry.getChildGroups()
|
||||
.isNotEmpty()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Folder,
|
||||
contentDescription = "group",
|
||||
tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground
|
||||
)
|
||||
if (hasChildren)
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground
|
||||
)
|
||||
}
|
||||
Text(
|
||||
entry.title,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
@@ -109,7 +112,7 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
|
||||
)
|
||||
}
|
||||
}
|
||||
items(currentRoot?.getChildEntries() ?: emptyList()) { entry ->
|
||||
items(if (groupsOnly) emptyList() else currentRoot?.getChildEntries() ?: emptyList()) { entry ->
|
||||
val isSel = (entry.nodeId == selection?.nodeId)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -120,6 +123,11 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
|
||||
}
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CreditCard,
|
||||
contentDescription = "entry",
|
||||
tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground
|
||||
)
|
||||
Text(
|
||||
entry.title,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -52,8 +53,10 @@ import kotlinx.coroutines.withContext
|
||||
import net.helcel.fidelity.activity.ToastHelper
|
||||
import net.helcel.fidelity.activity.fragment.SetupEventHandlers.onOpen
|
||||
import net.helcel.fidelity.tools.CredentialResult
|
||||
import net.helcel.fidelity.tools.FidelityRepository.genCredentials
|
||||
import net.helcel.fidelity.tools.FidelityRepository.start
|
||||
import net.helcel.fidelity.tools.KeepassDatabase
|
||||
import net.helcel.fidelity.tools.KeepassDatabase.genCredentials
|
||||
import net.helcel.fidelity.tools.KeepassDatabase.importDB
|
||||
import net.helcel.fidelity.tools.KeepassDatabase.start
|
||||
import net.helcel.fidelity.tools.KeePassStore.loadCredentials
|
||||
import net.helcel.fidelity.tools.KeePassStore.packCredentials
|
||||
import net.helcel.fidelity.tools.KeePassStore.saveCredentials
|
||||
@@ -116,8 +119,8 @@ fun InitialScreen(
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
when(val res = loadCredentials(context)) {
|
||||
CredentialResult.AuthFailed -> null
|
||||
CredentialResult.NoData -> null
|
||||
CredentialResult.AuthFailed -> ToastHelper.show(context, "Unable to Load Credentials")
|
||||
CredentialResult.NoData -> ToastHelper.show(context, "Unable to Load Credentials")
|
||||
is CredentialResult.Success -> {
|
||||
if (res.db != null) dbFile = res.db
|
||||
if (res.key != null) keyFile = res.key
|
||||
@@ -219,13 +222,14 @@ fun InitialScreen(
|
||||
onClick = {
|
||||
loading = true
|
||||
scope.launch {
|
||||
if(onOpen(context, dbFile!!, password, keyFile)){
|
||||
val res = onOpen(context, dbFile!!, password, keyFile)
|
||||
if(res != null){
|
||||
ToastHelper.show(context, "Successful... Importing")
|
||||
importDB(context)
|
||||
navController!!.popBackStack()
|
||||
navController.navigate("init")
|
||||
navController.navigate("launcher")
|
||||
}else{
|
||||
ToastHelper.show(context, "Auth failed...")
|
||||
navController!!.popBackStack()
|
||||
navController.navigate("exit")
|
||||
ToastHelper.show(context, "Failed... Retry")
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -233,6 +237,13 @@ fun InitialScreen(
|
||||
) {
|
||||
Text("Continue")
|
||||
}
|
||||
TextButton(
|
||||
enabled = !loading,
|
||||
onClick = { navController!!.navigate("mode") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Use Keepass2Android instead", color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -249,26 +260,33 @@ fun InitialScreen(
|
||||
}
|
||||
|
||||
object SetupEventHandlers {
|
||||
suspend fun onOpen(context: Context, db: Uri, p: String, key: Uri?): Boolean {
|
||||
/** Opens the file with the given credentials, then stores them for later sessions. */
|
||||
suspend fun onOpen(context: Context, db: Uri, p: String, key: Uri?): CredentialResult.Success? {
|
||||
try {
|
||||
val packCred = packCredentials(db, p, key)
|
||||
withContext(Dispatchers.IO) {
|
||||
start(context, db, genCredentials(context, packCred)
|
||||
)
|
||||
val opened = withContext(Dispatchers.IO) {
|
||||
start(context, db, genCredentials(context, packCred))
|
||||
}
|
||||
if (!opened) {
|
||||
ToastHelper.show(context, "Unable to open the database: check the password and key file")
|
||||
return null
|
||||
}
|
||||
|
||||
val res = withContext(Dispatchers.Main) {
|
||||
saveCredentials(context, packCred)
|
||||
}
|
||||
return when (res) {
|
||||
CredentialResult.AuthFailed, CredentialResult.NoData -> false
|
||||
is CredentialResult.Success -> true
|
||||
CredentialResult.AuthFailed, CredentialResult.NoData -> null
|
||||
is CredentialResult.Success -> {
|
||||
KeepassDatabase.credentials = res
|
||||
res
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ToastHelper.show(context, e.message.toString())
|
||||
println("Err${e.toString()}")
|
||||
println(e.message)
|
||||
return false
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -62,11 +63,19 @@ fun ViewEntryScreen(
|
||||
activity?.window?.attributes = activity.window?.attributes?.apply {
|
||||
screenBrightness = if (isFull) 1f else BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
}
|
||||
// Generate once per entry (not on every recomposition) and surface zxing's reason,
|
||||
// e.g. "Contents do not pass checksum" for a mistyped EAN.
|
||||
LaunchedEffect(entry) {
|
||||
try {
|
||||
bitmap = generateBarcode(entry.code, entry.format, 1024)
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
bitmap = null
|
||||
Toast.makeText(context, "Invalid barcode format", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Invalid barcode: " + (e.message ?: "unsupported format"),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
BackHandler {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package net.helcel.fidelity.pluginSDK
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
|
||||
|
||||
object AccessManager {
|
||||
private const val PREF_KEY_SCOPE = "scope"
|
||||
private const val PREF_KEY_TOKEN = "token"
|
||||
private const val PREF_HOSTS = "KP2A.PluginAccess.hosts"
|
||||
|
||||
private fun stringArrayToString(values: ArrayList<String?>): String? {
|
||||
if (values.isEmpty()) return null
|
||||
val a = JSONArray()
|
||||
values.forEach { a.put(it) }
|
||||
return a.toString()
|
||||
}
|
||||
|
||||
private fun stringToStringArray(s: String?): ArrayList<String> {
|
||||
val strings = ArrayList<String>()
|
||||
if (s.isNullOrEmpty()) return strings
|
||||
|
||||
try {
|
||||
val a = JSONArray(s)
|
||||
for (i in 0 until a.length())
|
||||
strings.add(a.optString(i))
|
||||
} catch (e: JSONException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return strings
|
||||
}
|
||||
|
||||
fun storeAccessToken(
|
||||
ctx: Context,
|
||||
hostPackage: String?,
|
||||
accessToken: String?,
|
||||
scopes: ArrayList<String?>
|
||||
) {
|
||||
getPrefsForHost(ctx, hostPackage).edit {
|
||||
putString(PREF_KEY_TOKEN, accessToken)
|
||||
putString(PREF_KEY_SCOPE, stringArrayToString(scopes))
|
||||
}
|
||||
|
||||
val hostPrefs = ctx.getSharedPreferences(PREF_HOSTS, Context.MODE_PRIVATE)
|
||||
if (!hostPrefs.contains(hostPackage))
|
||||
hostPrefs.edit { putString(hostPackage, "") }
|
||||
}
|
||||
|
||||
private fun getPrefsForHost(
|
||||
ctx: Context,
|
||||
hostPackage: String?
|
||||
): SharedPreferences {
|
||||
return ctx.getSharedPreferences("KP2A.PluginAccess.$hostPackage", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun tryGetAccessToken(ctx: Context, hostPackage: String?, scopes: ArrayList<String?>): String? {
|
||||
if (hostPackage.isNullOrEmpty()) return null
|
||||
|
||||
val prefs = getPrefsForHost(ctx, hostPackage)
|
||||
val scopesString = prefs.getString(PREF_KEY_SCOPE, "")
|
||||
val currentScope = stringToStringArray(scopesString)
|
||||
if (!isSubset(scopes, currentScope))
|
||||
return null
|
||||
return prefs.getString(PREF_KEY_TOKEN, null)
|
||||
}
|
||||
|
||||
private fun isSubset(
|
||||
requiredScopes: ArrayList<String?>,
|
||||
availableScopes: ArrayList<String>
|
||||
): Boolean {
|
||||
return availableScopes.containsAll(requiredScopes)
|
||||
}
|
||||
|
||||
fun removeAccessToken(
|
||||
ctx: Context, hostPackage: String?,
|
||||
accessToken: String?
|
||||
) {
|
||||
val prefs = getPrefsForHost(ctx, hostPackage)
|
||||
if (prefs.getString(PREF_KEY_TOKEN, "") == accessToken)
|
||||
prefs.edit { clear() }
|
||||
|
||||
val hostPrefs = ctx.getSharedPreferences(PREF_HOSTS, Context.MODE_PRIVATE)
|
||||
if (hostPrefs.contains(hostPackage))
|
||||
hostPrefs.edit { remove(hostPackage) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package net.helcel.fidelity.pluginSDK
|
||||
|
||||
@Suppress("unused")
|
||||
object KeepassDef {
|
||||
var TitleField: String = "Title"
|
||||
var UserNameField: String = "UserName"
|
||||
var PasswordField: String = "Password"
|
||||
var UrlField: String = "URL"
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package net.helcel.fidelity.pluginSDK
|
||||
|
||||
import android.content.Intent
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
object Kp2aControl {
|
||||
|
||||
fun getAddEntryIntent(
|
||||
fields: HashMap<String, String>,
|
||||
protectedFields: ArrayList<String>?
|
||||
): Intent {
|
||||
val outputData = JSONObject((fields as Map<*, *>)).toString()
|
||||
val startKp2aIntent = Intent(Strings.ACTION_START_WITH_TASK)
|
||||
startKp2aIntent.addCategory(Intent.CATEGORY_DEFAULT)
|
||||
startKp2aIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
startKp2aIntent.putExtra("KP2A_APPTASK", "CreateEntryThenCloseTask")
|
||||
startKp2aIntent.putExtra("ShowUserNotifications", "true")
|
||||
startKp2aIntent.putExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA, outputData)
|
||||
if (protectedFields != null)
|
||||
startKp2aIntent.putStringArrayListExtra(
|
||||
Strings.EXTRA_PROTECTED_FIELDS_LIST,
|
||||
protectedFields
|
||||
)
|
||||
return startKp2aIntent
|
||||
}
|
||||
|
||||
fun getQueryEntryForOwnPackageIntent(): Intent {
|
||||
return Intent(Strings.ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE)
|
||||
}
|
||||
|
||||
fun getEntryFieldsFromIntent(intent: Intent?): HashMap<String, String> {
|
||||
val res = HashMap<String, String>()
|
||||
try {
|
||||
val json = JSONObject(intent?.getStringExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA) ?: "")
|
||||
val itr = json.keys()
|
||||
while (itr.hasNext()) {
|
||||
val key = itr.next()
|
||||
val value = json[key].toString()
|
||||
res[key] = value
|
||||
}
|
||||
} catch (e: JSONException) {
|
||||
e.printStackTrace()
|
||||
} catch (e: NullPointerException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package net.helcel.fidelity.pluginSDK
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class PluginAccessBroadcastReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
val action = intent.action ?: return
|
||||
when (action) {
|
||||
Strings.ACTION_TRIGGER_REQUEST_ACCESS -> requestAccess(ctx, intent)
|
||||
Strings.ACTION_RECEIVE_ACCESS -> receiveAccess(ctx, intent)
|
||||
Strings.ACTION_REVOKE_ACCESS -> revokeAccess(ctx, intent)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun revokeAccess(ctx: Context, intent: Intent) {
|
||||
val senderPackage = intent.getStringExtra(Strings.EXTRA_SENDER)
|
||||
val accessToken = intent.getStringExtra(Strings.EXTRA_ACCESS_TOKEN)
|
||||
AccessManager.removeAccessToken(ctx, senderPackage, accessToken)
|
||||
}
|
||||
|
||||
|
||||
private fun receiveAccess(ctx: Context, intent: Intent) {
|
||||
val senderPackage = intent.getStringExtra(Strings.EXTRA_SENDER)
|
||||
val accessToken = intent.getStringExtra(Strings.EXTRA_ACCESS_TOKEN)
|
||||
AccessManager.storeAccessToken(ctx, senderPackage, accessToken, scopes)
|
||||
}
|
||||
|
||||
private fun requestAccess(ctx: Context, intent: Intent) {
|
||||
val senderPackage = intent.getStringExtra(Strings.EXTRA_SENDER)
|
||||
val requestToken = intent.getStringExtra(Strings.EXTRA_REQUEST_TOKEN)
|
||||
val rpi = Intent(Strings.ACTION_REQUEST_ACCESS)
|
||||
rpi.setPackage(senderPackage)
|
||||
rpi.putExtra(Strings.EXTRA_SENDER, ctx.packageName)
|
||||
rpi.putExtra(Strings.EXTRA_REQUEST_TOKEN, requestToken)
|
||||
|
||||
val token: String? = AccessManager.tryGetAccessToken(ctx, senderPackage, scopes)
|
||||
rpi.putExtra(Strings.EXTRA_ACCESS_TOKEN, token)
|
||||
|
||||
rpi.putStringArrayListExtra(Strings.EXTRA_SCOPES, scopes)
|
||||
ctx.sendBroadcast(rpi)
|
||||
}
|
||||
|
||||
private val scopes: ArrayList<String?> = ArrayList(
|
||||
listOf(
|
||||
Strings.SCOPE_QUERY_CREDENTIALS_FOR_OWN_PACKAGE,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package net.helcel.fidelity.pluginSDK
|
||||
|
||||
@Suppress("unused")
|
||||
object Strings {
|
||||
|
||||
const val SCOPE_DATABASE_ACTIONS = "keepass2android.SCOPE_DATABASE_ACTIONS"
|
||||
const val SCOPE_CURRENT_ENTRY = "keepass2android.SCOPE_CURRENT_ENTRY"
|
||||
const val SCOPE_QUERY_CREDENTIALS_FOR_OWN_PACKAGE =
|
||||
"keepass2android.SCOPE_QUERY_CREDENTIALS_FOR_OWN_PACKAGE"
|
||||
const val SCOPE_QUERY_CREDENTIALS = "keepass2android.SCOPE_QUERY_CREDENTIALS"
|
||||
|
||||
const val EXTRA_SCOPES = "keepass2android.EXTRA_SCOPES"
|
||||
const val EXTRA_PLUGIN_PACKAGE = "keepass2android.EXTRA_PLUGIN_PACKAGE"
|
||||
|
||||
const val EXTRA_SENDER = "keepass2android.EXTRA_SENDER"
|
||||
const val EXTRA_REQUEST_TOKEN = "keepass2android.EXTRA_REQUEST_TOKEN"
|
||||
const val ACTION_START_WITH_TASK = "keepass2android.ACTION_START_WITH_TASK"
|
||||
|
||||
const val ACTION_TRIGGER_REQUEST_ACCESS = "keepass2android.ACTION_TRIGGER_REQUEST_ACCESS"
|
||||
const val ACTION_REQUEST_ACCESS = "keepass2android.ACTION_REQUEST_ACCESS"
|
||||
const val ACTION_RECEIVE_ACCESS = "keepass2android.ACTION_RECEIVE_ACCESS"
|
||||
const val ACTION_REVOKE_ACCESS = "keepass2android.ACTION_REVOKE_ACCESS"
|
||||
|
||||
|
||||
const val EXTRA_ENTRY_OUTPUT_DATA = "keepass2android.EXTRA_ENTRY_OUTPUT_DATA"
|
||||
const val EXTRA_PROTECTED_FIELDS_LIST = "keepass2android.EXTRA_PROTECTED_FIELDS_LIST"
|
||||
const val EXTRA_ACCESS_TOKEN = "keepass2android.EXTRA_ACCESS_TOKEN"
|
||||
const val EXTRA_ENTRY_ID = "keepass2android.EXTRA_ENTRY_ID"
|
||||
|
||||
const val ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE =
|
||||
"keepass2android.ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package net.helcel.fidelity.tools
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.core.content.edit
|
||||
|
||||
const val FIDELITY_PREFS = "fidelity_prefs"
|
||||
|
||||
enum class AppMode {
|
||||
/** Opens a KDBX file directly through the bundled KeePassDX engine. */
|
||||
STANDALONE,
|
||||
|
||||
/** Delegates storage to the Keepass2Android app through its plugin interface. */
|
||||
KP2A,
|
||||
}
|
||||
|
||||
object AppModeStore {
|
||||
private const val KEY_MODE = "app_mode"
|
||||
|
||||
/** Null until the user has picked a mode on the selection screen. */
|
||||
val mode = mutableStateOf<AppMode?>(null)
|
||||
|
||||
val isKp2a: Boolean
|
||||
get() = mode.value == AppMode.KP2A
|
||||
|
||||
fun load(context: Context): AppMode? {
|
||||
val prefs = context.getSharedPreferences(FIDELITY_PREFS, Context.MODE_PRIVATE)
|
||||
mode.value = prefs.getString(KEY_MODE, null)?.let { name ->
|
||||
AppMode.entries.firstOrNull { it.name == name }
|
||||
}
|
||||
return mode.value
|
||||
}
|
||||
|
||||
fun set(context: Context, newMode: AppMode) {
|
||||
context.getSharedPreferences(FIDELITY_PREFS, Context.MODE_PRIVATE)
|
||||
.edit { putString(KEY_MODE, newMode.name) }
|
||||
mode.value = newMode
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import com.google.zxing.common.HybridBinarizer
|
||||
import net.helcel.fidelity.tools.BarcodeFormatConverter.formatToString
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
|
||||
@OptIn(ExperimentalGetImage::class)
|
||||
object BarcodeScanner {
|
||||
|
||||
@@ -56,6 +55,4 @@ object BarcodeScanner {
|
||||
fun bitmapUseCase(bitmap: Bitmap, cb: (String?, String?) -> Unit) {
|
||||
processImage(bitmap, cb)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package net.helcel.fidelity.tools
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import android.widget.Toast
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import javax.crypto.Cipher
|
||||
@@ -16,6 +19,7 @@ import com.kunzisoft.keepass.utils.parseUri
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import net.helcel.fidelity.activity.ToastHelper
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
@@ -36,6 +40,17 @@ sealed class CredentialResult {
|
||||
|
||||
private const val KEY_ALIAS = "keepass_bio_key"
|
||||
|
||||
// Android 11+ can unlock a keystore key with the device PIN/pattern/password as well, which
|
||||
// keeps standalone mode usable on devices without (enrolled) biometrics. Below that, a
|
||||
// CryptoObject can only be released by a biometric.
|
||||
private val deviceCredentialSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
|
||||
private val allowedAuthenticators: Int
|
||||
get() = if (deviceCredentialSupported)
|
||||
BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
else
|
||||
BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
|
||||
fun getOrCreateBiometricKey(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
|
||||
@@ -47,6 +62,12 @@ fun getOrCreateBiometricKey(): SecretKey {
|
||||
).apply {
|
||||
setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
if (deviceCredentialSupported)
|
||||
setUserAuthenticationParameters(
|
||||
0,
|
||||
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
|
||||
)
|
||||
else
|
||||
setUserAuthenticationRequired(true)
|
||||
setInvalidatedByBiometricEnrollment(true)
|
||||
}.build()
|
||||
@@ -108,23 +129,44 @@ object KeePassStore {
|
||||
suspend fun showBiometricPrompt(activity: FragmentActivity, enc: Boolean): Cipher? {
|
||||
val prefs = activity.securePrefs.data.first()
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
val status = BiometricManager.from(activity).canAuthenticate(allowedAuthenticators)
|
||||
if (status != BiometricManager.BIOMETRIC_SUCCESS) {
|
||||
ToastHelper.show(
|
||||
activity,
|
||||
if (deviceCredentialSupported) "Set up a screen lock or fingerprint first"
|
||||
else "Enroll a fingerprint first",
|
||||
Toast.LENGTH_LONG
|
||||
)
|
||||
cont.resume(null) { _, _, _ -> }
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
val executor = ContextCompat.getMainExecutor(activity)
|
||||
val biometricPrompt = BiometricPrompt(
|
||||
activity,
|
||||
executor,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { cont.resume(result.cryptoObject?.cipher) {} }
|
||||
override fun onAuthenticationError(code: Int, msg: CharSequence) { cont.resume(null) {} }
|
||||
override fun onAuthenticationFailed() { cont.resume(null) {} }
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
cont.resume(result.cryptoObject?.cipher) { _, _, _ -> }
|
||||
}
|
||||
override fun onAuthenticationError(code: Int, msg: CharSequence) {
|
||||
cont.resume(null) { _, _, _ -> }
|
||||
}
|
||||
// onAuthenticationFailed is a retryable miss: the prompt stays open, so the
|
||||
// coroutine must stay suspended until it either succeeds or errors out.
|
||||
}
|
||||
)
|
||||
val iv = if(enc) null else prefs[KeePassKeys.IV]?.let { Base64.decode(it, Base64.DEFAULT) }
|
||||
if (!enc && iv == null) { cont.resume(null) {} }
|
||||
if (!enc && iv == null) {
|
||||
cont.resume(null) { _, _, _ -> }
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
val cipher = getCipherForDecryption(getOrCreateBiometricKey(), iv)
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Unlock KeePass")
|
||||
.setSubtitle("Authenticate to access your KeePass database")
|
||||
.setNegativeButtonText("Cancel")
|
||||
.setAllowedAuthenticators(allowedAuthenticators)
|
||||
// A negative button is mandatory without, and forbidden with, device credential.
|
||||
.apply { if (!deviceCredentialSupported) setNegativeButtonText("Cancel") }
|
||||
.build()
|
||||
|
||||
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package net.helcel.fidelity.tools
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Names of the custom KeePass fields a card is stored in. */
|
||||
object FidelityKeepassFields {
|
||||
const val FIDELITYFORMAT = "FidelityFormat"
|
||||
const val FIDELITYCODE = "FidelityCode"
|
||||
}
|
||||
|
||||
/**
|
||||
* A loyalty card as shown by the app.
|
||||
*
|
||||
* [uid] is the KeePass entry UUID in standalone mode, or the id Keepass2Android reports
|
||||
* (falling back to a title-derived one) in plugin mode. The last three fields are
|
||||
* per-device preferences that never reach the database.
|
||||
*/
|
||||
@Serializable
|
||||
data class FidelityEntry(
|
||||
val uid: String? = null,
|
||||
val title: String = "",
|
||||
val code: String = "",
|
||||
val format: String = "",
|
||||
val protected: Boolean = false,
|
||||
|
||||
val hidden: Boolean = false,
|
||||
val pinned: Boolean = false,
|
||||
val lastUse: Int = 0,
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
package net.helcel.fidelity.tools
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* The on-device list of cards shown by the launcher, persisted in shared preferences.
|
||||
*
|
||||
* In standalone mode it mirrors the cards found in the KDBX file (see
|
||||
* [KeepassDatabase.importDB]); in Keepass2Android mode it is a cache of the entries the
|
||||
* user has fetched so far.
|
||||
*/
|
||||
object FidelityRepository {
|
||||
val entries = mutableStateListOf<FidelityEntry>()
|
||||
|
||||
/** Card being edited on the create screen. */
|
||||
val activeEntry = mutableStateOf(FidelityEntry())
|
||||
|
||||
/** Entry handed over by Keepass2Android for viewing without being cached. */
|
||||
val transientEntry = mutableStateOf<FidelityEntry?>(null)
|
||||
|
||||
// Each mode keeps its own cache: standalone uids are KDBX UUIDs, KP2A ones are not.
|
||||
private fun entriesKey() = if (AppModeStore.isKp2a) "entries_kp2a" else "entries"
|
||||
|
||||
fun saveEntries(context: Context) {
|
||||
val prefs = context.getSharedPreferences(FIDELITY_PREFS, Context.MODE_PRIVATE)
|
||||
// In KP2A mode "protected" means the code must never leave the KP2A database.
|
||||
val persisted = if (AppModeStore.isKp2a) entries.filter { !it.protected } else entries
|
||||
prefs.edit { putString(entriesKey(), Json.encodeToString(
|
||||
ListSerializer(FidelityEntry.serializer()),
|
||||
persisted
|
||||
)) }
|
||||
}
|
||||
|
||||
fun loadEntries(context: Context) {
|
||||
val prefs = context.getSharedPreferences(FIDELITY_PREFS, Context.MODE_PRIVATE)
|
||||
entries.clear()
|
||||
try {
|
||||
val json = prefs.getString(entriesKey(), null) ?: return
|
||||
val list = Json.decodeFromString(
|
||||
ListSerializer(FidelityEntry.serializer()),
|
||||
json
|
||||
)
|
||||
entries.addAll(list)
|
||||
}catch(_: Exception){
|
||||
prefs.edit{ putString(entriesKey(),Json.encodeToString(
|
||||
ListSerializer(FidelityEntry.serializer()),emptyList()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts an entry coming from Keepass2Android into the local cache, keeping the
|
||||
* per-device flags (pin/hide/last use) of the entry it replaces. Matching also
|
||||
* falls back to title+code because an optimistically cached entry gets a local uid
|
||||
* until KP2A returns it with its own.
|
||||
*/
|
||||
fun cacheEntry(context: Context, entry: FidelityEntry) {
|
||||
val idx = entries.indexOfFirst {
|
||||
it.uid == entry.uid || (it.title == entry.title && it.code == entry.code)
|
||||
}
|
||||
if (idx >= 0) {
|
||||
val old = entries[idx]
|
||||
entries[idx] = entry.copy(pinned = old.pinned, hidden = old.hidden, lastUse = old.lastUse)
|
||||
} else {
|
||||
entries.add(entry)
|
||||
}
|
||||
saveEntries(context)
|
||||
}
|
||||
|
||||
fun removeEntry(context: Context, entry: FidelityEntry) {
|
||||
entries.removeIf { it.uid == entry.uid }
|
||||
saveEntries(context)
|
||||
}
|
||||
|
||||
fun findEntry(uid: String?): FidelityEntry? =
|
||||
entries.find { it.uid == uid } ?: transientEntry.value?.takeIf { it.uid == uid }
|
||||
}
|
||||
+79
-67
@@ -2,12 +2,6 @@ package net.helcel.fidelity.tools
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.ByteArrayInputStream
|
||||
import kotlinx.serialization.json.Json
|
||||
import androidx.core.content.edit
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.Field
|
||||
import com.kunzisoft.keepass.database.element.Group
|
||||
@@ -17,39 +11,75 @@ import com.kunzisoft.keepass.database.element.node.NodeIdUUID
|
||||
import com.kunzisoft.keepass.database.element.security.ProtectedString
|
||||
import com.kunzisoft.keepass.hardware.HardwareKey
|
||||
import com.kunzisoft.keepass.utils.getBinaryDir
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.helcel.fidelity.activity.ToastHelper
|
||||
import net.helcel.fidelity.tools.FidelityRepository.entries
|
||||
import net.helcel.fidelity.tools.FidelityRepository.saveEntries
|
||||
import net.helcel.fidelity.tools.KeePassStore.loadCredentials
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
object FidelityKeepassFields {
|
||||
const val FIDELITYFORMAT = "FidelityFormat"
|
||||
const val FIDELITYCODE = "FidelityCode"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class FidelityEntry(
|
||||
val uid: String? = null,
|
||||
val title: String = "",
|
||||
val code: String = "",
|
||||
val format: String = "",
|
||||
val protected: Boolean = false,
|
||||
|
||||
val hidden: Boolean = false,
|
||||
val pinned: Boolean = false,
|
||||
val lastUse: Int = 0,
|
||||
)
|
||||
|
||||
object FidelityRepository {
|
||||
/**
|
||||
* Standalone mode: the KDBX file opened through the bundled KeePassDX engine.
|
||||
*
|
||||
* [unlock] and [save] are the session-level operations the screens use; the rest is the
|
||||
* raw file handling underneath them.
|
||||
*/
|
||||
object KeepassDatabase {
|
||||
private var db: Database = Database()
|
||||
private var binaryDir: File? = null
|
||||
val entries = mutableStateListOf<FidelityEntry>()
|
||||
val activeEntry = mutableStateOf(FidelityEntry())
|
||||
|
||||
/** Credentials released by the user for this process, so authentication happens once. */
|
||||
var credentials: CredentialResult.Success? = null
|
||||
|
||||
fun getRoot(): Group? {
|
||||
return db.rootGroup
|
||||
}
|
||||
|
||||
/** Makes sure [credentials] are available, asking the user to authenticate if needed. */
|
||||
suspend fun ensureCredentials(context: Context): Boolean {
|
||||
if (credentials != null) return true
|
||||
return when (val res = loadCredentials(context)) {
|
||||
is CredentialResult.Success -> {
|
||||
credentials = res
|
||||
true
|
||||
}
|
||||
CredentialResult.AuthFailed, CredentialResult.NoData -> {
|
||||
ToastHelper.show(context, "Unable to Load Credentials")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the file with [credentials] and imports its cards into [FidelityRepository]. */
|
||||
suspend fun unlock(context: Context): Boolean {
|
||||
val cred = credentials ?: return false
|
||||
val opened = withContext(Dispatchers.IO) {
|
||||
start(context, cred.db, genCredentials(context, cred))
|
||||
}
|
||||
if (!opened) {
|
||||
ToastHelper.show(context, "Unable to open the database")
|
||||
return false
|
||||
}
|
||||
importDB(context)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Writes the database back to its file. */
|
||||
suspend fun save(context: Context): Boolean {
|
||||
val cred = credentials ?: return false
|
||||
val saved = try {
|
||||
withContext(Dispatchers.IO) { end(context, cred.db, genCredentials(context, cred)) }
|
||||
} catch (e: Exception) {
|
||||
println(e)
|
||||
false
|
||||
}
|
||||
if (!saved) ToastHelper.show(context, "Unable to save the database")
|
||||
return saved
|
||||
}
|
||||
|
||||
fun start(ctx: Context, uri: Uri?, c: MasterCredential): Boolean {
|
||||
if (binaryDir == null) binaryDir = ctx.getBinaryDir()
|
||||
if (uri == null) return false
|
||||
@@ -59,7 +89,7 @@ object FidelityRepository {
|
||||
db.loadData(
|
||||
bitStream, c,
|
||||
{ hardwareKey, seed -> retrieveResponseFromChallenge(hardwareKey, seed) },
|
||||
false, binaryDir!!,
|
||||
readOnly=false, allowUserVerification = false,binaryDir!!,
|
||||
{ BinaryData.canMemoryBeAllocatedInRAM(ctx, it) },
|
||||
false, null
|
||||
)
|
||||
@@ -84,12 +114,13 @@ object FidelityRepository {
|
||||
hardwareKey: HardwareKey? = null
|
||||
): MasterCredential {
|
||||
return MasterCredential(
|
||||
cred.password,
|
||||
cred.password.toCharArray(),
|
||||
cred.key?.let { ctx.contentResolver.openInputStream(cred.key)?.readBytes() },
|
||||
hardwareKey
|
||||
)
|
||||
}
|
||||
|
||||
/** Replaces the card list with the cards found in the database, keeping per-device flags. */
|
||||
fun importDB(context: Context) {
|
||||
val seenID= arrayListOf<String>()
|
||||
fun importDBRec(group: Group) {
|
||||
@@ -103,8 +134,8 @@ object FidelityRepository {
|
||||
val newEntry = FidelityEntry(
|
||||
uid=it.nodeId.id.toString(),
|
||||
title=it.title,
|
||||
code=code.protectedValue.stringValue,
|
||||
format=format.protectedValue.stringValue,
|
||||
code=code.protectedValue.toString(),
|
||||
format=format.protectedValue.toString(),
|
||||
protected=code.protectedValue.isProtected,
|
||||
)
|
||||
val idx = entries.indexOfFirst { e -> e.uid == newEntry.uid }
|
||||
@@ -119,8 +150,6 @@ object FidelityRepository {
|
||||
} else {
|
||||
entries.add(newEntry)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
group.getChildGroups().forEach { importDBRec(it) }
|
||||
}
|
||||
@@ -133,36 +162,19 @@ object FidelityRepository {
|
||||
saveEntries(context)
|
||||
}
|
||||
|
||||
fun saveEntries(context: Context) {
|
||||
val prefs = context.getSharedPreferences("fidelity_prefs", Context.MODE_PRIVATE)
|
||||
prefs.edit { putString("entries", Json.encodeToString(
|
||||
ListSerializer(FidelityEntry.serializer()),
|
||||
entries
|
||||
)) }
|
||||
}
|
||||
|
||||
fun loadEntries(context: Context) {
|
||||
val prefs = context.getSharedPreferences("fidelity_prefs", Context.MODE_PRIVATE)
|
||||
try {
|
||||
val json = prefs.getString("entries", null) ?: return
|
||||
val list = Json.decodeFromString(
|
||||
ListSerializer(FidelityEntry.serializer()),
|
||||
json
|
||||
)
|
||||
|
||||
entries.clear()
|
||||
entries.addAll(list)
|
||||
}catch(_: Exception){
|
||||
prefs.edit{ putString("entries",Json.encodeToString(
|
||||
ListSerializer(FidelityEntry.serializer()),emptyList()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the card into the loaded database. [FidelityEntry.uid] is either the id of an
|
||||
* existing entry, which is updated in place, or the id of the group to create it in.
|
||||
*/
|
||||
fun addEntry(ctx: Context, entry: FidelityEntry) {
|
||||
val dbEntry = db.getEntryById(NodeIdUUID(UUID.fromString(entry.uid))) ?: db.createEntry()
|
||||
val dbParent = db.getGroupById(NodeIdUUID(UUID.fromString(entry.uid)))
|
||||
dbEntry?.apply {
|
||||
val id = NodeIdUUID(UUID.fromString(entry.uid))
|
||||
val existing = db.getEntryById(id)
|
||||
val dbEntry = existing ?: db.createEntry() ?: return
|
||||
dbEntry.apply {
|
||||
title = entry.title
|
||||
// Keepass2Android lists entries for this app by URL, so cards written here stay
|
||||
// reachable from KP2A mode as well.
|
||||
if (url.isBlank()) url = Kp2a.appUrl(ctx)
|
||||
putExtraField(
|
||||
Field(
|
||||
FidelityKeepassFields.FIDELITYCODE,
|
||||
@@ -172,14 +184,14 @@ object FidelityRepository {
|
||||
putExtraField(
|
||||
Field(
|
||||
FidelityKeepassFields.FIDELITYFORMAT,
|
||||
ProtectedString(string= entry.format)
|
||||
ProtectedString(true, entry.format.toCharArray())
|
||||
)
|
||||
)
|
||||
if(dbParent!=null) title = entry.title
|
||||
dbParent?.addChildEntry(dbEntry)
|
||||
}
|
||||
if (existing != null) db.updateEntry(dbEntry)
|
||||
else db.addEntryTo(dbEntry, db.getGroupById(id) ?: db.rootGroup ?: return)
|
||||
entries.removeIf {it.uid == entry.uid}
|
||||
entries.add(entry.copy(uid=dbEntry?.nodeId?.id.toString()))
|
||||
entries.add(entry.copy(uid=dbEntry.nodeId.id.toString()))
|
||||
saveEntries(ctx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package net.helcel.fidelity.tools
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import net.helcel.fidelity.activity.ToastHelper
|
||||
import net.helcel.fidelity.pluginSDK.KeepassDef
|
||||
import net.helcel.fidelity.pluginSDK.Kp2aControl
|
||||
import net.helcel.fidelity.pluginSDK.Strings
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Bridge between [FidelityEntry] and the Keepass2Android plugin interface.
|
||||
*
|
||||
* Entries live in the KP2A database; this app only keeps a local cache of the
|
||||
* non-protected ones (see [FidelityRepository.cacheEntry]).
|
||||
*/
|
||||
object Kp2a {
|
||||
private const val CODE_FIELD = FidelityKeepassFields.FIDELITYCODE
|
||||
private const val FORMAT_FIELD = FidelityKeepassFields.FIDELITYFORMAT
|
||||
|
||||
// Legacy flag written by pre-1.3 versions: "true" means "do not cache locally".
|
||||
private const val PROTECT_CODE_FIELD = "FidelityProtectedCode"
|
||||
|
||||
private const val NOT_INSTALLED = "Keepass2Android Not Installed"
|
||||
|
||||
/** URL that marks a database entry as belonging to this app. */
|
||||
fun appUrl(context: Context): String = "androidapp://" + context.packageName
|
||||
|
||||
fun isAvailable(context: Context): Boolean =
|
||||
Kp2aControl.getQueryEntryForOwnPackageIntent()
|
||||
.resolveActivity(context.packageManager) != null
|
||||
|
||||
/** Asks KP2A to pick one of the entries whose URL points at this app. */
|
||||
fun launchQuery(context: Context, launcher: ActivityResultLauncher<Intent>): Boolean {
|
||||
return try {
|
||||
launcher.launch(Kp2aControl.getQueryEntryForOwnPackageIntent())
|
||||
true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
ToastHelper.show(context, NOT_INSTALLED)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens KP2A on a pre-filled "new entry" form. KP2A runs in its own task, so no
|
||||
* activity result comes back: the caller must cache the entry optimistically.
|
||||
*/
|
||||
fun launchAdd(context: Context, entry: FidelityEntry): Boolean {
|
||||
val fields = HashMap<String, String>()
|
||||
fields[KeepassDef.TitleField] = entry.title
|
||||
fields[KeepassDef.UrlField] = appUrl(context)
|
||||
fields[CODE_FIELD] = entry.code
|
||||
fields[FORMAT_FIELD] = entry.format
|
||||
fields[PROTECT_CODE_FIELD] = entry.protected.toString()
|
||||
val protectedFields = if (entry.protected) arrayListOf(CODE_FIELD) else null
|
||||
|
||||
return try {
|
||||
context.startActivity(Kp2aControl.getAddEntryIntent(fields, protectedFields))
|
||||
true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
ToastHelper.show(context, NOT_INSTALLED)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable id for entries KP2A did not tag with its own UUID. */
|
||||
fun localUid(title: String): String =
|
||||
UUID.nameUUIDFromBytes("kp2a:$title".toByteArray(Charsets.UTF_8)).toString()
|
||||
|
||||
/**
|
||||
* Extracts a fidelity entry from an intent produced by KP2A (query result, or the
|
||||
* launch intent when the user opens this app from an entry). Returns null when the
|
||||
* intent carries no fidelity data.
|
||||
*/
|
||||
fun entryFromIntent(intent: Intent?): FidelityEntry? {
|
||||
if (intent?.hasExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA) != true) return null
|
||||
val fields = Kp2aControl.getEntryFieldsFromIntent(intent)
|
||||
val code = fields[CODE_FIELD] ?: return null
|
||||
val format = fields[FORMAT_FIELD] ?: return null
|
||||
val title = fields[KeepassDef.TitleField] ?: ""
|
||||
|
||||
val protected = fields[PROTECT_CODE_FIELD]?.toBooleanStrictOrNull()
|
||||
?: protectedFieldsFromIntent(intent).contains(CODE_FIELD)
|
||||
|
||||
return FidelityEntry(
|
||||
uid = intent.getStringExtra(Strings.EXTRA_ENTRY_ID) ?: localUid(title),
|
||||
title = title,
|
||||
code = code,
|
||||
format = format,
|
||||
protected = protected,
|
||||
)
|
||||
}
|
||||
|
||||
// KP2A sends the protected field names either as a string list or a JSON array.
|
||||
private fun protectedFieldsFromIntent(intent: Intent): List<String> {
|
||||
intent.getStringArrayListExtra(Strings.EXTRA_PROTECTED_FIELDS_LIST)?.let { return it }
|
||||
val json = intent.getStringExtra(Strings.EXTRA_PROTECTED_FIELDS_LIST) ?: return emptyList()
|
||||
return try {
|
||||
val a = JSONArray(json)
|
||||
List(a.length()) { a.optString(it) }
|
||||
} catch (_: JSONException) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -2,4 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
+7
-9
@@ -1,16 +1,14 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
// ext.kotlin_version = '1.8.20'
|
||||
// ext.android_core_version = '1.10.1'
|
||||
// ext.android_appcompat_version = '1.6.1'
|
||||
// ext.android_material_version = '1.9.0'
|
||||
ext.android_test_version = '1.5.2'
|
||||
ext.joda_time_version = '2.14.1'
|
||||
ext.commons_io_version = '2.21.0'
|
||||
ext.android_test_version = '1.7.0'
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'com.android.application' version '8.13.1' apply false
|
||||
id 'com.android.library' version '8.13.1' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '2.2.21' apply false
|
||||
id 'com.autonomousapps.dependency-analysis' version '3.4.1' apply true
|
||||
id 'com.android.application' version '9.3.2' apply false
|
||||
id 'com.android.library' version '9.3.2' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '2.3.21' apply false
|
||||
id 'com.autonomousapps.dependency-analysis' version '3.13.0' apply true
|
||||
}
|
||||
Vendored
+1
-1
Submodule external/KeePassDX updated: 1b98bd740c...581df551ea
@@ -22,3 +22,7 @@ kotlin.code.style=official
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
android.onlyEnableUnitTestForTheTestedBuildType=false
|
||||
android.uniquePackageNames=false
|
||||
android.r8.strictFullModeForKeepRules=false
|
||||
android.dependency.useConstraints=false
|
||||
Vendored
BIN
Binary file not shown.
+3
-1
@@ -1,7 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
|
||||
Vendored
+10
-21
@@ -23,8 +23,8 @@
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@@ -51,7 +51,7 @@ echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
@@ -65,7 +65,7 @@ echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
@@ -73,21 +73,10 @@ goto fail
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
|
||||
@@ -1 +1 @@
|
||||
<p><i>Keepass-Fidelity</i> adds an interface to view/save barcodes (QR included) to Keepass through the plugin interface of the Keepass2Android app.</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 the history or queried from Keepass2Android</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 Keepass2Android app</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>
|
||||
@@ -1 +1 @@
|
||||
Fidelity (Membership/Loyalty) Card plugin for Keepass2Android
|
||||
Fidelity (Membership/Loyalty) Card app with Keepass Database Storage
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
for file in external/KeePassDX/{crypto,database}/build.gradle; do
|
||||
if [ -f "$file" ]; then
|
||||
sed -i "/id 'kotlin-android'/d" "$file"
|
||||
sed -i "/apply plugin: 'kotlin-android'/d" "$file"
|
||||
sed -i '/kotlinOptions {/,/}/d' "$file"
|
||||
fi
|
||||
done
|
||||
Reference in New Issue
Block a user