Compare commits

..
Author SHA1 Message Date
Renovate Bot 05de48c436 Update plugin com.android.library to v8.11.1 2025-07-11 02:07:36 +00:00
60 changed files with 1308 additions and 2963 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

+14 -60
View File
@@ -23,15 +23,7 @@ jobs:
contents: write
steps:
- 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
- uses: actions/checkout@v4
- name: set up secrets
run: |
@@ -40,11 +32,8 @@ 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
- name: Generate Dynamic Release Notes
run: |
mkdir -p tmp/whatsnew
echo "Various improvements for you to experience..." > tmp/whatsnew/whatsnew-en-US
- uses: gradle/wrapper-validation-action@v3
- name: create and checkout branch
if: github.event_name == 'pull_request'
env:
@@ -52,59 +41,24 @@ jobs:
run: git checkout -B "$BRANCH"
- name: set up JDK
uses: actions/setup-java@v6
uses: actions/setup-java@v4
with:
java-version: 21
java-version: 17
distribution: "temurin"
- 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
cache: 'gradle'
- name: Build APK
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 bundleSignedRelease assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
# ./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
# ./gradlew bundleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
run: ./gradlew assemble
- name: Upload APK
uses: actions/upload-artifact@v7
with:
path: app/build/outputs/apk/signedRelease/app-signedRelease.apk
compression-level: 0
archive: false
# - name: Upload APK
# uses: actions/upload-artifact@v4
# with:
# name: app.apk
# path: app/build/outputs/apk/release/app-release.apk
- name: Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
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
- name: Upload to Google Play
uses: r0adkll/upload-google-play@v1
if: startsWith(github.ref, 'refs/tags/')
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: net.helcel.fidelity
releaseFiles: app/build/outputs/bundle/signedRelease/app-signedRelease.aab
tracks: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'alpha' }}
status: completed
whatsNewDirectory: tmp/whatsnew
app/build/outputs/apk/release/app-release.apk
-3
View File
@@ -1,3 +0,0 @@
[submodule "external/KeePassDX"]
path = external/KeePassDX
url = https://github.com/Kunzisoft/KeePassDX.git
-77
View File
@@ -1,77 +0,0 @@
# Privacy Policy for Keepass Fidelity
**App:** Keepass Fidelity (`net.helcel.fidelity`)
**Developer:** Helcel
**Effective date:** 19 September 2026
## What we collect
Nothing. We operate no servers and receive no data from the app. Keepass
Fidelity has no internet access, no account, and contains no analytics,
advertising, or tracking libraries.
## Your data
Your loyalty cards (names and barcodes) are stored in the
KeePass database you choose, not in a separate store controlled by us:
- **Standalone mode:** the app opens a `.kdbx` file you select, using the
bundled KeePassDX engine. The file stays where you put it, encrypted by
KeePass with your password or key file.
- **Keepass2Android plugin mode:** entries are read and created through the
Keepass2Android app installed on your device. That app holds the database and
its own privacy policy applies to it.
Your database password is used only to unlock your database on the device. It is
never stored anywhere by us and never leaves your device.
If your database file is kept in a folder synchronised by another app or cloud
service, that service handles the file under its own privacy policy.
## On-device cache
For quick access, the app keeps a recently-used history and may cache entry data
in its private storage. You can mark individual entries to be excluded from
caching, and the cache can be cleared from the app or by clearing the app's data
in Android Settings.
## Permissions
- `CAMERA` — scanning a barcode to create an entry. Camera frames are processed
on the device and are not stored or transmitted.
- `READ_MEDIA_VISUAL_USER_SELECTED` — reading a barcode from an image you
explicitly select. Only the images you pick are accessed, and only while
importing.
## Retention and deletion
Cached data and history remain until you clear them or uninstall the app.
Uninstalling does not delete your KeePass database, which is your own file:
delete it yourself, or remove entries through the app or any KeePass client.
## Children
Keepass Fidelity is suitable for all ages and collects no data from any user,
including children.
## Security
Your cards are protected by KeePass encryption, which depends on the strength of
the password or key file you choose. On-device cache and history are held in the
app's private storage, protected by the Android sandbox.
## Open source
Keepass Fidelity is released into the public domain under the Unlicense. The
full source is available at https://github.com/helcel-net/keepass-fidelity, so
these statements can be independently verified.
## Changes
Any change affecting privacy will be published in this document before or with
the release that introduces it.
## Contact
Email: net.helcel+privacy@google.com
Issues: https://github.com/helcel-net/keepass-fidelity/issues
+8 -30
View File
@@ -3,15 +3,13 @@
<h1>Keepass Fidelity</h1>
<img width="100px" src="./metadata/en-US/images/icon.png" alt="Logo">
<p>A minimalist fidelity/loyalty card app with Keepass Database storage</p>
<a href="https://ko-fi.com/I2I615VP5M"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="ko-fi"></a>
<br>
<p>A minimalist fidelity/loyalty card plugin</p>
<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/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 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>
</div>
@@ -29,10 +27,7 @@
## ⭐ Features
- Two storage modes, chosen on first start (switchable later from the launcher):
- **Standalone**: opens a `.kdbx` database directly (bundled KeePassDX engine)
- **Keepass2Android plugin**: queries and creates entries through the installed Keepass2Android app
- Search entries in Keepass Database
- Search entries in [Keepass2Android](https://github.com/PhilippC/keepass2android/)
- Scan & Create entries
- Recently used history for fast access
- Protect entries from caching
@@ -43,23 +38,13 @@
<div style="display: flex; justify-content: center; align-items: center; flex-direction: row;">
<a href="https://apt.izzysoft.de/fdroid/index/apk/net.helcel.fidelity">
<img width="200" height="84" alt="Izzy Download" src=".github/images/izzy.png">
<img width="200" height="80" alt="Izzy Download" src=".github/images/izzy.png">
</a>
<a href="https://github.com/helcel-net/keepass-fidelity/releases/latest">
<a href="https://github.com/choelzl/keepass-fidelity/releases/latest">
<img width="200" height="84" alt="APK Download" src=".github/images/apk.png">
</a>
<a future-href="https://f-droid.org/en/packages/net.helcel.fidelity">
<img width="200" height="84" alt="Fdroid Download" src=".github/images/fdroid.png">
</a>
<a href="https://play.google.com/store/apps/details?id=net.helcel.fidelity">
<img width="200" height="84" alt="GooglePlay Download" src=".github/images/playstore.png">
</a>
</div>
Note:
- Fdroid: requested, see https://gitlab.com/fdroid/rfp/-/work_items/4411
- PlayStore: alpha test restriction by Google. To access, join https://groups.google.com/g/helcel-android-test
## ⚙️ Permissions
- `CAMERA`: necessary for importing barcodes from camera
@@ -78,17 +63,10 @@ Keepass-Fidelity is a user-driven project. We welcome any contribution, big or s
Thanks to all contributors, the developers of our dependencies, and our users.
## Signinig key
```
net.helcel.fidelity
D1:96:54:FE:1D:79:16:76:47:D7:A6:12:0E:F6:AB:7D:56:9D:45:AD:FD:41:EF:D6:FA:80:9D:26:52:73:F6
```
## 📝 License
```
Copyright (C) 2026 Helcel
Copyright (C) 2024 Helcel
Licensed under the Unlicense
For more information, please refer to <https://unlicense.org>
+34 -133
View File
@@ -1,61 +1,45 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.20'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.20'
id 'jacoco'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.0'
}
def keystorePropertiesFile = rootProject.file("app/keystore.properties")
def keystoreProperties = new Properties()
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
android {
namespace 'net.helcel.fidelity'
compileSdk = 37
ndkVersion "25.2.9519653" //Match this to KeypassDX crypto NDK version
compileSdk 34
defaultConfig {
applicationId 'net.helcel.fidelity'
// CI derives both from git (see .github/workflows/build.yml); the fallbacks are for local builds.
versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1.3c"
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
buildConfigField("String", "APP_NAME", "\"Keepass Fidelity\"")
manifestPlaceholders["APP_NAME"] = "Keepass Fidelity"
minSdk = 28
targetSdk = 37
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resValue "string", "app_name", "Keepass Fidelity"
minSdk 28
targetSdk 34
}
signingConfigs {
register("release") {
try {
def keystorePropertiesFile = rootProject.file("app/keystore.properties")
def keystoreProperties = new Properties()
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
} catch (FileNotFoundException e) {
println("File not found: ${e.message}")
}
create("release") {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
debug {
debuggable true
initWith(buildTypes.release)
signingConfig signingConfigs.debug
enableUnitTestCoverage true
signingConfig = signingConfigs.getByName("release")
}
release {
minifyEnabled true
shrinkResources true
shrinkResources false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
signedRelease {
initWith(buildTypes.release)
matchingFallbacks = ['release']
signingConfig = signingConfigs.getByName("release")
}
}
@@ -64,15 +48,17 @@ android {
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
encoding 'utf-8'
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17
}
buildFeatures {
viewBinding true
compose true
buildConfig true
}
dependenciesInfo {
@@ -81,103 +67,18 @@ android {
// Disables dependency metadata when building Android App Bundles.
includeInBundle = false
}
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)
}
lint {
disable 'UsingMaterialAndMaterial3Libraries'
disable 'PreviewAnnotationInFunctionWithParameters'
}
}
dependencies {
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3:1.4.0'
implementation 'androidx.compose.material:material:1.12.1'
implementation 'androidx.compose.material:material-icons-extended:1.7.8'
implementation 'androidx.navigation:navigation-compose:2.10.1'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation "androidx.biometric:biometric:1.2.0-alpha05"
implementation "androidx.security:security-crypto:1.1.0"
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.6.2'
implementation 'androidx.camera:camera-view:1.6.2'
runtimeOnly 'androidx.camera:camera-camera2:1.6.2'
implementation 'androidx.camera:camera-lifecycle:1.4.2'
implementation 'androidx.camera:camera-view:1.4.2'
runtimeOnly 'androidx.camera:camera-camera2:1.4.2'
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: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.4'
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.17'
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'
])
}
implementation 'com.google.code.gson:gson:2.13.1'
implementation 'com.google.android.material:material:1.12.0'
implementation 'com.google.zxing:core:3.5.3'
}
+7 -2
View File
@@ -2,7 +2,12 @@
# fields. Proguard removes such information by default, keep it.
-keepattributes Signature
-keep class org.joda.convert.** { *; }
-dontwarn org.threeten.bp.**
# This is also needed for R8 in compat mode since multiple
# optimizations will remove the generic signature such as class
# merging and argument removal. See:
# https://r8.googlesource.com/r8/+/refs/heads/main/compatibility-faq.md#troubleshooting-gson-gson
-keep class com.google.gson.reflect.TypeToken { *; }
-keep class * extends com.google.gson.reflect.TypeToken
# Optional. For using GSON @Expose annotation
-keepattributes AnnotationDefault,RuntimeVisibleAnnotations
+8 -16
View File
@@ -1,30 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools"
android:versionCode="8"
android:versionName="1.2c">
<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:label="@string/app_name"
android:supportsRtl="true">
<activity
android:name=".activity.MainActivity"
android:exported="true"
android:launchMode="singleTask">
android:theme="@style/Theme.Fidelity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -42,4 +34,4 @@
</intent-filter>
</receiver>
</application>
</manifest>
</manifest>
@@ -1 +0,0 @@
CUE6FCSBR6JIAAAAAAAAAAAAAA
@@ -1,65 +0,0 @@
package net.helcel.fidelity.activity
import android.content.Context
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.Colors
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.preference.PreferenceManager
import net.helcel.fidelity.R
object ToastHelper{
fun show(context: Context, message: String, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(context, message, duration).show()
}
}
@Composable
fun SysTheme(
content: @Composable () -> Unit
) {
val context = LocalContext.current
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val themeKey = prefs.getString(stringResource(R.string.key_theme), stringResource(R.string.system))
val darkTheme = when (themeKey) {
stringResource(R.string.system) -> isSystemInDarkTheme()
stringResource(R.string.light) -> false
stringResource(R.string.dark) -> true
else -> isSystemInDarkTheme()
}
val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if(darkTheme) dynamicDarkColorScheme(LocalContext.current ) else dynamicLightColorScheme(LocalContext.current )
} else {
if(darkTheme) darkColorScheme() else lightColorScheme()
}
val m2colors = Colors(
primary = colorScheme.primary,
primaryVariant = colorScheme.primaryContainer,
secondary = colorScheme.secondary,
background = colorScheme.background,
surface = colorScheme.surface,
onPrimary = colorScheme.onPrimary,
onSecondary = colorScheme.onSecondary,
onBackground = colorScheme.onBackground,
onSurface = colorScheme.onSurface,
secondaryVariant = colorScheme.secondary,
error = colorScheme.error,
onError = colorScheme.onError,
isLight = !darkTheme,
)
MaterialTheme(
colors = m2colors,
content = content
)
}
@@ -1,107 +1,65 @@
package net.helcel.fidelity.activity
import android.annotation.SuppressLint
import android.content.Intent
import android.content.Context
import android.content.SharedPreferences
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
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
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.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
import androidx.activity.addCallback
import androidx.appcompat.app.AppCompatActivity
import net.helcel.fidelity.R
import net.helcel.fidelity.activity.fragment.Launcher
import net.helcel.fidelity.activity.fragment.ViewEntry
import net.helcel.fidelity.databinding.ActMainBinding
import net.helcel.fidelity.pluginSDK.Kp2aControl.getEntryFieldsFromIntent
import net.helcel.fidelity.tools.CacheManager
import net.helcel.fidelity.tools.KeepassWrapper.bundleCreate
import net.helcel.fidelity.tools.KeepassWrapper.entryExtract
class MainActivity : FragmentActivity() {
@SuppressLint("SourceLockedOrientationActivity")
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActMainBinding
private lateinit var sharedPreferences: SharedPreferences
// 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)
sharedPreferences =
this.getSharedPreferences(CacheManager.PREF_NAME, Context.MODE_PRIVATE)
CacheManager.loadFidelity(sharedPreferences)
setContent {
SysTheme {
val navController = rememberNavController()
val context = LocalContext.current
BackHandler {
if (!navController.popBackStack()) finish()
}
LaunchedEffect(Unit) {
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("scanCam") { ScannerScreen(navController) }
composable("scanFile") { FileScanner(navController) }
composable("edit") { CreateEntryScreen(navController) }
composable("view/{entryId}") { e ->
val entry = findEntry(e.arguments?.getString("entryId"))
if (entry == null) return@composable navController.navigate("launcher")
ViewEntryScreen(navController, entry)
}
}
}
binding = ActMainBinding.inflate(layoutInflater)
setContentView(binding.root)
onBackPressedDispatcher.addCallback(this) {
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStackImmediate()
loadLauncher()
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} else {
finish()
}
}
if (intent.extras != null)
loadViewEntry()
else if (savedInstanceState == null)
loadLauncher()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
Kp2a.entryFromIntent(intent)?.let { incomingEntry.value = it }
private fun loadLauncher() {
supportFragmentManager.beginTransaction()
.replace(R.id.container, Launcher())
.commit()
}
private fun loadViewEntry() {
val viewEntry = ViewEntry()
val data = getEntryFieldsFromIntent(intent)
viewEntry.arguments = bundleCreate(entryExtract(data))
supportFragmentManager.beginTransaction()
.replace(R.id.container, viewEntry)
.commit()
}
}
@@ -0,0 +1,49 @@
package net.helcel.fidelity.activity.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.LinearLayout
import androidx.recyclerview.widget.RecyclerView
import net.helcel.fidelity.databinding.ListItemFidelityBinding
class FidelityListAdapter(
private val triples: ArrayList<Triple<String?, String?, String?>>,
private val onItemClicked: (Triple<String?, String?, String?>) -> Unit
) :
RecyclerView.Adapter<FidelityListAdapter.FidelityViewHolder>() {
private lateinit var binding: ListItemFidelityBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FidelityViewHolder {
binding = ListItemFidelityBinding.inflate(LayoutInflater.from(parent.context))
binding.root.setLayoutParams(
LinearLayout.LayoutParams(
MATCH_PARENT, WRAP_CONTENT
)
)
return FidelityViewHolder(binding.root)
}
override fun onBindViewHolder(holder: FidelityViewHolder, position: Int) {
val triple = triples[position]
holder.bind(triple)
}
override fun getItemCount(): Int = triples.size
inner class FidelityViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(triple: Triple<String?, String?, String?>) {
val text = "${triple.first}"
binding.textView.text = text
binding.card.setOnClickListener { onItemClicked(triple) }
}
}
}
@@ -1,429 +1,167 @@
package net.helcel.fidelity.activity.fragment
import android.content.Context
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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
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
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ExposedDropdownMenuBox
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import android.content.ActivityNotFoundException
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.ArrayAdapter
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import com.google.android.material.textfield.TextInputEditText
import com.google.zxing.FormatException
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.databinding.FragCreateEntryBinding
import net.helcel.fidelity.pluginSDK.Kp2aControl
import net.helcel.fidelity.tools.BarcodeGenerator.generateBarcode
import net.helcel.fidelity.tools.FidelityEntry
import net.helcel.fidelity.tools.FidelityRepository.activeEntry
import net.helcel.fidelity.tools.KeepassDatabase
import net.helcel.fidelity.tools.KeepassDatabase.addEntry
import net.helcel.fidelity.tools.FidelityRepository.cacheEntry
import net.helcel.fidelity.tools.Kp2a
import kotlin.time.Duration.Companion.milliseconds
import net.helcel.fidelity.tools.CacheManager
import net.helcel.fidelity.tools.ErrorToaster
import net.helcel.fidelity.tools.KeepassWrapper
private const val DEBOUNCE_DELAY = 500L
@Preview
@Composable
fun CreateEntryScreen(navController: NavHostController?) {
var entry by remember { activeEntry }
var errorTitle by remember { mutableStateOf("") }
var errorCode by remember { mutableStateOf("") }
var errorFormat by remember { mutableStateOf("") }
class CreateEntry : Fragment() {
var barcodeBitmap by remember { mutableStateOf<Bitmap?>(null) }
var isValidBarcode by remember { mutableStateOf(false) }
var showDialog by remember { mutableStateOf(false) }
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
private val handler = Handler(Looper.getMainLooper())
private lateinit var binding: FragCreateEntryBinding
fun pickGroup() {
isLoading = true
scope.launch {
if (ensureUnlocked(ctx, navController!!)) {
group = group ?: KeepassDatabase.getRoot()
showDialog = true
}
isLoading = false
private val resultLauncherAdd = KeepassWrapper.resultLauncher(this) {
val r = KeepassWrapper.entryExtract(it)
if (!KeepassWrapper.isProtected(it)) {
CacheManager.addFidelity(r)
}
startViewEntry(r.first, r.second, r.third)
}
LaunchedEffect(entry) {
isValidBarcode = false
delay(500.milliseconds)
if (entry.code.isEmpty()) return@LaunchedEffect
private var isValidBarcode: Boolean = false
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragCreateEntryBinding.inflate(layoutInflater)
val formats = resources.getStringArray(R.array.format_array)
val arrayAdapter = ArrayAdapter(requireContext(), R.layout.list_item_dropdown, formats)
binding.editTextFormat.setAdapter(arrayAdapter)
val res = KeepassWrapper.bundleExtract(arguments)
binding.editTextCode.setText(res.second)
binding.editTextFormat.setText(res.third, false)
binding.editTextCode.addTextChangedListener { changeListener() }
binding.editTextFormat.addTextChangedListener { changeListener() }
binding.editTextFormat.addTextChangedListener { binding.editTextFormat.error = null }
binding.btnSave.setOnClickListener { submit() }
binding.editTextTitle.onDone { submit() }
binding.editTextCode.onDone { submit() }
updatePreview()
return binding.root
}
private fun updatePreview() {
try {
val bmp = generateBarcode(entry.code, entry.format, 600)
barcodeBitmap = bmp
val barcodeBitmap = generateBarcode(
binding.editTextCode.text.toString(),
binding.editTextFormat.text.toString(),
600
)
binding.imageViewPreview.setImageBitmap(barcodeBitmap)
isValidBarcode = true
errorCode = ""
} catch (_: FormatException) {
barcodeBitmap = null
errorCode = "Invalid Format"
} catch (e: FormatException) {
binding.imageViewPreview.setImageBitmap(null)
binding.editTextCode.error = "Invalid format"
} catch (e: IllegalArgumentException) {
barcodeBitmap = null
errorCode = if (e.message == "com.google.zxing.FormatException") "Invalid Format"
else e.message ?: "Invalid Argument"
binding.imageViewPreview.setImageBitmap(null)
binding.editTextCode.error = e.message
} catch (e: Exception) {
barcodeBitmap = null
ToastHelper.show(ctx, e.message ?: e.toString())
binding.imageViewPreview.setImageBitmap(null)
e.printStackTrace()
}
}
if (showDialog) {
TreeSelectorDialog(groupsOnly = true) {
showDialog = false
if (it is Group) group = it
private fun isValidForm(): Boolean {
var valid = true
if (binding.editTextFormat.text.isNullOrEmpty()) {
valid = false
binding.editTextFormat.error = "Format cannot be empty"
}
if (binding.editTextCode.text.isNullOrEmpty()) {
valid = false
binding.editTextCode.error = "Code cannot be empty"
}
if (binding.editTextTitle.text.isNullOrEmpty()) {
valid = false
binding.editTextTitle.error = "Title cannot be empty"
}
return valid
}
private fun startViewEntry(title: String?, code: String?, fmt: String?) {
val viewEntryFragment = ViewEntry()
viewEntryFragment.arguments = KeepassWrapper.bundleCreate(title, code, fmt)
requireActivity().supportFragmentManager.beginTransaction()
.replace(R.id.container, viewEntryFragment).commit()
}
private fun changeListener() {
isValidBarcode = false
handler.removeCallbacksAndMessages(null)
handler.postDelayed({
updatePreview()
}, DEBOUNCE_DELAY)
}
private fun TextInputEditText.onDone(callback: () -> Unit) {
setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
callback.invoke()
return@setOnEditorActionListener true
}
false
}
}
val formats = stringArrayResource(R.array.format_array)
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.imePadding(),
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp, 32.dp)
.padding(bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
)
{
OutlinedTextField(
value = entry.title,
onValueChange = {
entry = entry.copy(title = it)
errorTitle = ""
},
label = { Text(text = "Title") },
isError = errorTitle.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
colors = TextFieldDefaults.textFieldColors(
textColor = MaterialTheme.colors.onBackground
),
private fun submit() {
if (!isValidForm() || !isValidBarcode) {
ErrorToaster.formIncomplete(context)
} else {
val kpEntry = KeepassWrapper.entryCreate(
this,
binding.editTextTitle.text.toString(),
binding.editTextCode.text.toString(),
binding.editTextFormat.text.toString(),
binding.checkboxProtected.isChecked,
)
if (errorTitle.isNotEmpty()) {
Text(errorTitle, color = MaterialTheme.colors.error)
}
OutlinedTextField(
value = entry.code,
onValueChange = {
entry = entry.copy(code = it)
errorCode = ""
},
colors = TextFieldDefaults.textFieldColors(
textColor = MaterialTheme.colors.onBackground
),
label = { Text("Code") },
isError = errorCode.isNotEmpty(),
maxLines = 5,
singleLine = false,
modifier = Modifier.fillMaxWidth()
)
if (errorCode.isNotEmpty()) {
Text(errorCode, color = MaterialTheme.colors.error)
}
FormatDropdown(
formats,
entry.format,
errorFormat.ifEmpty { null },
) {
entry = entry.copy(format = it)
errorFormat = ""
}
if (errorFormat.isNotEmpty()) {
Text(errorFormat, color = MaterialTheme.colors.error)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = entry.protected,
onCheckedChange = {
entry = entry.copy(protected = it)
},
colors = CheckboxDefaults.colors()
)
Text("Protected", color = MaterialTheme.colors.onBackground)
Spacer(modifier = Modifier.weight(1f))
Button(onClick = { onCameraScan(navController!!) }) {
Icon(Icons.Default.Camera, contentDescription = null)
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = { onFileScan(navController!!) }) {
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,
try {
resultLauncherAdd.launch(
Kp2aControl.getAddEntryIntent(
kpEntry.first,
kpEntry.second
)
Button(onClick = { pickGroup() }) {
Icon(Icons.Default.FolderOpen, contentDescription = "Choose group")
}
}
}
if (barcodeBitmap != null) {
Image(
bitmap = barcodeBitmap!!.asImageBitmap(),
contentDescription = "Barcode preview",
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
Spacer(modifier = Modifier.height(8.dp))
} catch (e: ActivityNotFoundException) {
ErrorToaster.noKP2AFound(context)
} catch (e: Exception) {
e.printStackTrace()
}
}
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(48.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
onSubmitIfValid(
entry,
setErrors = { t, c, f ->
errorTitle = t
errorCode = c
errorFormat = f
},
isValidBarcode,
) {
if (kp2a) {
onSaveKp2a(ctx, navController!!, entry)
} else {
isLoading = true
scope.launch {
onSaveStandalone(ctx, navController!!, entry, group)
isLoading = false
}
}
}
},
enabled = isValidBarcode && entry.title.isNotEmpty(),
) {
Text("Save", style = MaterialTheme.typography.h6)
if (!binding.checkboxProtected.isChecked) {
val r = KeepassWrapper.entryExtract(kpEntry.first)
CacheManager.addFidelity(r)
}
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background.copy(alpha = 0.75f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { }
),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun FormatDropdown(
formats: Array<String>,
format: String,
errorFormat: String?,
onFormatChange: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value = format,
onValueChange = {},
readOnly = true, // important for dropdown
label = { Text("Format", color=MaterialTheme.colors.onBackground) },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = "Expand",
)
},
colors = TextFieldDefaults.textFieldColors(
textColor = MaterialTheme.colors.onBackground
),
isError = errorFormat != null,
modifier = Modifier.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
formats.forEach { option ->
DropdownMenuItem(
onClick = {
onFormatChange(option)
expanded = false
}
) {
Text(option)
}
}
}
}
}
private fun onSubmitIfValid(
entry: FidelityEntry,
setErrors: (String, String, String) -> Unit,
isValidBarcode: Boolean,
onValid: (FidelityEntry) -> Unit
) {
var tErr = ""
var cErr = ""
var fErr = ""
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"
setErrors(tErr, cErr, fErr)
if (tErr.isEmpty() && cErr.isEmpty() && fErr.isEmpty() && isValidBarcode) {
onValid(entry.copy())
}
}
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)
activity?.supportFragmentManager?.popBackStack()
}
}
fun onSubmit(navController: NavHostController){
navController.popBackStack()
activeEntry.value = activeEntry.value.copy(
uid = null,
title = "",
code = "",
format = "",
protected = false
)
}
fun onFileScan(navController: NavHostController){
navController.navigate("scanFile")
}
fun onCameraScan(navController: NavHostController){
navController.navigate("scanCam")
}
}
@@ -1,152 +0,0 @@
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")
}
}
}
}
@@ -0,0 +1,107 @@
package net.helcel.fidelity.activity.fragment
import android.Manifest
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import net.helcel.fidelity.R
import net.helcel.fidelity.tools.BarcodeScanner
import net.helcel.fidelity.tools.ErrorToaster
import net.helcel.fidelity.tools.KeepassWrapper
import java.io.FileNotFoundException
class FileScanner : Fragment() {
private var code: String = ""
private var fmt: String = ""
private val resultPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
resultLauncherOpenMediaPick.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
private val resultLauncherOpenMediaBase =
registerForActivityResult(ActivityResultContracts.GetContent()) {
loadUri(it)
}
private val resultLauncherOpenMediaPick =
registerForActivityResult(ActivityResultContracts.PickVisualMedia()) {
loadUri(it)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
println(Build.VERSION.SDK_INT)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
resultPermission.launch(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED)
} else {
// resultLauncherOpenMediaBase.launch("image/*")
resultLauncherOpenMediaPick.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
return View(context)
}
private fun startCreateEntry() {
val createEntryFragment = CreateEntry()
createEntryFragment.arguments =
KeepassWrapper.bundleCreate(null, this.code, this.fmt)
requireActivity().supportFragmentManager.beginTransaction()
.replace(R.id.container, createEntryFragment)
.commit()
}
private fun scannerResult(code: String?, format: String?) {
if (!code.isNullOrEmpty() && !format.isNullOrEmpty()) {
this.code = code
this.fmt = format
}
val isDone = this.code.isNotEmpty() && this.fmt.isNotEmpty()
requireActivity().runOnUiThread {
if (isDone) {
startCreateEntry()
} else {
parentFragmentManager.popBackStack()
ErrorToaster.nothingFound(context)
}
}
}
private fun loadUri(it: Uri?) {
try {
run {
require(it != null)
val file = requireContext().contentResolver.openInputStream(it)
val image = BitmapFactory.decodeStream(file)
BarcodeScanner.bitmapUseCase(image) { code, format ->
scannerResult(code, format)
}
}
} catch (e: FileNotFoundException) {
e.printStackTrace()
println(e.message)
println(it)
ErrorToaster.noPermission(context)
parentFragmentManager.popBackStack()
} catch (e: IllegalArgumentException) {
ErrorToaster.nothingFound(context)
parentFragmentManager.popBackStack()
} catch (e: SecurityException) {
ErrorToaster.noPermission(context)
parentFragmentManager.popBackStack()
}
}
}
@@ -1,327 +1,136 @@
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.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
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.HideSource
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Search
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
import androidx.compose.runtime.remember
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.launch
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.onQuery
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onView
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.searchQuery
import net.helcel.fidelity.tools.AppModeStore
import net.helcel.fidelity.tools.FidelityEntry
import net.helcel.fidelity.tools.FidelityRepository.activeEntry
import net.helcel.fidelity.tools.FidelityRepository.cacheEntry
import net.helcel.fidelity.tools.FidelityRepository.entries
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
import android.content.ActivityNotFoundException
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import net.helcel.fidelity.R
import net.helcel.fidelity.activity.adapter.FidelityListAdapter
import net.helcel.fidelity.databinding.FragLauncherBinding
import net.helcel.fidelity.pluginSDK.Kp2aControl
import net.helcel.fidelity.tools.CacheManager
import net.helcel.fidelity.tools.ErrorToaster
import net.helcel.fidelity.tools.KeepassWrapper
@Preview
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LauncherScreen(
navController: NavHostController?,
) {
if(navController==null) return
var isRefreshingState by remember { mutableStateOf(false) }
var showHidden by remember { mutableStateOf(false) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
val kp2a = AppModeStore.isKp2a
val kp2aQueryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val entry = Kp2a.entryFromIntent(result.data)
if (entry == null) {
ToastHelper.show(context, "Entry has no fidelity code")
return@rememberLauncherForActivityResult
class Launcher : Fragment() {
private lateinit var binding: FragLauncherBinding
private lateinit var fidelityListAdapter: FidelityListAdapter
private val resultLauncherQuery = KeepassWrapper.resultLauncher(this) {
val r = KeepassWrapper.entryExtract(it)
if (!KeepassWrapper.isProtected(it)) {
CacheManager.addFidelity(r)
}
cacheEntry(context, entry)
onView(navController, entry)
startViewEntry(r.first, r.second, r.third)
}
BackHandler(enabled = isSearchVisible) {
onQuery()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragLauncherBinding.inflate(layoutInflater)
binding.btnQuery.setOnClickListener { startGetFromKeepass() }
binding.btnAdd.setOnClickListener {
if (binding.menuAdd.visibility == View.GONE)
showMenuAdd()
else
hideMenuAdd()
}
hideMenuAdd()
binding.btnScan.setOnClickListener {
startScanner()
hideMenuAdd()
}
binding.btnOpen.setOnClickListener {
startFileScanner()
hideMenuAdd()
}
binding.btnManual.setOnClickListener {
startCreateEntry()
hideMenuAdd()
}
binding.fidelityList.layoutManager =
LinearLayoutManager(requireContext())
fidelityListAdapter = FidelityListAdapter(CacheManager.getFidelity()) {
startViewEntry(it.first, it.second, it.third)
}
binding.fidelityList.adapter = fidelityListAdapter
recyclerSlideHelper().attachToRecyclerView(binding.fidelityList)
return binding.root
}
val sortedEntries = remember(entries, showHidden, searchQuery) {
derivedStateOf {
entries.filter {
(showHidden || !it.hidden) &&
(searchQuery.isEmpty() || it.title.contains(searchQuery, ignoreCase = true))
}.sortedWith(
compareByDescending<FidelityEntry> { it.pinned }
.thenBy { it.hidden }
.thenByDescending { it.lastUse }
)
private fun hideMenuAdd() {
binding.btnAdd.setImageResource(R.drawable.cross)
binding.menuAdd.visibility = View.GONE
}
private fun showMenuAdd() {
binding.btnAdd.setImageResource(R.drawable.minus)
binding.menuAdd.visibility = View.VISIBLE
}
private fun startGetFromKeepass() {
try {
this.resultLauncherQuery.launch(Kp2aControl.getQueryEntryForOwnPackageIntent())
} catch (e: ActivityNotFoundException) {
ErrorToaster.noKP2AFound(requireActivity())
}
}
private fun startFragment(fragment: Fragment) {
requireActivity().supportFragmentManager.beginTransaction()
.addToBackStack("Launcher")
.replace(R.id.container, fragment).commit()
}
Box(modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)) {
private fun startScanner() {
startFragment(Scanner())
}
PullToRefreshBox(
onRefresh = {
isRefreshingState = true
scope.launch {
// 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()
private fun startFileScanner() {
startFragment(FileScanner())
}
private fun startCreateEntry() {
startFragment(CreateEntry())
}
private fun startViewEntry(title: String?, code: String?, fmt: String?) {
val viewEntryFragment = ViewEntry()
viewEntryFragment.arguments = KeepassWrapper.bundleCreate(title, code, fmt)
startFragment(viewEntryFragment)
}
private fun recyclerSlideHelper(): ItemTouchHelper {
return ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
0, ItemTouchHelper.LEFT
) {
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
.fillMaxSize()
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(sortedEntries.value) { entry ->
FidelityRow(navController, entry)
}
}
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val pos = viewHolder.adapterPosition
CacheManager.rmFidelity(pos)
fidelityListAdapter.notifyItemRemoved(pos)
}
FloatingActionButton(
onClick = { onQuery() },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp),
) {
Icon(
Icons.Default.Search,
contentDescription = "Query",
modifier = Modifier.size(32.dp)
)
}
FloatingActionButton(
onClick = { onAdd(navController) }, modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
) {
Icon(Icons.Default.Add, contentDescription = "Add")
}
if (kp2a)
FloatingActionButton(
onClick = { Kp2a.launchQuery(context, kp2aQueryLauncher) },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 88.dp),
backgroundColor = MaterialTheme.colors.secondary,
) {
Icon(
Icons.Default.Key,
tint = MaterialTheme.colors.onSecondary,
contentDescription = "Fetch from Keepass2Android"
)
}
}
if (isRefreshingState)
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background.copy(alpha = 0.75f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { }
)
)
})
}
}
object LauncherEventHandlers {
var isSearchVisible by mutableStateOf(false)
var searchQuery by mutableStateOf("")
fun onAdd(navController: NavHostController) {
navController.navigate("edit")
}
fun onQuery() {
isSearchVisible = !isSearchVisible
if (!isSearchVisible) searchQuery = ""
}
/** 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)
}
/** 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) {
navController.navigate("view/${entry.uid}")
val index = entries.indexOfFirst { it.uid == entry.uid }
if (index != -1)
entries[index] = entry.copy(lastUse = System.currentTimeMillis().toInt())
}
fun onPin(entry: FidelityEntry){
val index = entries.indexOfFirst { it.uid == entry.uid }
if (index != -1)
entries[index] = entry.copy(pinned = !entry.pinned)
}
fun onHide(entry: FidelityEntry){
val index = entries.indexOfFirst { it.uid == entry.uid }
if (index != -1)
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")
}
}
}
@@ -1,154 +0,0 @@
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)
}
}
}
}
@@ -1,224 +1,105 @@
@file:Suppress("PreviewAnnotationInFunctionWithParameters",
"PreviewAnnotationInFunctionWithParameters"
)
package net.helcel.fidelity.activity.fragment
import android.Manifest
import android.graphics.BitmapFactory
import android.content.ContentValues
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FlashOn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.helcel.fidelity.activity.fragment.ScannerEventHandler.onResult
import net.helcel.fidelity.tools.BarcodeScanner
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import net.helcel.fidelity.R
import net.helcel.fidelity.databinding.FragScannerBinding
import net.helcel.fidelity.tools.BarcodeScanner.analysisUseCase
import net.helcel.fidelity.tools.FidelityRepository.activeEntry
import net.helcel.fidelity.tools.ErrorToaster
import net.helcel.fidelity.tools.KeepassWrapper
@androidx.compose.ui.tooling.preview.Preview
@Composable
fun ScannerScreen(
navController: NavController
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
class Scanner : Fragment() {
val cameraProviderFuture = remember {
ProcessCameraProvider.getInstance(context)
}
var camera: Camera? by remember { mutableStateOf(null) }
var torchOn by remember { mutableStateOf(false) }
private lateinit var binding: FragScannerBinding
val done = remember { mutableStateOf(false) }
val previewView = remember { PreviewView(context) }
private var code: String = ""
private var fmt: String = ""
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted ->
if (granted) {
val cameraProvider = cameraProviderFuture.get()
val previewUseCase = Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
val analysisUseCase = analysisUseCase { detectedCode, detectedFormat ->
if (detectedCode.isNullOrEmpty() || detectedFormat.isNullOrEmpty()) return@analysisUseCase
if(done.value) return@analysisUseCase
scope.launch(Dispatchers.Main) {
activeEntry.value =
activeEntry.value.copy(code = detectedCode, format = detectedFormat)
done.value = true
onResult(navController)
}
return@analysisUseCase
}
try {
cameraProvider.unbindAll()
camera = cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
previewUseCase,
analysisUseCase
)
} catch (e: Exception) {
Log.e("ScannerScreen", "Camera bind failed: ${e.message}")
}
private val resultPermissionRequest =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (it) {
bindCameraUseCases()
} else {
Toast.makeText(context, "Camera permission denied", Toast.LENGTH_SHORT).show()
scope.launch(Dispatchers.Main){
onResult(navController)
}
parentFragmentManager.popBackStack()
ErrorToaster.noPermission(context)
}
}
)
LaunchedEffect(Unit) {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
factory = { previewView },
modifier = Modifier.fillMaxSize()
)
ScannerOverlay(
modifier = Modifier.fillMaxSize()
)
Button(onClick = {
torchOn = !torchOn
camera?.cameraControl?.enableTorch(torchOn)
}, modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
) {
Icon(Icons.Default.FlashOn, contentDescription = null)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragScannerBinding.inflate(layoutInflater)
binding.btnScanDone.setOnClickListener {
startCreateEntry()
}
if(!done.value)
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.BottomCenter) // same spot as buttons
.padding(bottom =80.dp),
)
binding.btnScanDone.isEnabled = false
resultPermissionRequest.launch(Manifest.permission.CAMERA)
return binding.root
}
}
@Composable
fun ScannerOverlay(
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier.fillMaxSize()) {
val widthF = size.width
val heightF = size.height
drawRect(
color = Color(0x80000000), // semi-transparent black
size = size
)
val squareSize = 0.75f * minOf(widthF, heightF)
val left = (widthF - squareSize) / 2
val top = (heightF - squareSize) / 2
drawRect(
color = Color.Transparent,
topLeft = Offset(left, top),
size = Size(squareSize, squareSize),
blendMode = BlendMode.Clear
)
private fun startCreateEntry() {
val createEntryFragment = CreateEntry()
createEntryFragment.arguments =
KeepassWrapper.bundleCreate(null, this.code, this.fmt)
requireActivity().supportFragmentManager.beginTransaction()
.replace(R.id.container, createEntryFragment)
.commit()
}
}
@Composable
fun FileScanner(navController: NavHostController) {
val context = LocalContext.current
rememberCoroutineScope()
val pickImageLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri ->
if (uri == null) {
Toast.makeText(context, "No file selected", Toast.LENGTH_SHORT).show()
onResult(navController)
return@rememberLauncherForActivityResult
private fun scannerResult(code: String?, format: String?) {
if (!code.isNullOrEmpty() && !format.isNullOrEmpty()) {
this.code = code
this.fmt = format
}
try {
val inputStream = context.contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
BarcodeScanner.bitmapUseCase(bitmap) { code, format ->
if (!code.isNullOrEmpty() && !format.isNullOrEmpty()) {
activeEntry.value = activeEntry.value.copy(code=code, format=format)
onResult(navController)
} else {
Toast.makeText(context, "No barcode found", Toast.LENGTH_SHORT).show()
onResult(navController)
val isDone = this.code.isNotEmpty() && this.fmt.isNotEmpty()
activity?.runOnUiThread {
binding.btnScanDone.isEnabled = isDone
binding.ScanActive.isEnabled = !isDone
}
}
private fun bindCameraUseCases() {
val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext())
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val previewUseCase = Preview.Builder()
.build()
.also {
it.setSurfaceProvider(binding.cameraView.surfaceProvider)
}
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
val analysisUseCase = analysisUseCase { code, format ->
scannerResult(code, format)
}
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(context, "Failed to load image", Toast.LENGTH_SHORT).show()
onResult(navController)
}
try {
cameraProvider.bindToLifecycle(
this,
cameraSelector,
previewUseCase,
analysisUseCase
)
} catch (illegalStateException: IllegalStateException) {
Log.e(ContentValues.TAG, illegalStateException.message.orEmpty())
} catch (illegalArgumentException: IllegalArgumentException) {
Log.e(ContentValues.TAG, illegalArgumentException.message.orEmpty())
}
}, ContextCompat.getMainExecutor(requireContext()))
}
LaunchedEffect(Unit) {
pickImageLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
BackHandler {
onResult(navController)
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
object ScannerEventHandler {
fun onResult(navController: NavController) {
navController.popBackStack()
}
}
}
@@ -1,152 +0,0 @@
package net.helcel.fidelity.activity.fragment
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
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.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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
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.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(groupsOnly: Boolean = false, onDismiss: (Node?) -> Unit = {}) {
Dialog(
onDismissRequest = {onDismiss(null)},
content = {
Column(
modifier = Modifier.fillMaxWidth().background(
MaterialTheme.colors.background,
RoundedCornerShape(8.dp)
)
) {
var currentRoot by remember { mutableStateOf(KeepassDatabase.getRoot()) }
var selection by remember { mutableStateOf<Node?>(KeepassDatabase.getRoot()) }
Column(
modifier = Modifier.fillMaxWidth().padding(8.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = {
selection = currentRoot
currentRoot = currentRoot?.parent
},
enabled = currentRoot?.parent != null
) {
Icon(Icons.AutoMirrored.Filled.Undo, contentDescription = "up")
}
Spacer(modifier = Modifier.width(8.dp))
Text(
currentRoot?.title ?: "?",
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.h6
)
Spacer(modifier = Modifier.width(8.dp))
}
Spacer(modifier = Modifier.height(8.dp))
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 {
selection = entry
if (hasChildren) currentRoot = entry
}
.padding(8.dp)
) {
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),
color = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground
)
}
}
items(if (groupsOnly) emptyList() else currentRoot?.getChildEntries() ?: emptyList()) { entry ->
val isSel = (entry.nodeId == selection?.nodeId)
Row(
modifier = Modifier
.fillMaxWidth()
.background(color = if (isSel) MaterialTheme.colors.primary else MaterialTheme.colors.background)
.clickable {
selection = entry
}
.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),
color = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
modifier = Modifier.align(Alignment.CenterHorizontally),
enabled = selection != null,
onClick = {
onDismiss(selection)
}) {
Text("Select " + if (selection is Group) "Group" else "Entry")
}
}
}
}
)
}
@@ -1,292 +0,0 @@
package net.helcel.fidelity.activity.fragment
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
import androidx.compose.foundation.background
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.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.Checkbox
import androidx.compose.material.CheckboxDefaults
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
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
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.SetupEventHandlers.onOpen
import net.helcel.fidelity.tools.CredentialResult
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
class GetPersistentContent : OpenDocument() {
@SuppressLint("InlinedApi")
override fun createIntent(context: Context, input: Array<String>): Intent {
return super.createIntent(context, input).apply {
addCategory(Intent.CATEGORY_DEFAULT)
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
}
}
@Preview
@Composable
fun InitialScreen(
navController: NavHostController?
) {
var loading by remember { mutableStateOf(false) }
var dbFile by remember { mutableStateOf<Uri?>(null) }
var password by remember { mutableStateOf("") }
var keyFile by remember { mutableStateOf<Uri?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
val dbFilePickerLauncher = rememberLauncherForActivityResult(
contract = GetPersistentContent(),
) {
if(it!=null) {
dbFile = it
scope.launch(Dispatchers.IO) {
context.contentResolver.takePersistableUriPermission(
it,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
}
}
}
val keyFilePickerLauncher = rememberLauncherForActivityResult(
contract = GetPersistentContent()
) {
if(it!=null) {
keyFile = it
scope.launch(Dispatchers.IO) {
context.contentResolver.takePersistableUriPermission(
it,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
}
}
BackHandler {
navController!!.navigate("exit")
}
LaunchedEffect(Unit) {
scope.launch(Dispatchers.Main) {
when(val res = loadCredentials(context)) {
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
if (res.password != "" && password == "") password = res.password
}
}
}
}
Box(modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.background(MaterialTheme.colors.background),
verticalArrangement = Arrangement.Center
) {
Text(
"Keypass Database Setup",
style = MaterialTheme.typography.h5,
color = MaterialTheme.colors.onBackground
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text("KDBX Database:", color = MaterialTheme.colors.onBackground)
Spacer(modifier = Modifier.width(8.dp))
Checkbox(
enabled = !loading,
modifier = Modifier
.background(
MaterialTheme.colors.primary,
RoundedCornerShape(8.dp)
)
.size(32.dp),
checked = dbFile != null,
onCheckedChange = { dbFilePickerLauncher.launch(arrayOf("*/*")) },
colors = CheckboxDefaults.colors(
uncheckedColor = MaterialTheme.colors.primary,
checkedColor = MaterialTheme.colors.primary,
checkmarkColor = MaterialTheme.colors.onPrimary
),
)
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
enabled = !loading,
value = password,
onValueChange = { password = it },
label = { Text("Password") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Unspecified,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
colors = TextFieldDefaults.textFieldColors(
textColor = MaterialTheme.colors.onBackground
),
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text("KDBX Key File:", color = MaterialTheme.colors.onBackground)
Spacer(modifier = Modifier.width(8.dp))
Checkbox(
enabled = !loading,
modifier = Modifier
.background(
MaterialTheme.colors.primary,
RoundedCornerShape(8.dp)
)
.size(32.dp),
checked = keyFile != null,
onCheckedChange = { keyFilePickerLauncher.launch(arrayOf("*/*")) },
colors = CheckboxDefaults.colors(
uncheckedColor = MaterialTheme.colors.primary,
checkedColor = MaterialTheme.colors.primary,
checkmarkColor = MaterialTheme.colors.onPrimary
),
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
enabled = !loading && password.isNotBlank() && dbFile != null ,
onClick = {
loading = true
scope.launch {
val res = onOpen(context, dbFile!!, password, keyFile)
if(res != null){
ToastHelper.show(context, "Successful... Importing")
importDB(context)
navController!!.popBackStack()
navController.navigate("launcher")
}else{
ToastHelper.show(context, "Failed... Retry")
}
}
},
modifier = Modifier.fillMaxWidth()
) {
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()
.padding(32.dp)){
if(loading )
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.BottomCenter) // same spot as buttons
.padding(bottom = 80.dp),
)
}
}
}
object SetupEventHandlers {
/** Opens the file with the given credentials, then stores them for later sessions. */
suspend fun onOpen(context: Context, db: Uri, p: String, key: Uri?): CredentialResult.Success? {
try {
val packCred = packCredentials(db, p, key)
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 -> 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 null
}
}
}
@@ -1,134 +1,86 @@
package net.helcel.fidelity.activity.fragment
import android.app.Activity
import android.graphics.Bitmap
import android.annotation.SuppressLint
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.fragment.app.Fragment
import com.google.zxing.FormatException
import net.helcel.fidelity.databinding.FragViewEntryBinding
import net.helcel.fidelity.tools.BarcodeGenerator.generateBarcode
import net.helcel.fidelity.tools.FidelityEntry
import kotlin.let
import kotlin.math.min
import net.helcel.fidelity.tools.ErrorToaster
import net.helcel.fidelity.tools.KeepassWrapper
@SuppressLint("SourceLockedOrientationActivity")
class ViewEntry : Fragment() {
@Preview
@Composable
fun PreviewEntryScreen(){
ViewEntryScreen(null, FidelityEntry("Title","AAA","QR"))
}
private lateinit var binding: FragViewEntryBinding
private var title: String? = null
private var code: String? = null
private var fmt: String? = null
@Composable
fun ViewEntryScreen(
navController: NavHostController?,
entry: FidelityEntry
) {
val context = LocalContext.current
val activity = context as? Activity
var isFull by remember { mutableStateOf(false) }
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragViewEntryBinding.inflate(layoutInflater)
val res = KeepassWrapper.bundleExtract(arguments)
title = res.first
code = res.second
fmt = res.third
SideEffect {
activity?.window?.attributes = activity.window?.attributes?.apply {
screenBrightness = if (isFull) 1f else BRIGHTNESS_OVERRIDE_NONE
updatePreview()
updateLayout()
binding.imageViewPreview.setOnClickListener {
requireActivity().requestedOrientation =
if (isLandscape()) ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
return binding.root
}
// 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) {
private fun updatePreview() {
binding.title.text = title
try {
bitmap = generateBarcode(entry.code, entry.format, 1024)
} catch (e: Exception) {
bitmap = null
Toast.makeText(
context,
"Invalid barcode: " + (e.message ?: "unsupported format"),
Toast.LENGTH_LONG
).show()
}
}
BackHandler {
isFull=false
navController!!.popBackStack()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(
onClick = { isFull = !isFull },
indication = null, // remove ripple effect
interactionSource = remember { MutableInteractionSource() }
),
contentAlignment = Alignment.TopCenter
) {
if (!isFull) {
Text(
text = entry.title,
color = Color.White,
style = MaterialTheme.typography.h4,
modifier = Modifier.padding(32.dp)
val barcodeBitmap = generateBarcode(
code, fmt, 1024
)
binding.imageViewPreview.setImageBitmap(barcodeBitmap)
} catch (e: FormatException) {
ErrorToaster.invalidFormat(requireActivity())
binding.imageViewPreview.setImageBitmap(null)
} catch (e: IllegalArgumentException) {
binding.imageViewPreview.setImageBitmap(null)
ErrorToaster.invalidFormat(requireActivity())
} catch (e: Exception) {
binding.imageViewPreview.setImageBitmap(null)
e.printStackTrace()
}
}
BoxWithConstraints(
modifier = Modifier
.fillMaxSize().padding(8.dp),
contentAlignment = Alignment.Center
) {
bitmap?.let {
val modifier = Modifier
.fillMaxSize()
.width(maxWidth)
.height(maxHeight)
.padding(16.dp)
.aspectRatio(it.width.toFloat()/it.height.toFloat())
.rotate(if (isFull) 90f else 0f)
.scale(if(isFull) min(it.width.dp/maxHeight,it.height.dp/maxWidth) else 1f)
Image(
bitmap = it.asImageBitmap(),
contentDescription = "Barcode",
modifier = modifier,
contentScale = ContentScale.Fit,
)
} ?: CircularProgressIndicator(color = Color.White)
private fun updateLayout() {
if (isLandscape()) {
binding.title.visibility = View.GONE
setScreenBrightness(BRIGHTNESS_OVERRIDE_FULL)
} else {
binding.title.visibility = View.VISIBLE
setScreenBrightness(BRIGHTNESS_OVERRIDE_NONE)
}
}
private fun isLandscape(): Boolean {
return (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)
}
private fun setScreenBrightness(brightness: Float) {
requireActivity().window?.attributes?.screenBrightness = brightness
}
}
@@ -0,0 +1,45 @@
package net.helcel.fidelity.activity.view
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.util.AttributeSet
import android.view.View
class ScannerView : View {
private val overlayPaint = Paint().apply {
color = Color.parseColor("#80000000") // Semi-transparent black
style = Paint.Style.FILL
}
private val clearPaint = Paint().apply {
xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
}
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), overlayPaint)
val centerX = width / 2f
val centerY = height / 2f
val squareSize = 0.75f * width.coerceAtMost(height)
canvas.drawRect(
centerX - squareSize / 2, centerY - squareSize / 2,
centerX + squareSize / 2, centerY + squareSize / 2, clearPaint
)
}
}
@@ -2,7 +2,6 @@ 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
@@ -10,7 +9,6 @@ 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
@@ -39,14 +37,16 @@ object AccessManager {
accessToken: String?,
scopes: ArrayList<String?>
) {
getPrefsForHost(ctx, hostPackage).edit {
putString(PREF_KEY_TOKEN, accessToken)
putString(PREF_KEY_SCOPE, stringArrayToString(scopes))
}
val prefs = getPrefsForHost(ctx, hostPackage)
val edit = prefs.edit()
edit.putString(PREF_KEY_TOKEN, accessToken)
val scopesString = stringArrayToString(scopes)
edit.putString(PREF_KEY_SCOPE, scopesString)
edit.apply()
val hostPrefs = ctx.getSharedPreferences(PREF_HOSTS, Context.MODE_PRIVATE)
val hostPrefs = ctx.getSharedPreferences("KP2A.PluginAccess.hosts", Context.MODE_PRIVATE)
if (!hostPrefs.contains(hostPackage))
hostPrefs.edit { putString(hostPackage, "") }
hostPrefs.edit().putString(hostPackage, "").apply()
}
private fun getPrefsForHost(
@@ -65,6 +65,7 @@ object AccessManager {
if (!isSubset(scopes, currentScope))
return null
return prefs.getString(PREF_KEY_TOKEN, null)
}
private fun isSubset(
@@ -79,11 +80,15 @@ object AccessManager {
accessToken: String?
) {
val prefs = getPrefsForHost(ctx, hostPackage)
if (prefs.getString(PREF_KEY_TOKEN, "") == accessToken)
prefs.edit { clear() }
if (prefs.getString(PREF_KEY_TOKEN, "") == accessToken) {
val edit = prefs.edit()
edit.clear()
edit.apply()
}
val hostPrefs = ctx.getSharedPreferences(PREF_HOSTS, Context.MODE_PRIVATE)
if (hostPrefs.contains(hostPackage))
hostPrefs.edit { remove(hostPackage) }
val hostPrefs = ctx.getSharedPreferences("KP2A.PluginAccess.hosts", Context.MODE_PRIVATE)
if (hostPrefs.contains(hostPackage)) {
hostPrefs.edit().remove(hostPackage).apply()
}
}
}
@@ -25,7 +25,6 @@ object Strings {
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"
@@ -1,39 +0,0 @@
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
}
}
@@ -48,7 +48,7 @@ object BarcodeFormatConverter {
BarcodeFormat.RSS_14 -> "RSS_14"
BarcodeFormat.RSS_EXPANDED -> "RSS_EXPANDED"
BarcodeFormat.UPC_EAN_EXTENSION -> "UPC_EAN"
//else -> throw Exception("Unsupported Format: $f")
else -> throw Exception("Unsupported Format: $f")
}
}
}
@@ -6,8 +6,6 @@ import com.google.zxing.MultiFormatWriter
import com.google.zxing.WriterException
import com.google.zxing.common.BitMatrix
import net.helcel.fidelity.tools.BarcodeFormatConverter.stringToFormat
import androidx.core.graphics.set
import androidx.core.graphics.createBitmap
object BarcodeGenerator {
@@ -33,11 +31,13 @@ object BarcodeGenerator {
val bitMatrix: BitMatrix = writer.encode(content, format, width, height)
val bitmap = createBitmap(width, height)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
for (x in 0 until width) {
for (y in 0 until height) {
bitmap[x, y] = getPixelColor(bitMatrix, x, y)
bitmap.setPixel(
x, y, getPixelColor(bitMatrix, x, y)
)
}
}
return bitmap
@@ -13,6 +13,7 @@ import com.google.zxing.common.HybridBinarizer
import net.helcel.fidelity.tools.BarcodeFormatConverter.formatToString
import java.util.concurrent.Executors
@OptIn(ExperimentalGetImage::class)
object BarcodeScanner {
@@ -25,9 +26,9 @@ object BarcodeScanner {
try {
val result = reader.decode(binaryBitmap)
cb(result.text, formatToString(result.barcodeFormat))
} catch (_: NotFoundException) {
} catch (e: NotFoundException) {
cb(null, null)
} catch (_: ReaderException) {
} catch (e: ReaderException) {
cb(null, null)
}
}
@@ -55,4 +56,6 @@ object BarcodeScanner {
fun bitmapUseCase(bitmap: Bitmap, cb: (String?, String?) -> Unit) {
processImage(bitmap, cb)
}
}
@@ -1,184 +0,0 @@
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
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import androidx.fragment.app.FragmentActivity
import com.kunzisoft.keepass.hardware.HardwareKey
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
val Context.securePrefs by preferencesDataStore("keepass_prefs")
object KeePassKeys {
val DB_FILE_PATH = stringPreferencesKey("db_file_path")
val PASSWORD = stringPreferencesKey("password_enc")
val KEY_FILE_PATH = stringPreferencesKey("key_file_path")
val IV = stringPreferencesKey("iv")
}
sealed class CredentialResult {
data class Success(val db: Uri?, val password: String, val key: Uri?) : CredentialResult()
object NoData : CredentialResult()
object AuthFailed : 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 }
val keyGenerator =
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
).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()
keyGenerator.init(spec)
return keyGenerator.generateKey()
}
fun getCipherForDecryption(key: SecretKey, iv: ByteArray?): Cipher {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
if(iv==null) cipher.init(Cipher.ENCRYPT_MODE, key)
else cipher.init(Cipher.DECRYPT_MODE, key, javax.crypto.spec.GCMParameterSpec(128, iv))
return cipher
}
object KeePassStore {
suspend fun saveCredentials(
context: Context, cred: CredentialResult.Success
): CredentialResult {
val cipher = showBiometricPrompt(context as FragmentActivity, true)
?: return CredentialResult.AuthFailed
val encPasswordB = cipher.doFinal(cred.password.toByteArray(Charsets.UTF_8))
context.securePrefs.edit { prefs ->
prefs[KeePassKeys.DB_FILE_PATH] = cred.db.toString()
prefs[KeePassKeys.PASSWORD] = Base64.encodeToString(encPasswordB, Base64.DEFAULT)
prefs[KeePassKeys.IV] = Base64.encodeToString(cipher.iv, Base64.DEFAULT)
cred.key?.let { prefs[KeePassKeys.KEY_FILE_PATH] = it.toString() }
}
return cred
}
suspend fun hasCredentials(context: Context): Boolean {
val prefs = context.securePrefs.data.first()
return prefs[KeePassKeys.DB_FILE_PATH] != null &&
prefs[KeePassKeys.PASSWORD] != null
}
fun packCredentials(dbFilePath:Uri?, password: String, keyFilePath: Uri?): CredentialResult.Success {
return CredentialResult.Success(dbFilePath, password, keyFilePath)
}
suspend fun loadCredentials(context: Context): CredentialResult {
val prefs = context.securePrefs.data.first { true }
val dbFilePath = prefs[KeePassKeys.DB_FILE_PATH] ?: return CredentialResult.NoData
val encryptedBase64 = prefs[KeePassKeys.PASSWORD] ?: return CredentialResult.NoData
val keyFilePath = prefs[KeePassKeys.KEY_FILE_PATH]
val cipher = showBiometricPrompt(context as FragmentActivity, false)
?: return CredentialResult.AuthFailed
val decrypted = cipher.doFinal(Base64.decode(encryptedBase64, Base64.DEFAULT))
return packCredentials(
dbFilePath.parseUri(),
String(decrypted, Charsets.UTF_8),
keyFilePath?.parseUri()
)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
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) { _, _, _ -> }
}
// onAuthenticationFailed is a retryable miss: the prompt stays open, so the
// coroutine must stay suspended until it either succeeds or errors out.
}
)
val iv = if(enc) null else prefs[KeePassKeys.IV]?.let { Base64.decode(it, Base64.DEFAULT) }
if (!enc && iv == null) {
cont.resume(null) { _, _, _ -> }
return@suspendCancellableCoroutine
}
val cipher = getCipherForDecryption(getOrCreateBiometricKey(), iv)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock KeePass")
.setSubtitle("Authenticate to access your KeePass database")
.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))
}
}
fun retrieveResponseFromChallenge(
hardwareKey: HardwareKey,
seed: ByteArray?,
): ByteArray {
val response: ByteArray = "".toByteArray()
return response
}
@@ -0,0 +1,50 @@
package net.helcel.fidelity.tools
import android.content.SharedPreferences
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
object CacheManager {
const val PREF_NAME = "FIDELITY"
private const val ENTRY_KEY = "FIDELITY"
private var data: ArrayList<Triple<String?, String?, String?>> = ArrayList()
private var pref: SharedPreferences? = null
fun addFidelity(item: Triple<String?, String?, String?>) {
val exists = data.find { it.first == item.first }
if (exists != null)
data.remove(exists)
data.add(0, item)
saveFidelity()
}
fun rmFidelity(idx: Int) {
data.removeAt(idx)
saveFidelity()
}
private fun saveFidelity() {
val editor = pref?.edit()
val gson = Gson()
val json = gson.toJson(data)
editor?.putString(ENTRY_KEY, json)
editor?.apply()
}
fun loadFidelity(pref: SharedPreferences) {
this.pref = pref
val gson = Gson()
val json = pref.getString(ENTRY_KEY, null)
val type = object : TypeToken<List<Triple<String, String, Int>>>() {}.type
data = gson.fromJson(json, type) ?: ArrayList()
}
fun getFidelity(): ArrayList<Triple<String?, String?, String?>> {
return data
}
}
@@ -0,0 +1,31 @@
package net.helcel.fidelity.tools
import android.content.Context
import android.widget.Toast
object ErrorToaster {
private fun helper(activity: Context?, message: String, length: Int) {
if (activity != null)
Toast.makeText(activity, message, length).show()
}
fun noKP2AFound(activity: Context?) {
helper(activity, "KeePass2Android Not Installed", Toast.LENGTH_LONG)
}
fun formIncomplete(activity: Context?) {
helper(activity, "Form Incomplete", Toast.LENGTH_SHORT)
}
fun invalidFormat(activity: Context?) {
helper(activity, "Invalid Format", Toast.LENGTH_SHORT)
}
fun nothingFound(activity: Context?) {
helper(activity, "Nothing Found", Toast.LENGTH_SHORT)
}
fun noPermission(activity: Context?) {
helper(activity, "Missing Permission", Toast.LENGTH_LONG)
}
}
@@ -1,29 +0,0 @@
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,
)
@@ -1,82 +0,0 @@
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 }
}
@@ -1,197 +0,0 @@
package net.helcel.fidelity.tools
import android.content.Context
import android.net.Uri
import com.kunzisoft.keepass.database.element.Database
import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.Group
import com.kunzisoft.keepass.database.element.MasterCredential
import com.kunzisoft.keepass.database.element.binary.BinaryData
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.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
/**
* 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
/** 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
try {
val bitStream =
ByteArrayInputStream(ctx.contentResolver.openInputStream(uri)?.readBytes())
db.loadData(
bitStream, c,
{ hardwareKey, seed -> retrieveResponseFromChallenge(hardwareKey, seed) },
readOnly=false, allowUserVerification = false,binaryDir!!,
{ BinaryData.canMemoryBeAllocatedInRAM(ctx, it) },
false, null
)
return true
} catch (e: Exception) {
println(e)
return false
}
}
fun end(ctx: Context, uri: Uri?, c: MasterCredential): Boolean {
if (uri == null) return false
db.saveData(
File(binaryDir, db.binaryCache.hashCode().toString()),{ ctx.contentResolver.openOutputStream(uri) },
false, c, { hardwareKey, seed -> retrieveResponseFromChallenge(hardwareKey, seed) })
return true
}
fun genCredentials(
ctx: Context,
cred: CredentialResult.Success,
hardwareKey: HardwareKey? = null
): MasterCredential {
return MasterCredential(
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) {
group.getChildEntries().forEach {
val fields = it.getExtraFields()
val code = fields.firstOrNull { e -> e.name == FidelityKeepassFields.FIDELITYCODE }
val format =
fields.firstOrNull { e -> e.name == FidelityKeepassFields.FIDELITYFORMAT }
if (code == null || format == null) return@forEach
val newEntry = FidelityEntry(
uid=it.nodeId.id.toString(),
title=it.title,
code=code.protectedValue.toString(),
format=format.protectedValue.toString(),
protected=code.protectedValue.isProtected,
)
val idx = entries.indexOfFirst { e -> e.uid == newEntry.uid }
seenID.add(newEntry.uid!!)
if (idx >= 0) {
val oldEntry = entries[idx]
entries[idx] = newEntry.copy(
pinned = oldEntry.pinned,
hidden = oldEntry.hidden,
lastUse = oldEntry.lastUse
)
} else {
entries.add(newEntry)
}
}
group.getChildGroups().forEach { importDBRec(it) }
}
if (db.rootGroup != null)
importDBRec(db.rootGroup!!)
entries.removeAll { !seenID.contains(it.uid)}
val distinct = entries.distinctBy { it.uid }
entries.clear()
entries.addAll(distinct)
saveEntries(context)
}
/**
* 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 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,
ProtectedString(entry.protected, entry.code)
)
)
putExtraField(
Field(
FidelityKeepassFields.FIDELITYFORMAT,
ProtectedString(true, entry.format.toCharArray())
)
)
}
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()))
saveEntries(ctx)
}
}
@@ -0,0 +1,85 @@
package net.helcel.fidelity.tools
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import net.helcel.fidelity.pluginSDK.KeepassDef
import net.helcel.fidelity.pluginSDK.Kp2aControl
object KeepassWrapper {
private const val CODE_FIELD: String = "FidelityCode"
private const val FORMAT_FIELD: String = "FidelityFormat"
private const val PROTECT_CODE_FIELD: String = "FidelityProtectedCode"
fun entryCreate(
fragment: Fragment,
title: String,
code: String,
format: String,
protectCode: Boolean,
): Pair<HashMap<String, String>, ArrayList<String>> {
val fields = HashMap<String, String>()
val protected = ArrayList<String>()
fields[KeepassDef.TitleField] = title
fields[KeepassDef.UrlField] =
"androidapp://" + fragment.requireActivity().packageName
fields[CODE_FIELD] = code
fields[FORMAT_FIELD] = format
fields[PROTECT_CODE_FIELD] = protectCode.toString()
protected.add(CODE_FIELD)
return Pair(fields, protected)
}
fun resultLauncher(
fragment: Fragment,
callback: (HashMap<String, String>) -> Unit
): ActivityResultLauncher<Intent> {
return fragment.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val credentials = Kp2aControl.getEntryFieldsFromIntent(result.data)
callback(credentials)
}
}
}
fun entryExtract(map: HashMap<String, String>): Triple<String?, String?, String?> {
return Triple(
map[KeepassDef.TitleField],
map[CODE_FIELD],
map[FORMAT_FIELD]
)
}
fun bundleCreate(title: String?, code: String?, fmt: String?): Bundle {
val data = Bundle()
data.putString("title", title)
data.putString("code", code)
data.putString("fmt", fmt)
return data
}
fun bundleCreate(triple: Triple<String?, String?, String?>): Bundle {
return bundleCreate(triple.first, triple.second, triple.third)
}
fun bundleExtract(data: Bundle?): Triple<String?, String?, String?> {
return Triple(
data?.getString("title"),
data?.getString("code"),
data?.getString("fmt")
)
}
fun isProtected(map: HashMap<String, String>): Boolean {
return map[PROTECT_CODE_FIELD].toBoolean()
}
}
@@ -1,109 +0,0 @@
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()
}
}
}
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:orientation="vertical"
tools:context=".activity.MainActivity">
<FrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
tools:ignore="MergeRootFrame" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".activity.fragment.CreateEntry">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/title">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="text"
android:maxLines="1"
android:minLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/codeInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/code"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/checkboxProtected"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTextCode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="text"
android:maxLines="1"
android:minLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/checkboxProtected"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:button="@drawable/lock_checkbox"
android:scaleX="0.40"
android:scaleY="0.40"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/codeInputLayout"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/format"
android:labelFor="@id/edit_text_format">
<AutoCompleteTextView
android:id="@+id/edit_text_format"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:focusable="false"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<ImageView
android:id="@+id/imageViewPreview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:contentDescription="@string/barcode_preview"
android:scaleType="fitCenter" />
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btnSave"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:layout_margin="24dp"
android:contentDescription="@string/save"
app:fabCustomSize="46dp"
app:maxImageSize="32dp"
app:srcCompat="@drawable/save" />
</RelativeLayout>
+94
View File
@@ -0,0 +1,94 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:orientation="vertical"
tools:context=".activity.fragment.Launcher">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/fidelityList"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="24dp" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btnQuery"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_gravity="center"
android:layout_margin="24dp"
android:contentDescription="@string/query"
app:fabCustomSize="46dp"
app:maxImageSize="32dp"
app:srcCompat="@drawable/search" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:layout_margin="16dp"
android:background="@android:color/transparent"
android:orientation="vertical"
tools:ignore="RelativeOverlap">
<LinearLayout
android:id="@+id/menuAdd"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btnScan"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:contentDescription="@string/scan"
app:fabCustomSize="46dp"
app:maxImageSize="32dp"
app:srcCompat="@drawable/camera" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btnOpen"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:contentDescription="@string/open"
app:fabCustomSize="46dp"
app:maxImageSize="32dp"
app:srcCompat="@drawable/open" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btnManual"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:contentDescription="@string/manual"
app:fabCustomSize="46dp"
app:maxImageSize="32dp"
app:srcCompat="@drawable/edit" />
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btnAdd"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="8dp"
android:contentDescription="@string/expand"
app:fabCustomSize="46dp"
app:maxImageSize="32dp" />
</LinearLayout>
</RelativeLayout>
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".activity.fragment.Scanner">
<androidx.camera.view.PreviewView
android:id="@+id/cameraView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<net.helcel.fidelity.activity.view.ScannerView
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/btnScanDone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_margin="24dp"
android:contentDescription="@string/manual" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/ScanActive"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_margin="28dp"
android:indeterminate="true" />
</RelativeLayout>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
tools:context=".activity.fragment.ViewEntry">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:hint="@string/title"
android:textAlignment="center"
android:textSize="42sp"
app:layout_constraintBottom_toTopOf="@id/imageViewPreview"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0" />
<ImageView
android:id="@+id/imageViewPreview"
android:layout_width="match_parent"
android:layout_height="0dp"
android:contentDescription="@string/barcode_preview"
android:scaleType="fitCenter"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="15dp"
android:text=""
android:textSize="18sp"
android:textStyle="bold" />
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp"
app:cardMaxElevation="4dp"
app:cardPreventCornerOverlap="false"
app:cardUseCompatPadding="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="18sp"
android:textStyle="bold" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
@@ -2,5 +2,4 @@
<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>
+3
View File
@@ -0,0 +1,3 @@
<resources>
</resources>
+6 -6
View File
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="key_theme">App theme</string>
<string name="system">System</string>
<string name="light">Light</string>
<string name="dark">Dark</string>
<string name="key_stats">Statistics</string>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="kp2aplugin_title" tools:keep="@string/kp2aplugin_title">Fidelity</string>
<string name="kp2aplugin_shortdesc" tools:keep="@string/kp2aplugin_shortdesc">Fidelity adds an interface to manage fidelity cards and other barcodes to Keepass2Android</string>
<string name="kp2aplugin_author" tools:keep="@string/kp2aplugin_author">Soraefir</string>
<string name="app_name">Keepass Fidelity</string>
<string name="barcode_preview">barcode preview</string>
<string name="expand">Expand</string>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Fidelity" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/blue</item>
<item name="colorPrimaryVariant">@color/blue</item>
<item name="colorSecondary">@color/blue</item>
<item name="colorSecondaryVariant">@color/blue</item>
<item name="colorOnPrimary">@color/darkgray</item>
</style>
</resources>
+4 -10
View File
@@ -1,14 +1,8 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
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 '9.4.1' apply false
id 'com.android.library' version '9.4.1' apply false
id 'org.jetbrains.kotlin.android' version '2.4.20' apply false
id 'com.autonomousapps.dependency-analysis' version '3.13.0' apply true
id 'com.android.application' version '8.11.0' apply false
id 'com.android.library' version '8.11.1' apply false
id 'org.jetbrains.kotlin.android' version '2.2.0' apply false
id 'com.autonomousapps.dependency-analysis' version '2.19.0' apply true
}
Submodule external/KeePassDX deleted from 581df551ea
+1 -5
View File
@@ -21,8 +21,4 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# 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
android.nonTransitiveRClass=true
Binary file not shown.
+1 -3
View File
@@ -1,9 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+5 -2
View File
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/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/.
@@ -114,6 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -171,6 +172,7 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -210,6 +212,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
Vendored
+22 -10
View File
@@ -23,8 +23,8 @@
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
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
"%COMSPEC%" /c exit 1
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,18 +65,30 @@ 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
"%COMSPEC%" /c exit 1
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
@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
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
: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
+1 -1
View File
@@ -1 +1 @@
<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>
<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>
+1 -1
View File
@@ -1 +1 @@
Fidelity (Membership/Loyalty) Card app with Keepass Database Storage
Fidelity (Membership/Loyalty) Card plugin for Keepass2Android
-9
View File
@@ -1,9 +0,0 @@
#!/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
-5
View File
@@ -14,11 +14,6 @@ dependencyResolutionManagement {
maven { url 'https://jitpack.io' }
}
}
include(":database")
project(":database").projectDir = file("external/KeePassDX/database")
include(":crypto")
project(":crypto").projectDir = file("external/KeePassDX/crypto")
rootProject.name = "Fidelity"
include ':app'