Compare commits

..
1 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
56 changed files with 1307 additions and 2849 deletions
+14 -42
View File
@@ -23,15 +23,7 @@ jobs:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v4
with:
submodules: true
fetch-depth: 0
- name: Make script executable
run: chmod +x ./patch_submodule.sh
- name: Run patch submodule script
run: ./patch_submodule.sh
- name: set up secrets - name: set up secrets
run: | run: |
@@ -40,6 +32,8 @@ jobs:
gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch keystore.asc > app/keystore.properties 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 gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch key.asc > app/key.jks
- uses: gradle/wrapper-validation-action@v3
- name: create and checkout branch - name: create and checkout branch
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
env: env:
@@ -47,46 +41,24 @@ jobs:
run: git checkout -B "$BRANCH" run: git checkout -B "$BRANCH"
- name: set up JDK - name: set up JDK
uses: actions/setup-java@v6 uses: actions/setup-java@v4
with: with:
java-version: 21 java-version: 17
distribution: "temurin" distribution: "temurin"
cache: 'gradle'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
# Scoped to :app so the upstream KeePassDX submodule tests do not gate this build.
- name: Run unit tests
run: ./gradlew :app:testDebugUnitTest
- name: Build APK - name: Build APK
run: | run: ./gradlew assemble
VERSION_CODE=$(git rev-list --count HEAD)
VERSION_BASE=$(git describe --tags --abbrev=0 | sed 's/^v//')
VERSION_DEV=$(git rev-list --count $(git describe --tags --abbrev=0)..HEAD)
if [ $VERSION_DEV -gt 0 ]; then
VERSION_NAME="${VERSION_BASE}.${VERSION_DEV}"
else
VERSION_NAME="${VERSION_BASE}"
fi
./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
- name: Upload APK # - name: Upload APK
uses: actions/upload-artifact@v7 # uses: actions/upload-artifact@v4
with: # with:
path: app/build/outputs/apk/signedRelease/app-signedRelease.apk # name: app.apk
compression-level: 0 # path: app/build/outputs/apk/release/app-release.apk
archive: false
- name: Release - name: Release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
with: with:
files: | files: |
app/build/outputs/apk/signedRelease/app-signedRelease.apk app/build/outputs/apk/release/app-release.apk
prerelease: |
if [[ ${{ github.ref }} =~ ^refs/tags/[0-9]+\.[0-9]+[a-z]+$ ]]; then
echo "false"
else
echo "true"
fi
-3
View File
@@ -1,3 +0,0 @@
[submodule "external/KeePassDX"]
path = external/KeePassDX
url = https://github.com/Kunzisoft/KeePassDX.git
+7 -12
View File
@@ -3,15 +3,13 @@
<h1>Keepass Fidelity</h1> <h1>Keepass Fidelity</h1>
<img width="100px" src="./metadata/en-US/images/icon.png" alt="Logo"> <img width="100px" src="./metadata/en-US/images/icon.png" alt="Logo">
<p>A minimalist fidelity/loyalty card app with Keepass Database storage</p> <p>A minimalist fidelity/loyalty card plugin</p>
<a href="https://ko-fi.com/I2I615VP5M"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="ko-fi"></a>
<br>
<img src="https://forthebadge.com/images/badges/built-for-android.svg" alt="Built for Android"> <img src="https://forthebadge.com/images/badges/built-for-android.svg" alt="Built for Android">
<img src="https://forthebadge.com/images/badges/built-with-love.svg" alt="Built with love"> <img src="https://forthebadge.com/images/badges/built-with-love.svg" alt="Built with love">
<br> <br>
<a href="https://github.com/helcel-net/keepass-fidelity/actions/workflows/build.yml"> <a href="https://github.com/choelzl/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"> <img src="https://github.com/choelzl/keepass-fidelity/actions/workflows/build.yml/badge.svg?branch=main" alt="Build Status">
</a> </a>
</div> </div>
@@ -29,10 +27,7 @@
## ⭐ Features ## ⭐ Features
- Two storage modes, chosen on first start (switchable later from the launcher): - Search entries in [Keepass2Android](https://github.com/PhilippC/keepass2android/)
- **Standalone**: opens a `.kdbx` database directly (bundled KeePassDX engine)
- **Keepass2Android plugin**: queries and creates entries through the installed Keepass2Android app
- Search entries in Keepass Database
- Scan & Create entries - Scan & Create entries
- Recently used history for fast access - Recently used history for fast access
- Protect entries from caching - Protect entries from caching
@@ -45,7 +40,7 @@
<a href="https://apt.izzysoft.de/fdroid/index/apk/net.helcel.fidelity"> <a href="https://apt.izzysoft.de/fdroid/index/apk/net.helcel.fidelity">
<img width="200" height="80" alt="Izzy Download" src=".github/images/izzy.png"> <img width="200" height="80" alt="Izzy Download" src=".github/images/izzy.png">
</a> </a>
<a href="https://github.com/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"> <img width="200" height="84" alt="APK Download" src=".github/images/apk.png">
</a> </a>
</div> </div>
@@ -71,7 +66,7 @@ Thanks to all contributors, the developers of our dependencies, and our users.
## 📝 License ## 📝 License
``` ```
Copyright (C) 2026 Helcel Copyright (C) 2024 Helcel
Licensed under the Unlicense Licensed under the Unlicense
For more information, please refer to <https://unlicense.org> For more information, please refer to <https://unlicense.org>
+34 -133
View File
@@ -1,61 +1,45 @@
plugins { plugins {
id 'com.android.application' id 'com.android.application'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.20' id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.20' id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.0'
id 'jacoco'
} }
def keystorePropertiesFile = rootProject.file("app/keystore.properties")
def keystoreProperties = new Properties()
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
android { android {
namespace 'net.helcel.fidelity' namespace 'net.helcel.fidelity'
compileSdk = 37 compileSdk 34
ndkVersion "25.2.9519653" //Match this to KeypassDX crypto NDK version
defaultConfig { defaultConfig {
applicationId 'net.helcel.fidelity' applicationId 'net.helcel.fidelity'
// CI derives both from git (see .github/workflows/build.yml); the fallbacks are for local builds. resValue "string", "app_name", "Keepass Fidelity"
versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1.3c" minSdk 28
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1 targetSdk 34
buildConfigField("String", "APP_NAME", "\"Keepass Fidelity\"")
manifestPlaceholders["APP_NAME"] = "Keepass Fidelity"
minSdk = 28
targetSdk = 37
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs { signingConfigs {
register("release") { create("release") {
try { keyAlias keystoreProperties['keyAlias']
def keystorePropertiesFile = rootProject.file("app/keystore.properties") keyPassword keystoreProperties['keyPassword']
def keystoreProperties = new Properties() storeFile file(keystoreProperties['storeFile'])
keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
} catch (FileNotFoundException e) {
println("File not found: ${e.message}")
}
} }
} }
buildTypes { buildTypes {
debug { debug {
debuggable true debuggable true
initWith(buildTypes.release) signingConfig = signingConfigs.getByName("release")
signingConfig signingConfigs.debug
enableUnitTestCoverage true
} }
release { release {
minifyEnabled true minifyEnabled true
shrinkResources true shrinkResources false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
signedRelease {
initWith(buildTypes.release)
matchingFallbacks = ['release']
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
} }
} }
@@ -64,15 +48,17 @@ android {
compileOptions { compileOptions {
coreLibraryDesugaringEnabled true coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_21 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_21 targetCompatibility JavaVersion.VERSION_17
encoding 'utf-8' encoding 'utf-8'
} }
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17
}
buildFeatures { buildFeatures {
viewBinding true viewBinding true
compose true
buildConfig true
} }
dependenciesInfo { dependenciesInfo {
@@ -81,103 +67,18 @@ android {
// Disables dependency metadata when building Android App Bundles. // Disables dependency metadata when building Android App Bundles.
includeInBundle = false 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 { 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' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5'
implementation 'androidx.camera:camera-lifecycle:1.6.2' implementation 'androidx.camera:camera-lifecycle:1.4.2'
implementation 'androidx.camera:camera-view:1.6.2' implementation 'androidx.camera:camera-view:1.4.2'
runtimeOnly 'androidx.camera:camera-camera2:1.6.2' runtimeOnly 'androidx.camera:camera-camera2:1.4.2'
implementation 'com.google.android.material:material:1.14.0' implementation 'com.google.code.gson:gson:2.13.1'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0' implementation 'com.google.android.material:material:1.12.0'
implementation 'com.google.zxing:core:3.5.4' implementation 'com.google.zxing:core:3.5.3'
}
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.3'
implementation 'org.joda:joda-convert:3.0.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.14.11'
testImplementation 'org.robolectric:robolectric:4.16.1'
testImplementation 'androidx.test:core:1.7.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0'
testImplementation 'androidx.compose.ui:ui-test-junit4'
androidTestImplementation platform('androidx.compose:compose-bom:2026.09.00')
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
androidTestImplementation 'io.mockk:mockk-android:1.14.11'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
}
tasks.register('jacocoTestReport', JacocoReport) {
dependsOn 'testDebugUnitTest'
reports {
xml.required = true
html.required = true
}
def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*']
def debugTree = fileTree(dir: "${project.layout.buildDirectory.asFile.get()}/intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes", excludes: fileFilter)
def mainSrc = "${project.projectDir}/src/main/java"
sourceDirectories.from = files([mainSrc])
classDirectories.from = files([debugTree])
executionData.from = fileTree(dir: project.layout.buildDirectory.asFile.get(), includes: [
'jacoco/testDebugUnitTest.exec', 'outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec'
])
}
+7 -2
View File
@@ -2,7 +2,12 @@
# fields. Proguard removes such information by default, keep it. # fields. Proguard removes such information by default, keep it.
-keepattributes Signature -keepattributes Signature
-keep class org.joda.convert.** { *; } # This is also needed for R8 in compat mode since multiple
-dontwarn org.threeten.bp.** # 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 # Optional. For using GSON @Expose annotation
-keepattributes AnnotationDefault,RuntimeVisibleAnnotations -keepattributes AnnotationDefault,RuntimeVisibleAnnotations
+8 -16
View File
@@ -1,30 +1,22 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
android:versionCode="8"
android:versionName="1.2c">
<uses-feature android:name="android.hardware.camera" /> <uses-feature android:name="android.hardware.camera" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" /> <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<!-- Keepass2Android plugin mode: make the KP2A activities resolvable from this app -->
<queries>
<intent>
<action android:name="keepass2android.ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE" />
</intent>
<intent>
<action android:name="keepass2android.ACTION_START_WITH_TASK" />
</intent>
<package android:name="keepass2android.keepass2android" />
<package android:name="keepass2android.keepass2android_nonet" />
</queries>
<application <application
android:icon="@mipmap/ic_launcher_round" android:icon="@mipmap/ic_launcher_round"
android:label="${APP_NAME}" android:label="@string/app_name"
android:supportsRtl="true"> android:supportsRtl="true">
<activity <activity
android:name=".activity.MainActivity" android:name=".activity.MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask"> android:theme="@style/Theme.Fidelity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -42,4 +34,4 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
</application> </application>
</manifest> </manifest>
@@ -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 package net.helcel.fidelity.activity
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Context
import android.content.SharedPreferences
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.BackHandler import androidx.activity.addCallback
import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background import net.helcel.fidelity.R
import androidx.compose.foundation.layout.Box import net.helcel.fidelity.activity.fragment.Launcher
import androidx.compose.foundation.layout.fillMaxSize import net.helcel.fidelity.activity.fragment.ViewEntry
import androidx.compose.foundation.layout.systemBarsPadding import net.helcel.fidelity.databinding.ActMainBinding
import androidx.compose.material.MaterialTheme import net.helcel.fidelity.pluginSDK.Kp2aControl.getEntryFieldsFromIntent
import androidx.compose.runtime.LaunchedEffect import net.helcel.fidelity.tools.CacheManager
import androidx.compose.runtime.mutableStateOf import net.helcel.fidelity.tools.KeepassWrapper.bundleCreate
import androidx.compose.ui.Modifier import net.helcel.fidelity.tools.KeepassWrapper.entryExtract
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
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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
actionBar?.hide() sharedPreferences =
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT this.getSharedPreferences(CacheManager.PREF_NAME, Context.MODE_PRIVATE)
AppModeStore.load(this) CacheManager.loadFidelity(sharedPreferences)
loadEntries(this.baseContext)
incomingEntry.value = Kp2a.entryFromIntent(intent)
setContent { binding = ActMainBinding.inflate(layoutInflater)
SysTheme { setContentView(binding.root)
val navController = rememberNavController() onBackPressedDispatcher.addCallback(this) {
val context = LocalContext.current if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStackImmediate()
BackHandler { loadLauncher()
if (!navController.popBackStack()) finish() requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} } else {
LaunchedEffect(Unit) { finish()
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)
}
}
}
} }
} }
if (intent.extras != null)
loadViewEntry()
else if (savedInstanceState == null)
loadLauncher()
} }
override fun onNewIntent(intent: Intent) { private fun loadLauncher() {
super.onNewIntent(intent) supportFragmentManager.beginTransaction()
Kp2a.entryFromIntent(intent)?.let { incomingEntry.value = it } .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 package net.helcel.fidelity.activity.fragment
import android.content.Context import android.content.ActivityNotFoundException
import android.graphics.Bitmap import android.os.Bundle
import androidx.compose.foundation.Image import android.os.Handler
import androidx.compose.foundation.background import android.os.Looper
import androidx.compose.foundation.clickable import android.view.LayoutInflater
import androidx.compose.foundation.interaction.MutableInteractionSource import android.view.View
import androidx.compose.foundation.layout.Arrangement import android.view.ViewGroup
import androidx.compose.foundation.layout.Box import android.view.inputmethod.EditorInfo
import androidx.compose.foundation.layout.Column import android.widget.ArrayAdapter
import androidx.compose.foundation.layout.Row import androidx.core.widget.addTextChangedListener
import androidx.compose.foundation.layout.Spacer import androidx.fragment.app.Fragment
import androidx.compose.foundation.layout.fillMaxSize import com.google.android.material.textfield.TextInputEditText
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 com.google.zxing.FormatException 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.R
import net.helcel.fidelity.activity.ToastHelper import net.helcel.fidelity.databinding.FragCreateEntryBinding
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.ensureUnlocked import net.helcel.fidelity.pluginSDK.Kp2aControl
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onCameraScan
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onFileScan
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSaveKp2a
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSaveStandalone
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSubmit
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onSave
import net.helcel.fidelity.tools.AppModeStore
import net.helcel.fidelity.tools.BarcodeGenerator.generateBarcode import net.helcel.fidelity.tools.BarcodeGenerator.generateBarcode
import net.helcel.fidelity.tools.FidelityEntry import net.helcel.fidelity.tools.CacheManager
import net.helcel.fidelity.tools.FidelityRepository.activeEntry import net.helcel.fidelity.tools.ErrorToaster
import net.helcel.fidelity.tools.KeepassDatabase import net.helcel.fidelity.tools.KeepassWrapper
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
private const val DEBOUNCE_DELAY = 500L
@Preview class CreateEntry : Fragment() {
@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("") }
var barcodeBitmap by remember { mutableStateOf<Bitmap?>(null) } private val handler = Handler(Looper.getMainLooper())
var isValidBarcode by remember { mutableStateOf(false) } private lateinit var binding: FragCreateEntryBinding
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
fun pickGroup() { private val resultLauncherAdd = KeepassWrapper.resultLauncher(this) {
isLoading = true val r = KeepassWrapper.entryExtract(it)
scope.launch { if (!KeepassWrapper.isProtected(it)) {
if (ensureUnlocked(ctx, navController!!)) { CacheManager.addFidelity(r)
group = group ?: KeepassDatabase.getRoot()
showDialog = true
}
isLoading = false
} }
startViewEntry(r.first, r.second, r.third)
} }
LaunchedEffect(entry) { private var isValidBarcode: Boolean = false
isValidBarcode = false
delay(500.milliseconds) override fun onCreateView(
if (entry.code.isEmpty()) return@LaunchedEffect 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 { try {
val bmp = generateBarcode(entry.code, entry.format, 600) val barcodeBitmap = generateBarcode(
barcodeBitmap = bmp binding.editTextCode.text.toString(),
binding.editTextFormat.text.toString(),
600
)
binding.imageViewPreview.setImageBitmap(barcodeBitmap)
isValidBarcode = true isValidBarcode = true
errorCode = "" } catch (e: FormatException) {
} catch (_: FormatException) { binding.imageViewPreview.setImageBitmap(null)
barcodeBitmap = null binding.editTextCode.error = "Invalid format"
errorCode = "Invalid Format"
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
barcodeBitmap = null binding.imageViewPreview.setImageBitmap(null)
errorCode = if (e.message == "com.google.zxing.FormatException") "Invalid Format" binding.editTextCode.error = e.message
else e.message ?: "Invalid Argument"
} catch (e: Exception) { } catch (e: Exception) {
barcodeBitmap = null binding.imageViewPreview.setImageBitmap(null)
ToastHelper.show(ctx, e.message ?: e.toString()) e.printStackTrace()
} }
} }
if (showDialog) { private fun isValidForm(): Boolean {
TreeSelectorDialog(groupsOnly = true) { var valid = true
showDialog = false if (binding.editTextFormat.text.isNullOrEmpty()) {
if (it is Group) group = it 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( private fun submit() {
modifier = Modifier if (!isValidForm() || !isValidBarcode) {
.fillMaxSize() ErrorToaster.formIncomplete(context)
.background(MaterialTheme.colors.background) } else {
.imePadding(), val kpEntry = KeepassWrapper.entryCreate(
) { this,
Column( binding.editTextTitle.text.toString(),
modifier = Modifier binding.editTextCode.text.toString(),
.verticalScroll(rememberScrollState()) binding.editTextFormat.text.toString(),
.padding(16.dp, 32.dp) binding.checkboxProtected.isChecked,
.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
),
) )
if (errorTitle.isNotEmpty()) { try {
Text(errorTitle, color = MaterialTheme.colors.error) resultLauncherAdd.launch(
} Kp2aControl.getAddEntryIntent(
kpEntry.first,
OutlinedTextField( kpEntry.second
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,
) )
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()
} }
if (!binding.checkboxProtected.isChecked) {
} val r = KeepassWrapper.entryExtract(kpEntry.first)
CacheManager.addFidelity(r)
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)
} }
} activity?.supportFragmentManager?.popBackStack()
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)
} }
} }
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 package net.helcel.fidelity.activity.fragment
import android.app.Activity import android.content.ActivityNotFoundException
import android.content.Context import android.os.Bundle
import androidx.activity.compose.BackHandler import android.view.LayoutInflater
import androidx.activity.compose.rememberLauncherForActivityResult import android.view.View
import androidx.activity.result.contract.ActivityResultContracts import android.view.ViewGroup
import androidx.compose.foundation.background import androidx.fragment.app.Fragment
import androidx.compose.foundation.clickable import androidx.recyclerview.widget.ItemTouchHelper
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.recyclerview.widget.LinearLayoutManager
import androidx.compose.foundation.layout.Arrangement import androidx.recyclerview.widget.RecyclerView
import androidx.compose.foundation.layout.Box import net.helcel.fidelity.R
import androidx.compose.foundation.layout.Column import net.helcel.fidelity.activity.adapter.FidelityListAdapter
import androidx.compose.foundation.layout.Row import net.helcel.fidelity.databinding.FragLauncherBinding
import androidx.compose.foundation.layout.Spacer import net.helcel.fidelity.pluginSDK.Kp2aControl
import androidx.compose.foundation.layout.fillMaxSize import net.helcel.fidelity.tools.CacheManager
import androidx.compose.foundation.layout.fillMaxWidth import net.helcel.fidelity.tools.ErrorToaster
import androidx.compose.foundation.layout.padding import net.helcel.fidelity.tools.KeepassWrapper
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
@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( class Launcher : Fragment() {
contract = ActivityResultContracts.StartActivityForResult()
) { result -> private lateinit var binding: FragLauncherBinding
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult private lateinit var fidelityListAdapter: FidelityListAdapter
val entry = Kp2a.entryFromIntent(result.data)
if (entry == null) { private val resultLauncherQuery = KeepassWrapper.resultLauncher(this) {
ToastHelper.show(context, "Entry has no fidelity code") val r = KeepassWrapper.entryExtract(it)
return@rememberLauncherForActivityResult if (!KeepassWrapper.isProtected(it)) {
CacheManager.addFidelity(r)
} }
cacheEntry(context, entry) startViewEntry(r.first, r.second, r.third)
onView(navController, entry)
} }
BackHandler(enabled = isSearchVisible) { override fun onCreateView(
onQuery() 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) { private fun hideMenuAdd() {
derivedStateOf { binding.btnAdd.setImageResource(R.drawable.cross)
entries.filter { binding.menuAdd.visibility = View.GONE
(showHidden || !it.hidden) &&
(searchQuery.isEmpty() || it.title.contains(searchQuery, ignoreCase = true)) }
}.sortedWith(
compareByDescending<FidelityEntry> { it.pinned } private fun showMenuAdd() {
.thenBy { it.hidden } binding.btnAdd.setImageResource(R.drawable.minus)
.thenByDescending { it.lastUse } 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 private fun startScanner() {
.fillMaxSize() startFragment(Scanner())
.background(MaterialTheme.colors.background)) { }
PullToRefreshBox( private fun startFileScanner() {
onRefresh = { startFragment(FileScanner())
isRefreshingState = true }
scope.launch {
// KP2A owns the database: nothing to sync, just reload the cache. private fun startCreateEntry() {
if (kp2a) loadEntries(context) startFragment(CreateEntry())
else onRefresh(context, navController) }
isRefreshingState = false
}
}, private fun startViewEntry(title: String?, code: String?, fmt: String?) {
isRefreshing = isRefreshingState, val viewEntryFragment = ViewEntry()
modifier = Modifier.fillMaxSize() 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()) { override fun onMove(
// Top bar: hidden-cards toggle on the left, storage mode on the right. recyclerView: RecyclerView,
Row( viewHolder: RecyclerView.ViewHolder,
modifier = Modifier target: RecyclerView.ViewHolder
.fillMaxWidth() ): Boolean = false
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
verticalAlignment = Alignment.CenterVertically val pos = viewHolder.adapterPosition
) { CacheManager.rmFidelity(pos)
val hiddenTint = fidelityListAdapter.notifyItemRemoved(pos)
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)
}
}
} }
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 package net.helcel.fidelity.activity.fragment
import android.Manifest import android.Manifest
import android.graphics.BitmapFactory import android.content.ContentValues
import android.os.Bundle
import android.util.Log import android.util.Log
import android.widget.Toast import android.view.LayoutInflater
import androidx.activity.compose.BackHandler import android.view.View
import androidx.activity.compose.rememberLauncherForActivityResult import android.view.ViewGroup
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.core.content.ContextCompat
import androidx.compose.foundation.Canvas import androidx.fragment.app.Fragment
import androidx.compose.foundation.layout.Box import net.helcel.fidelity.R
import androidx.compose.foundation.layout.fillMaxSize import net.helcel.fidelity.databinding.FragScannerBinding
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 net.helcel.fidelity.tools.BarcodeScanner.analysisUseCase 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 class Scanner : Fragment() {
@Composable
fun ScannerScreen(
navController: NavController
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
val cameraProviderFuture = remember { private lateinit var binding: FragScannerBinding
ProcessCameraProvider.getInstance(context)
}
var camera: Camera? by remember { mutableStateOf(null) }
var torchOn by remember { mutableStateOf(false) }
val done = remember { mutableStateOf(false) } private var code: String = ""
val previewView = remember { PreviewView(context) } private var fmt: String = ""
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), private val resultPermissionRequest =
onResult = { granted -> registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (granted) { if (it) {
val cameraProvider = cameraProviderFuture.get() bindCameraUseCases()
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}")
}
} else { } else {
Toast.makeText(context, "Camera permission denied", Toast.LENGTH_SHORT).show() parentFragmentManager.popBackStack()
scope.launch(Dispatchers.Main){ ErrorToaster.noPermission(context)
onResult(navController)
}
} }
} }
)
LaunchedEffect(Unit) { override fun onCreateView(
permissionLauncher.launch(Manifest.permission.CAMERA) inflater: LayoutInflater,
} container: ViewGroup?,
savedInstanceState: Bundle?
Box(modifier = Modifier.fillMaxSize()) { ): View {
AndroidView( binding = FragScannerBinding.inflate(layoutInflater)
factory = { previewView }, binding.btnScanDone.setOnClickListener {
modifier = Modifier.fillMaxSize() startCreateEntry()
)
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)
} }
binding.btnScanDone.isEnabled = false
if(!done.value) resultPermissionRequest.launch(Manifest.permission.CAMERA)
CircularProgressIndicator( return binding.root
modifier = Modifier
.align(Alignment.BottomCenter) // same spot as buttons
.padding(bottom =80.dp),
)
} }
}
@Composable private fun startCreateEntry() {
fun ScannerOverlay( val createEntryFragment = CreateEntry()
modifier: Modifier = Modifier createEntryFragment.arguments =
) { KeepassWrapper.bundleCreate(null, this.code, this.fmt)
Canvas(modifier = modifier.fillMaxSize()) { requireActivity().supportFragmentManager.beginTransaction()
val widthF = size.width .replace(R.id.container, createEntryFragment)
val heightF = size.height .commit()
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
)
} }
}
@Composable private fun scannerResult(code: String?, format: String?) {
fun FileScanner(navController: NavHostController) { if (!code.isNullOrEmpty() && !format.isNullOrEmpty()) {
val context = LocalContext.current this.code = code
this.fmt = format
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
} }
try { val isDone = this.code.isNotEmpty() && this.fmt.isNotEmpty()
val inputStream = context.contentResolver.openInputStream(uri) activity?.runOnUiThread {
val bitmap = BitmapFactory.decodeStream(inputStream) binding.btnScanDone.isEnabled = isDone
BarcodeScanner.bitmapUseCase(bitmap) { code, format -> binding.ScanActive.isEnabled = !isDone
if (!code.isNullOrEmpty() && !format.isNullOrEmpty()) { }
activeEntry.value = activeEntry.value.copy(code=code, format=format) }
onResult(navController)
} else { private fun bindCameraUseCases() {
Toast.makeText(context, "No barcode found", Toast.LENGTH_SHORT).show() val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext())
onResult(navController)
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) { try {
e.printStackTrace() cameraProvider.bindToLifecycle(
Toast.makeText(context, "Failed to load image", Toast.LENGTH_SHORT).show() this,
onResult(navController) 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 package net.helcel.fidelity.activity.fragment
import android.app.Activity import android.annotation.SuppressLint
import android.graphics.Bitmap 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.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
import android.widget.Toast import androidx.fragment.app.Fragment
import androidx.activity.compose.BackHandler import com.google.zxing.FormatException
import androidx.compose.foundation.Image import net.helcel.fidelity.databinding.FragViewEntryBinding
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 net.helcel.fidelity.tools.BarcodeGenerator.generateBarcode import net.helcel.fidelity.tools.BarcodeGenerator.generateBarcode
import net.helcel.fidelity.tools.FidelityEntry import net.helcel.fidelity.tools.ErrorToaster
import kotlin.let import net.helcel.fidelity.tools.KeepassWrapper
import kotlin.math.min
@SuppressLint("SourceLockedOrientationActivity")
class ViewEntry : Fragment() {
@Preview private lateinit var binding: FragViewEntryBinding
@Composable private var title: String? = null
fun PreviewEntryScreen(){ private var code: String? = null
ViewEntryScreen(null, FidelityEntry("Title","AAA","QR")) private var fmt: String? = null
}
@Composable override fun onCreateView(
fun ViewEntryScreen( inflater: LayoutInflater,
navController: NavHostController?, container: ViewGroup?,
entry: FidelityEntry savedInstanceState: Bundle?
) { ): View {
val context = LocalContext.current binding = FragViewEntryBinding.inflate(layoutInflater)
val activity = context as? Activity val res = KeepassWrapper.bundleExtract(arguments)
var isFull by remember { mutableStateOf(false) } title = res.first
var bitmap by remember { mutableStateOf<Bitmap?>(null) } code = res.second
fmt = res.third
SideEffect { updatePreview()
activity?.window?.attributes = activity.window?.attributes?.apply { updateLayout()
screenBrightness = if (isFull) 1f else BRIGHTNESS_OVERRIDE_NONE
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. private fun updatePreview() {
LaunchedEffect(entry) { binding.title.text = title
try { try {
bitmap = generateBarcode(entry.code, entry.format, 1024) val barcodeBitmap = generateBarcode(
} catch (e: Exception) { code, fmt, 1024
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)
) )
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()
} }
} }
private fun updateLayout() {
BoxWithConstraints( if (isLandscape()) {
modifier = Modifier binding.title.visibility = View.GONE
.fillMaxSize().padding(8.dp), setScreenBrightness(BRIGHTNESS_OVERRIDE_FULL)
contentAlignment = Alignment.Center } else {
) { binding.title.visibility = View.VISIBLE
bitmap?.let { setScreenBrightness(BRIGHTNESS_OVERRIDE_NONE)
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 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.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.core.content.edit
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
@@ -10,7 +9,6 @@ import org.json.JSONException
object AccessManager { object AccessManager {
private const val PREF_KEY_SCOPE = "scope" private const val PREF_KEY_SCOPE = "scope"
private const val PREF_KEY_TOKEN = "token" private const val PREF_KEY_TOKEN = "token"
private const val PREF_HOSTS = "KP2A.PluginAccess.hosts"
private fun stringArrayToString(values: ArrayList<String?>): String? { private fun stringArrayToString(values: ArrayList<String?>): String? {
if (values.isEmpty()) return null if (values.isEmpty()) return null
@@ -39,14 +37,16 @@ object AccessManager {
accessToken: String?, accessToken: String?,
scopes: ArrayList<String?> scopes: ArrayList<String?>
) { ) {
getPrefsForHost(ctx, hostPackage).edit { val prefs = getPrefsForHost(ctx, hostPackage)
putString(PREF_KEY_TOKEN, accessToken) val edit = prefs.edit()
putString(PREF_KEY_SCOPE, stringArrayToString(scopes)) 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)) if (!hostPrefs.contains(hostPackage))
hostPrefs.edit { putString(hostPackage, "") } hostPrefs.edit().putString(hostPackage, "").apply()
} }
private fun getPrefsForHost( private fun getPrefsForHost(
@@ -65,6 +65,7 @@ object AccessManager {
if (!isSubset(scopes, currentScope)) if (!isSubset(scopes, currentScope))
return null return null
return prefs.getString(PREF_KEY_TOKEN, null) return prefs.getString(PREF_KEY_TOKEN, null)
} }
private fun isSubset( private fun isSubset(
@@ -79,11 +80,15 @@ object AccessManager {
accessToken: String? accessToken: String?
) { ) {
val prefs = getPrefsForHost(ctx, hostPackage) val prefs = getPrefsForHost(ctx, hostPackage)
if (prefs.getString(PREF_KEY_TOKEN, "") == accessToken) if (prefs.getString(PREF_KEY_TOKEN, "") == accessToken) {
prefs.edit { clear() } val edit = prefs.edit()
edit.clear()
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)) if (hostPrefs.contains(hostPackage)) {
hostPrefs.edit { remove(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_ENTRY_OUTPUT_DATA = "keepass2android.EXTRA_ENTRY_OUTPUT_DATA"
const val EXTRA_PROTECTED_FIELDS_LIST = "keepass2android.EXTRA_PROTECTED_FIELDS_LIST" const val EXTRA_PROTECTED_FIELDS_LIST = "keepass2android.EXTRA_PROTECTED_FIELDS_LIST"
const val EXTRA_ACCESS_TOKEN = "keepass2android.EXTRA_ACCESS_TOKEN" 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 = const val ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE =
"keepass2android.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_14 -> "RSS_14"
BarcodeFormat.RSS_EXPANDED -> "RSS_EXPANDED" BarcodeFormat.RSS_EXPANDED -> "RSS_EXPANDED"
BarcodeFormat.UPC_EAN_EXTENSION -> "UPC_EAN" 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.WriterException
import com.google.zxing.common.BitMatrix import com.google.zxing.common.BitMatrix
import net.helcel.fidelity.tools.BarcodeFormatConverter.stringToFormat import net.helcel.fidelity.tools.BarcodeFormatConverter.stringToFormat
import androidx.core.graphics.set
import androidx.core.graphics.createBitmap
object BarcodeGenerator { object BarcodeGenerator {
@@ -33,11 +31,13 @@ object BarcodeGenerator {
val bitMatrix: BitMatrix = writer.encode(content, format, width, height) 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 (x in 0 until width) {
for (y in 0 until height) { for (y in 0 until height) {
bitmap[x, y] = getPixelColor(bitMatrix, x, y) bitmap.setPixel(
x, y, getPixelColor(bitMatrix, x, y)
)
} }
} }
return bitmap return bitmap
@@ -13,6 +13,7 @@ import com.google.zxing.common.HybridBinarizer
import net.helcel.fidelity.tools.BarcodeFormatConverter.formatToString import net.helcel.fidelity.tools.BarcodeFormatConverter.formatToString
import java.util.concurrent.Executors import java.util.concurrent.Executors
@OptIn(ExperimentalGetImage::class) @OptIn(ExperimentalGetImage::class)
object BarcodeScanner { object BarcodeScanner {
@@ -25,9 +26,9 @@ object BarcodeScanner {
try { try {
val result = reader.decode(binaryBitmap) val result = reader.decode(binaryBitmap)
cb(result.text, formatToString(result.barcodeFormat)) cb(result.text, formatToString(result.barcodeFormat))
} catch (_: NotFoundException) { } catch (e: NotFoundException) {
cb(null, null) cb(null, null)
} catch (_: ReaderException) { } catch (e: ReaderException) {
cb(null, null) cb(null, null)
} }
} }
@@ -55,4 +56,6 @@ object BarcodeScanner {
fun bitmapUseCase(bitmap: Bitmap, cb: (String?, String?) -> Unit) { fun bitmapUseCase(bitmap: Bitmap, cb: (String?, String?) -> Unit) {
processImage(bitmap, cb) 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"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources xmlns:tools="http://schemas.android.com/tools">
<string name="key_theme">App theme</string> <string name="kp2aplugin_title" tools:keep="@string/kp2aplugin_title">Fidelity</string>
<string name="system">System</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="light">Light</string> <string name="kp2aplugin_author" tools:keep="@string/kp2aplugin_author">Soraefir</string>
<string name="dark">Dark</string>
<string name="key_stats">Statistics</string> <string name="app_name">Keepass Fidelity</string>
<string name="barcode_preview">barcode preview</string> <string name="barcode_preview">barcode preview</string>
<string name="expand">Expand</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. // 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 { plugins {
id 'com.android.application' version '9.3.2' apply false id 'com.android.application' version '8.11.0' apply false
id 'com.android.library' version '9.3.2' apply false id 'com.android.library' version '8.11.1' apply false
id 'org.jetbrains.kotlin.android' version '2.3.21' apply false id 'org.jetbrains.kotlin.android' version '2.2.0' apply false
id 'com.autonomousapps.dependency-analysis' version '3.13.0' apply true 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 # 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, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.onlyEnableUnitTestForTheTestedBuildType=false
android.uniquePackageNames=false
android.r8.strictFullModeForKeepRules=false
android.dependency.useConstraints=false
Binary file not shown.
+1 -3
View File
@@ -1,9 +1,7 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
Vendored
+5 -2
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015 the original authors. # Copyright © 2015-2021 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (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. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,6 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM. # 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 # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -210,6 +212,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@" "$@"
Vendored
+22 -10
View File
@@ -23,8 +23,8 @@
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled @rem Set local scope for the variables with windows NT shell
setlocal EnableExtensions if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1 goto fail
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1 goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=
@rem Execute Gradle @rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded "%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" %*
@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
:exitWithErrorLevel :end
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts @rem End local scope for the variables with windows NT shell
"%COMSPEC%" /c exit %ERRORLEVEL% 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' } 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" rootProject.name = "Fidelity"
include ':app' include ':app'