This commit is contained in:
soraefir
2025-10-07 00:37:19 +02:00
parent 7e1443deca
commit 8b5c1cd942
41 changed files with 1949 additions and 1492 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "external/KeePassDX"]
path = external/KeePassDX
url = https://github.com/Kunzisoft/KeePassDX.git

View File

@@ -2,44 +2,55 @@ plugins {
id 'com.android.application' id 'com.android.application'
id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.20' id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.20'
id 'org.jetbrains.kotlin.plugin.compose' version '2.2.20'
} }
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 34 compileSdk 36
defaultConfig { defaultConfig {
applicationId 'net.helcel.fidelity' applicationId 'net.helcel.fidelity'
resValue "string", "app_name", "Keepass Fidelity" versionName "1.0d"
buildConfigField("String", "APP_NAME", "\"Keepass Fidelity\"")
manifestPlaceholders["APP_NAME"] = "Keepass Fidelity"
minSdk 28 minSdk 28
targetSdk 34 targetSdk 36
} }
signingConfigs { signingConfigs {
create("release") { create("release") {
keyAlias keystoreProperties['keyAlias'] try {
keyPassword keystoreProperties['keyPassword'] def keystorePropertiesFile = rootProject.file("app/keystore.properties")
storeFile file(keystoreProperties['storeFile']) def keystoreProperties = new Properties()
storePassword keystoreProperties['storePassword'] keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
} catch (FileNotFoundException e) {
println("File not found: ${e.message}")
}
} }
} }
buildTypes { buildTypes {
debug { debug {
debuggable true debuggable true
signingConfig = signingConfigs.getByName("release")
} }
release { release {
minifyEnabled true minifyEnabled true
shrinkResources false shrinkResources false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
signedRelease {
minifyEnabled true
shrinkResources false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
} }
} }
@@ -48,17 +59,15 @@ android {
compileOptions { compileOptions {
coreLibraryDesugaringEnabled true coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_21
encoding 'utf-8' encoding 'utf-8'
} }
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17
}
buildFeatures { buildFeatures {
viewBinding true viewBinding true
compose true
buildConfig true
} }
dependenciesInfo { dependenciesInfo {
@@ -67,18 +76,49 @@ 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"
}
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.9.2'
implementation 'androidx.compose.material:material-icons-extended:1.7.8'
implementation 'androidx.navigation:navigation-compose:2.9.5'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation "androidx.biometric:biometric:1.2.0-alpha05"
implementation "androidx.security:security-crypto:1.1.0"
implementation "androidx.datastore:datastore-preferences:1.1.7"
implementation "androidx.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.5.0' implementation 'androidx.camera:camera-lifecycle:1.5.0'
implementation 'androidx.camera:camera-view:1.5.0' implementation 'androidx.camera:camera-view:1.5.0'
runtimeOnly 'androidx.camera:camera-camera2:1.5.0' runtimeOnly 'androidx.camera:camera-camera2:1.5.0'
implementation 'com.google.code.gson:gson:2.13.2'
implementation 'com.google.android.material:material:1.13.0' implementation 'com.google.android.material:material:1.13.0'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0'
implementation 'com.google.zxing:core:3.5.3' implementation 'com.google.zxing:core:3.5.3'
implementation project(":database")
implementation project(":crypto")
implementation platform('androidx.compose:compose-bom:2025.09.01')
debugImplementation 'androidx.compose.ui:ui-tooling:1.9.2'
debugImplementation 'androidx.compose.ui:ui-tooling-preview'
} }

View File

@@ -2,12 +2,5 @@
# fields. Proguard removes such information by default, keep it. # fields. Proguard removes such information by default, keep it.
-keepattributes Signature -keepattributes Signature
# This is also needed for R8 in compat mode since multiple
# optimizations will remove the generic signature such as class
# merging and argument removal. See:
# https://r8.googlesource.com/r8/+/refs/heads/main/compatibility-faq.md#troubleshooting-gson-gson
-keep class com.google.gson.reflect.TypeToken { *; }
-keep class * extends com.google.gson.reflect.TypeToken
# Optional. For using GSON @Expose annotation # Optional. For using GSON @Expose annotation
-keepattributes AnnotationDefault,RuntimeVisibleAnnotations -keepattributes AnnotationDefault,RuntimeVisibleAnnotations

View File

@@ -1,37 +1,20 @@
<?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"
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" />
<application <application
android:icon="@mipmap/ic_launcher_round" android:icon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="${APP_NAME}"
android:supportsRtl="true"> android:supportsRtl="true">
<activity <activity
android:name=".activity.MainActivity" android:name=".activity.MainActivity"
android:exported="true" android:exported="true">
android: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" />
</intent-filter> </intent-filter>
</activity> </activity>
<receiver
android:name=".pluginSDK.PluginAccessBroadcastReceiver"
android:exported="true"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="keepass2android.ACTION_TRIGGER_REQUEST_ACCESS" />
<action android:name="keepass2android.ACTION_RECEIVE_ACCESS" />
<action android:name="keepass2android.ACTION_REVOKE_ACCESS" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>

View File

@@ -0,0 +1,65 @@
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
)
}

View File

@@ -1,65 +1,62 @@
package net.helcel.fidelity.activity package net.helcel.fidelity.activity
import android.annotation.SuppressLint import android.annotation.SuppressLint
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.addCallback import androidx.activity.compose.BackHandler
import androidx.appcompat.app.AppCompatActivity import androidx.activity.compose.setContent
import net.helcel.fidelity.R import androidx.compose.runtime.LaunchedEffect
import net.helcel.fidelity.activity.fragment.Launcher import androidx.compose.ui.platform.LocalContext
import net.helcel.fidelity.activity.fragment.ViewEntry import androidx.fragment.app.FragmentActivity
import net.helcel.fidelity.databinding.ActMainBinding import androidx.navigation.compose.NavHost
import net.helcel.fidelity.pluginSDK.Kp2aControl.getEntryFieldsFromIntent import androidx.navigation.compose.composable
import net.helcel.fidelity.tools.CacheManager import androidx.navigation.compose.rememberNavController
import net.helcel.fidelity.tools.KeepassWrapper.bundleCreate import net.helcel.fidelity.activity.fragment.CreateEntryScreen
import net.helcel.fidelity.tools.KeepassWrapper.entryExtract import net.helcel.fidelity.activity.fragment.FileScanner
import net.helcel.fidelity.activity.fragment.InitialScreen
@SuppressLint("SourceLockedOrientationActivity") import net.helcel.fidelity.activity.fragment.LauncherScreen
class MainActivity : AppCompatActivity() { import net.helcel.fidelity.activity.fragment.ScannerScreen
import net.helcel.fidelity.activity.fragment.ViewEntryScreen
private lateinit var binding: ActMainBinding import net.helcel.fidelity.tools.FidelityRepository.entries
private lateinit var sharedPreferences: SharedPreferences import net.helcel.fidelity.tools.FidelityRepository.loadEntries
import net.helcel.fidelity.tools.KeePassStore.hasCredentials
class MainActivity : FragmentActivity() {
@SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
sharedPreferences = actionBar?.hide()
this.getSharedPreferences(CacheManager.PREF_NAME, Context.MODE_PRIVATE) requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
CacheManager.loadFidelity(sharedPreferences) loadEntries(this.baseContext)
binding = ActMainBinding.inflate(layoutInflater) setContent {
setContentView(binding.root) SysTheme {
onBackPressedDispatcher.addCallback(this) { val navController = rememberNavController()
if (supportFragmentManager.backStackEntryCount > 0) { val context = LocalContext.current
supportFragmentManager.popBackStackImmediate()
loadLauncher() BackHandler {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT if (!navController.popBackStack()) finish()
} else { }
finish() LaunchedEffect(Unit) {
if(!hasCredentials(context)) navController.navigate("init")
}
NavHost(navController = navController, startDestination = "launcher") {
composable("exit") { finish() }
composable("launcher") { LauncherScreen(navController) }
composable("init"){ InitialScreen (navController)}
composable("scanCam") { ScannerScreen(navController) }
composable("scanFile") { FileScanner(navController) }
composable("edit"){ CreateEntryScreen(navController) }
composable("view/{entryId}") { e ->
val entry = entries.find {
it.uid == (e.arguments?.getString("entryId") ?: "")
}
if (entry == null) return@composable navController.navigate("launcher")
ViewEntryScreen(navController,entry)
}
}
} }
} }
if (intent.extras != null)
loadViewEntry()
else if (savedInstanceState == null)
loadLauncher()
}
private fun loadLauncher() {
supportFragmentManager.beginTransaction()
.replace(R.id.container, Launcher())
.commit()
}
private fun loadViewEntry() {
val viewEntry = ViewEntry()
val data = getEntryFieldsFromIntent(intent)
viewEntry.arguments = bundleCreate(entryExtract(data))
supportFragmentManager.beginTransaction()
.replace(R.id.container, viewEntry)
.commit()
} }
} }

View File

@@ -1,49 +0,0 @@
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) }
}
}
}

View File

@@ -1,167 +1,370 @@
package net.helcel.fidelity.activity.fragment package net.helcel.fidelity.activity.fragment
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.padding
import androidx.compose.foundation.layout.width
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.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.Entry
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import net.helcel.fidelity.R import net.helcel.fidelity.R
import net.helcel.fidelity.databinding.FragCreateEntryBinding import net.helcel.fidelity.activity.ToastHelper
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.onSubmit
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onSave
import net.helcel.fidelity.tools.BarcodeGenerator.generateBarcode import net.helcel.fidelity.tools.BarcodeGenerator.generateBarcode
import net.helcel.fidelity.tools.CacheManager import net.helcel.fidelity.tools.FidelityEntry
import net.helcel.fidelity.tools.ErrorToaster import net.helcel.fidelity.tools.FidelityRepository
import net.helcel.fidelity.tools.KeepassWrapper import net.helcel.fidelity.tools.FidelityRepository.activeEntry
import net.helcel.fidelity.tools.FidelityRepository.addEntry
private const val DEBOUNCE_DELAY = 500L
class CreateEntry : Fragment() {
private val handler = Handler(Looper.getMainLooper())
private lateinit var binding: FragCreateEntryBinding
private val resultLauncherAdd = KeepassWrapper.resultLauncher(this) {
val r = KeepassWrapper.entryExtract(it)
if (!KeepassWrapper.isProtected(it)) {
CacheManager.addFidelity(r)
}
startViewEntry(r.first, r.second, r.third)
}
private var isValidBarcode: Boolean = false
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragCreateEntryBinding.inflate(layoutInflater)
val formats = resources.getStringArray(R.array.format_array)
val arrayAdapter = ArrayAdapter(requireContext(), R.layout.list_item_dropdown, formats)
binding.editTextFormat.setAdapter(arrayAdapter)
val res = KeepassWrapper.bundleExtract(arguments)
binding.editTextCode.setText(res.second)
binding.editTextFormat.setText(res.third, false)
binding.editTextCode.addTextChangedListener { changeListener() }
binding.editTextFormat.addTextChangedListener { changeListener() }
binding.editTextFormat.addTextChangedListener { binding.editTextFormat.error = null }
binding.btnSave.setOnClickListener { submit() }
binding.editTextTitle.onDone { submit() }
binding.editTextCode.onDone { submit() }
updatePreview() @Preview
return binding.root @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("") }
private fun updatePreview() { var barcodeBitmap by remember { mutableStateOf<Bitmap?>(null) }
try { var isValidBarcode by remember { mutableStateOf(false) }
val barcodeBitmap = generateBarcode( var showDialog by remember { mutableStateOf(false) }
binding.editTextCode.text.toString(), var isLoading by remember { mutableStateOf(false) }
binding.editTextFormat.text.toString(), val ctx = LocalContext.current
600 val scope = rememberCoroutineScope()
)
binding.imageViewPreview.setImageBitmap(barcodeBitmap)
isValidBarcode = true
} catch (e: FormatException) {
binding.imageViewPreview.setImageBitmap(null)
binding.editTextCode.error = "Invalid format"
} catch (e: IllegalArgumentException) {
binding.imageViewPreview.setImageBitmap(null)
binding.editTextCode.error = e.message
} catch (e: Exception) {
binding.imageViewPreview.setImageBitmap(null)
e.printStackTrace()
}
}
private fun isValidForm(): Boolean { LaunchedEffect(entry) {
var valid = true
if (binding.editTextFormat.text.isNullOrEmpty()) {
valid = false
binding.editTextFormat.error = "Format cannot be empty"
}
if (binding.editTextCode.text.isNullOrEmpty()) {
valid = false
binding.editTextCode.error = "Code cannot be empty"
}
if (binding.editTextTitle.text.isNullOrEmpty()) {
valid = false
binding.editTextTitle.error = "Title cannot be empty"
}
return valid
}
private fun startViewEntry(title: String?, code: String?, fmt: String?) {
val viewEntryFragment = ViewEntry()
viewEntryFragment.arguments = KeepassWrapper.bundleCreate(title, code, fmt)
requireActivity().supportFragmentManager.beginTransaction()
.replace(R.id.container, viewEntryFragment).commit()
}
private fun changeListener() {
isValidBarcode = false isValidBarcode = false
handler.removeCallbacksAndMessages(null) delay(500)
handler.postDelayed({ if (entry.code.isEmpty()) return@LaunchedEffect
updatePreview() try {
}, DEBOUNCE_DELAY) val bmp = generateBarcode(entry.code, entry.format, 600)
} barcodeBitmap = bmp
isValidBarcode = true
errorCode = ""
private fun TextInputEditText.onDone(callback: () -> Unit) { } catch (_: FormatException) {
setOnEditorActionListener { _, actionId, _ -> barcodeBitmap = null
if (actionId == EditorInfo.IME_ACTION_DONE) { errorCode = "Invalid Format"
callback.invoke() } catch (e: IllegalArgumentException) {
return@setOnEditorActionListener true barcodeBitmap = null
} errorCode = if (e.message == "com.google.zxing.FormatException") "Invalid Format"
false else e.message ?: "Invalid Argument"
} catch (e: Exception) {
barcodeBitmap = null
ToastHelper.show(ctx, e.message ?: e.toString())
} }
} }
private fun submit() { if (showDialog) {
if (!isValidForm() || !isValidBarcode) { TreeSelectorDialog(
ErrorToaster.formIncomplete(context) onDismiss = {
} else { showDialog = false
val kpEntry = KeepassWrapper.entryCreate( if(it!=null){
this, entry = entry.copy(uid = it.nodeId?.id.toString())
binding.editTextTitle.text.toString(), if(it is Entry){
binding.editTextCode.text.toString(), entry = entry.copy(title = it.title)
binding.editTextFormat.text.toString(), }
binding.checkboxProtected.isChecked, }
}
)
}
val formats = stringArrayResource(R.array.format_array)
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
Column(
modifier = Modifier
.padding(16.dp, 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
)
{
OutlinedTextField(
value = entry.title,
enabled = entry.uid!=null,
onValueChange = {
entry = entry.copy(title = it)
errorTitle = ""
},
label = { Text(text = "Title") },
isError = errorTitle.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
colors = TextFieldDefaults.textFieldColors(
textColor = if(entry.uid!=null)MaterialTheme.colors.onBackground
else MaterialTheme.colors.secondary
),
) )
try { if (errorTitle.isNotEmpty()) {
resultLauncherAdd.launch( Text(errorTitle, color = MaterialTheme.colors.error)
Kp2aControl.getAddEntryIntent( }
kpEntry.first,
kpEntry.second OutlinedTextField(
) value = entry.code,
onValueChange = {
entry = entry.copy(code = it)
errorCode = ""
},
colors = TextFieldDefaults.textFieldColors(
textColor = MaterialTheme.colors.onBackground
),
label = { Text("Code") },
isError = errorCode.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
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()
) )
} catch (e: ActivityNotFoundException) { Text("Protected", color = MaterialTheme.colors.onBackground)
ErrorToaster.noKP2AFound(context)
} catch (e: Exception) { Spacer(modifier = Modifier.weight(1f))
e.printStackTrace() 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 (!binding.checkboxProtected.isChecked) { if (barcodeBitmap != null) {
val r = KeepassWrapper.entryExtract(kpEntry.first) Image(
CacheManager.addFidelity(r) bitmap = barcodeBitmap!!.asImageBitmap(),
contentDescription = "Barcode preview",
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
Spacer(modifier = Modifier.height(8.dp))
}
}
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 (FidelityRepository.getRoot() == null) {
isLoading = true
scope.launch {
onRefresh(ctx, navController!!)
isLoading = false
if(entry.uid!=null){
addEntry(ctx,entry)
isLoading = true
onSave(ctx,navController)
isLoading = false
onSubmit(navController)
}else {
showDialog = true
}
}
} else {
if(entry.uid!=null){
addEntry(ctx,entry)
isLoading = true
scope.launch {
onSave(ctx, navController!!)
isLoading = false
onSubmit(navController)
}
}else {
showDialog = true
}
}
}
},
enabled = isValidBarcode.and(entry.uid==null || entry.title.isNotEmpty()),
) {
Text(if(entry.uid==null)"Select Entry" else "Save", style = MaterialTheme.typography.h6)
}
}
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()
} }
activity?.supportFragmentManager?.popBackStack()
} }
} }
}
@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.uid!=null && 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 {
fun onSubmit(navController: NavHostController){
navController.popBackStack()
activeEntry.value = activeEntry.value.copy(null,"","","",false)
}
fun onFileScan(navController: NavHostController){
navController.navigate("scanFile")
}
fun onCameraScan(navController: NavHostController){
navController.navigate("scanCam")
}
} }

View File

@@ -1,107 +0,0 @@
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()
}
}
}

View File

@@ -1,136 +1,345 @@
package net.helcel.fidelity.activity.fragment package net.helcel.fidelity.activity.fragment
import android.content.ActivityNotFoundException import android.content.Context
import android.os.Bundle import androidx.compose.foundation.background
import android.view.LayoutInflater import androidx.compose.foundation.clickable
import android.view.View import androidx.compose.foundation.combinedClickable
import android.view.ViewGroup import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.fragment.app.Fragment import androidx.compose.foundation.layout.Arrangement
import androidx.recyclerview.widget.ItemTouchHelper import androidx.compose.foundation.layout.Box
import androidx.recyclerview.widget.LinearLayoutManager import androidx.compose.foundation.layout.Row
import androidx.recyclerview.widget.RecyclerView import androidx.compose.foundation.layout.Spacer
import net.helcel.fidelity.R import androidx.compose.foundation.layout.fillMaxSize
import net.helcel.fidelity.activity.adapter.FidelityListAdapter import androidx.compose.foundation.layout.fillMaxWidth
import net.helcel.fidelity.databinding.FragLauncherBinding import androidx.compose.foundation.layout.padding
import net.helcel.fidelity.pluginSDK.Kp2aControl import androidx.compose.foundation.layout.size
import net.helcel.fidelity.tools.CacheManager import androidx.compose.foundation.layout.width
import net.helcel.fidelity.tools.ErrorToaster import androidx.compose.foundation.lazy.grid.GridCells
import net.helcel.fidelity.tools.KeepassWrapper import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.HideSource
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
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.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onAdd
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onEdit
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onHide
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onPin
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onQuery
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onView
import net.helcel.fidelity.tools.CredentialResult
import net.helcel.fidelity.tools.FidelityEntry
import net.helcel.fidelity.tools.FidelityRepository.activeEntry
import net.helcel.fidelity.tools.FidelityRepository.end
import net.helcel.fidelity.tools.FidelityRepository.entries
import net.helcel.fidelity.tools.FidelityRepository.genCredentials
import net.helcel.fidelity.tools.FidelityRepository.importDB
import net.helcel.fidelity.tools.FidelityRepository.start
import net.helcel.fidelity.tools.KeePassStore.loadCredentials
@Preview
class Launcher : Fragment() { @OptIn(ExperimentalMaterial3Api::class)
@Composable
private lateinit var binding: FragLauncherBinding fun LauncherScreen(
private lateinit var fidelityListAdapter: FidelityListAdapter navController: NavHostController?,
) {
private val resultLauncherQuery = KeepassWrapper.resultLauncher(this) { if(navController==null) return
val r = KeepassWrapper.entryExtract(it) var isRefreshingState by remember { mutableStateOf(false) }
if (!KeepassWrapper.isProtected(it)) { var showHidden by remember { mutableStateOf(false) }
CacheManager.addFidelity(r) val context = LocalContext.current
} val scope = rememberCoroutineScope()
startViewEntry(r.first, r.second, r.third) val sortedEntries = remember(entries) {
} derivedStateOf {
entries.filter{showHidden || !it.hidden}.sortedWith(
override fun onCreateView( compareByDescending<FidelityEntry> { it.pinned }
inflater: LayoutInflater, container: ViewGroup?, .thenBy { it.hidden }
savedInstanceState: Bundle? .thenByDescending { it.lastUse }
): 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
}
private fun hideMenuAdd() {
binding.btnAdd.setImageResource(R.drawable.cross)
binding.menuAdd.visibility = View.GONE
}
private fun showMenuAdd() {
binding.btnAdd.setImageResource(R.drawable.minus)
binding.menuAdd.visibility = View.VISIBLE
}
private fun startGetFromKeepass() {
try {
this.resultLauncherQuery.launch(Kp2aControl.getQueryEntryForOwnPackageIntent())
} catch (e: ActivityNotFoundException) {
ErrorToaster.noKP2AFound(requireActivity())
} }
} }
private fun startFragment(fragment: Fragment) {
requireActivity().supportFragmentManager.beginTransaction()
.addToBackStack("Launcher")
.replace(R.id.container, fragment).commit()
}
private fun startScanner() { Box(modifier = Modifier
startFragment(Scanner()) .fillMaxSize()
} .background(MaterialTheme.colors.background)) {
private fun startFileScanner() { PullToRefreshBox(
startFragment(FileScanner()) onRefresh = {
} isRefreshingState = true
scope.launch {
private fun startCreateEntry() { onRefresh(context, navController)
startFragment(CreateEntry()) isRefreshingState = false
} }
},
isRefreshing = isRefreshingState,
private fun startViewEntry(title: String?, code: String?, fmt: String?) { modifier = Modifier.fillMaxSize()
val viewEntryFragment = ViewEntry()
viewEntryFragment.arguments = KeepassWrapper.bundleCreate(title, code, fmt)
startFragment(viewEntryFragment)
}
private fun recyclerSlideHelper(): ItemTouchHelper {
return ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
0, ItemTouchHelper.LEFT
) { ) {
override fun onMove( LazyVerticalGrid(
recyclerView: RecyclerView, columns = GridCells.Fixed(2),
viewHolder: RecyclerView.ViewHolder, modifier = Modifier
target: RecyclerView.ViewHolder .fillMaxSize()
): Boolean = false .fillMaxSize()
.padding(16.dp),
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { verticalArrangement = Arrangement.spacedBy(8.dp),
val pos = viewHolder.adapterPosition horizontalArrangement = Arrangement.spacedBy(8.dp)
CacheManager.rmFidelity(pos) ) {
fidelityListAdapter.notifyItemRemoved(pos) 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")
}
FloatingActionButton(
onClick = {
showHidden=!showHidden
}, modifier = Modifier
.align(Alignment.BottomStart)
.padding(16.dp).size(24.dp),
backgroundColor = if(showHidden) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary,
) {
Icon(Icons.Default.HideSource,
tint= if(showHidden) MaterialTheme.colors.background else MaterialTheme.colors.onSecondary,
contentDescription = "Show Hidden")
}
}
if (isRefreshingState)
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background.copy(alpha = 0.75f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { }
)
)
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun FidelityRow(
navController: NavHostController,
e: FidelityEntry
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp)
.combinedClickable(
onClick = { onView(navController, e) },
onLongClick = { expanded = true },
),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colors.primary,
contentColor = MaterialTheme.colors.background
),
) {
Box(modifier = Modifier.fillMaxSize().padding(2.dp)) {
Row(modifier = Modifier.padding(14.dp)) {
Text(
text = e.title,
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onPrimary
)
}
Row(modifier = Modifier.align(Alignment.TopEnd)) {
if (e.hidden)
Icon(
Icons.Default.HideSource, contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colors.onPrimary
)
if (e.hidden && e.pinned)
Spacer(modifier = Modifier.width(8.dp))
if (e.pinned)
Icon(
Icons.Default.PushPin, contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colors.onPrimary
)
}
}
}
DropdownMenu(
modifier = Modifier,
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(onClick = {
expanded = false
onEdit(navController, e)
}) {
Icon(
Icons.Default.Edit,
contentDescription = "edit",
)
Spacer(modifier= Modifier.width(8.dp))
Text("Edit")
}
DropdownMenuItem(onClick = {
expanded = false
onPin(e)
}) {
Icon(
Icons.Default.PushPin,
contentDescription = "pin",
)
Spacer(modifier= Modifier.width(8.dp))
if(e.pinned) Text("Unpin")
else Text("Pin")
}
DropdownMenuItem(onClick = {
expanded = false
onHide(e)
}) {
Icon(
Icons.Default.HideSource,
contentDescription = "hide",
)
Spacer(modifier= Modifier.width(8.dp))
if(e.hidden) Text("Unhide")
else Text("Hide")
}
}
}
}
object LauncherEventHandlers {
fun onAdd(navController: NavHostController) {
navController.navigate("edit")
}
fun onQuery() {
//TODO
}
var CRED: CredentialResult.Success? = null
suspend fun onSave(context: Context, navController: NavHostController){
try {
if (CRED == null) {
val res = loadCredentials(context)
when (res) {
CredentialResult.AuthFailed, CredentialResult.NoData -> null
is CredentialResult.Success -> CRED = res
}
}
CRED!!
val cred = withContext(Dispatchers.IO) {
genCredentials(context, CRED!!)
}
if (withContext(Dispatchers.IO) {
end(context, CRED!!.db, cred)
})
throw Exception("Error in saving")
} catch (e: Exception) {
println(e.toString())
navController.navigate("init")
}
}
suspend fun onRefresh(context: Context, navController: NavHostController) {
try {
if (CRED == null) {
val res = loadCredentials(context)
when (res) {
CredentialResult.AuthFailed, CredentialResult.NoData -> null
is CredentialResult.Success -> CRED = res
}
}
CRED!!
val cred = withContext(Dispatchers.IO) {
genCredentials(context, CRED!!)
}
if (withContext(Dispatchers.IO) {
start(context, CRED!!.db, cred)
})
importDB(context)
} catch (e: Exception) {
println(e.toString())
navController.navigate("init")
}
}
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 onEdit(navController: NavHostController, entry: FidelityEntry){
activeEntry.value = entry
navController.navigate("edit")
} }
} }

View File

@@ -1,105 +1,224 @@
@file:Suppress("PreviewAnnotationInFunctionWithParameters",
"PreviewAnnotationInFunctionWithParameters"
)
package net.helcel.fidelity.activity.fragment package net.helcel.fidelity.activity.fragment
import android.Manifest import android.Manifest
import android.content.ContentValues import android.graphics.BitmapFactory
import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.widget.Toast
import android.view.View import androidx.activity.compose.BackHandler
import android.view.ViewGroup import androidx.activity.compose.rememberLauncherForActivityResult
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.core.content.ContextCompat import androidx.camera.view.PreviewView
import androidx.fragment.app.Fragment import androidx.compose.foundation.Canvas
import net.helcel.fidelity.R import androidx.compose.foundation.layout.Box
import net.helcel.fidelity.databinding.FragScannerBinding import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FlashOn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.helcel.fidelity.activity.fragment.ScannerEventHandler.onResult
import net.helcel.fidelity.tools.BarcodeScanner
import net.helcel.fidelity.tools.BarcodeScanner.analysisUseCase import net.helcel.fidelity.tools.BarcodeScanner.analysisUseCase
import net.helcel.fidelity.tools.ErrorToaster import net.helcel.fidelity.tools.FidelityRepository.activeEntry
import net.helcel.fidelity.tools.KeepassWrapper
class Scanner : Fragment() { @androidx.compose.ui.tooling.preview.Preview
@Composable
fun ScannerScreen(
navController: NavController
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
private lateinit var binding: FragScannerBinding val cameraProviderFuture = remember {
ProcessCameraProvider.getInstance(context)
private var code: String = ""
private var fmt: String = ""
private val resultPermissionRequest =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (it) {
bindCameraUseCases()
} else {
parentFragmentManager.popBackStack()
ErrorToaster.noPermission(context)
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragScannerBinding.inflate(layoutInflater)
binding.btnScanDone.setOnClickListener {
startCreateEntry()
}
binding.btnScanDone.isEnabled = false
resultPermissionRequest.launch(Manifest.permission.CAMERA)
return binding.root
} }
var camera: Camera? by remember { mutableStateOf(null) }
var torchOn by remember { mutableStateOf(false) }
val done = remember { mutableStateOf(false) }
val previewView = remember { PreviewView(context) }
private fun startCreateEntry() { val permissionLauncher = rememberLauncherForActivityResult(
val createEntryFragment = CreateEntry() contract = ActivityResultContracts.RequestPermission(),
createEntryFragment.arguments = onResult = { granted ->
KeepassWrapper.bundleCreate(null, this.code, this.fmt) if (granted) {
requireActivity().supportFragmentManager.beginTransaction() val cameraProvider = cameraProviderFuture.get()
.replace(R.id.container, createEntryFragment) val previewUseCase = Preview.Builder().build().also {
.commit() it.surfaceProvider = previewView.surfaceProvider
} }
val analysisUseCase = analysisUseCase { detectedCode, detectedFormat ->
if (detectedCode.isNullOrEmpty() || detectedFormat.isNullOrEmpty()) return@analysisUseCase
private fun scannerResult(code: String?, format: String?) { if(done.value) return@analysisUseCase
if (!code.isNullOrEmpty() && !format.isNullOrEmpty()) { scope.launch(Dispatchers.Main) {
this.code = code activeEntry.value =
this.fmt = format activeEntry.value.copy(code = detectedCode, format = detectedFormat)
} done.value = true
val isDone = this.code.isNotEmpty() && this.fmt.isNotEmpty() onResult(navController)
activity?.runOnUiThread { }
binding.btnScanDone.isEnabled = isDone return@analysisUseCase
binding.ScanActive.isEnabled = !isDone }
} try {
} cameraProvider.unbindAll()
camera = cameraProvider.bindToLifecycle(
private fun bindCameraUseCases() { lifecycleOwner,
val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext()) CameraSelector.DEFAULT_BACK_CAMERA,
previewUseCase,
cameraProviderFuture.addListener({ analysisUseCase
val cameraProvider = cameraProviderFuture.get() )
val previewUseCase = Preview.Builder() } catch (e: Exception) {
.build() Log.e("ScannerScreen", "Camera bind failed: ${e.message}")
.also { }
it.setSurfaceProvider(binding.cameraView.surfaceProvider) } else {
Toast.makeText(context, "Camera permission denied", Toast.LENGTH_SHORT).show()
scope.launch(Dispatchers.Main){
onResult(navController)
} }
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
val analysisUseCase = analysisUseCase { code, format ->
scannerResult(code, format)
} }
try { }
cameraProvider.bindToLifecycle( )
this,
cameraSelector, LaunchedEffect(Unit) {
previewUseCase, permissionLauncher.launch(Manifest.permission.CAMERA)
analysisUseCase
)
} catch (illegalStateException: IllegalStateException) {
Log.e(ContentValues.TAG, illegalStateException.message.orEmpty())
} catch (illegalArgumentException: IllegalArgumentException) {
Log.e(ContentValues.TAG, illegalArgumentException.message.orEmpty())
}
}, ContextCompat.getMainExecutor(requireContext()))
} }
}
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
factory = { previewView },
modifier = Modifier.fillMaxSize()
)
ScannerOverlay(
modifier = Modifier.fillMaxSize()
)
Button(onClick = {
torchOn = !torchOn
camera?.cameraControl?.enableTorch(torchOn)
}, modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
) {
Icon(Icons.Default.FlashOn, contentDescription = null)
}
if(!done.value)
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.BottomCenter) // same spot as buttons
.padding(bottom =80.dp),
)
}
}
@Composable
fun ScannerOverlay(
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier.fillMaxSize()) {
val widthF = size.width
val heightF = size.height
drawRect(
color = Color(0x80000000), // semi-transparent black
size = size
)
val squareSize = 0.75f * minOf(widthF, heightF)
val left = (widthF - squareSize) / 2
val top = (heightF - squareSize) / 2
drawRect(
color = Color.Transparent,
topLeft = Offset(left, top),
size = Size(squareSize, squareSize),
blendMode = BlendMode.Clear
)
}
}
@Composable
fun FileScanner(navController: NavHostController) {
val context = LocalContext.current
rememberCoroutineScope()
val pickImageLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri ->
if (uri == null) {
Toast.makeText(context, "No file selected", Toast.LENGTH_SHORT).show()
onResult(navController)
return@rememberLauncherForActivityResult
}
try {
val inputStream = context.contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
BarcodeScanner.bitmapUseCase(bitmap) { code, format ->
if (!code.isNullOrEmpty() && !format.isNullOrEmpty()) {
activeEntry.value = activeEntry.value.copy(code=code, format=format)
onResult(navController)
} else {
Toast.makeText(context, "No barcode found", Toast.LENGTH_SHORT).show()
onResult(navController)
}
}
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(context, "Failed to load image", Toast.LENGTH_SHORT).show()
onResult(navController)
}
}
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()
}
}

View File

@@ -0,0 +1,144 @@
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.ExpandMore
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.FidelityRepository
@Preview
@Composable
fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
Dialog(
onDismissRequest = {onDismiss(null)},
content = {
Column(
modifier = Modifier.fillMaxWidth().background(
MaterialTheme.colors.background,
RoundedCornerShape(8.dp)
)
) {
var currentRoot by remember { mutableStateOf(FidelityRepository.getRoot()) }
var selection by remember { mutableStateOf<Node?>(FidelityRepository.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)
Row(
modifier = Modifier
.fillMaxWidth()
.background(color = if (isSel) MaterialTheme.colors.primary else MaterialTheme.colors.background)
.clickable {
if (entry.getChildEntries().isNotEmpty()) {
currentRoot = entry
selection = entry
} else if (entry.getChildGroups().isNotEmpty()) {
currentRoot = entry
selection = entry
} else {
selection = entry
}
}
.padding(8.dp)
) {
if (entry.getChildEntries().isNotEmpty() || entry.getChildGroups()
.isNotEmpty()
) {
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(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)
) {
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")
}
}
}
}
)
}

View File

@@ -0,0 +1,274 @@
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.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.FidelityRepository.genCredentials
import net.helcel.fidelity.tools.FidelityRepository.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 -> null
CredentialResult.NoData -> null
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 {
if(onOpen(context, dbFile!!, password, keyFile)){
navController!!.popBackStack()
navController.navigate("init")
}else{
ToastHelper.show(context, "Auth failed...")
navController!!.popBackStack()
navController.navigate("exit")
}
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Continue")
}
}
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 {
suspend fun onOpen(context: Context, db: Uri, p: String, key: Uri?): Boolean {
try {
val packCred = packCredentials(db, p, key)
withContext(Dispatchers.IO) {
start(context, db, genCredentials(context, packCred)
)
}
val res = withContext(Dispatchers.Main) {
saveCredentials(context, packCred)
}
return when (res) {
CredentialResult.AuthFailed, CredentialResult.NoData -> false
is CredentialResult.Success -> true
}
} catch (e: Exception) {
ToastHelper.show(context, e.message.toString())
println("Err${e.toString()}")
println(e.message)
return false
}
}
}

View File

@@ -1,86 +1,125 @@
package net.helcel.fidelity.activity.fragment package net.helcel.fidelity.activity.fragment
import android.annotation.SuppressLint import android.app.Activity
import android.content.pm.ActivityInfo import android.graphics.Bitmap
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 androidx.fragment.app.Fragment import android.widget.Toast
import com.google.zxing.FormatException import androidx.activity.compose.BackHandler
import net.helcel.fidelity.databinding.FragViewEntryBinding import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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.ErrorToaster import net.helcel.fidelity.tools.FidelityEntry
import net.helcel.fidelity.tools.KeepassWrapper import kotlin.let
import kotlin.math.min
@SuppressLint("SourceLockedOrientationActivity")
class ViewEntry : Fragment() {
private lateinit var binding: FragViewEntryBinding @Preview
private var title: String? = null @Composable
private var code: String? = null fun PreviewEntryScreen(){
private var fmt: String? = null ViewEntryScreen(null, FidelityEntry("Title","AAA","QR"))
}
override fun onCreateView( @Composable
inflater: LayoutInflater, fun ViewEntryScreen(
container: ViewGroup?, navController: NavHostController?,
savedInstanceState: Bundle? entry: FidelityEntry
): View { ) {
binding = FragViewEntryBinding.inflate(layoutInflater) val context = LocalContext.current
val res = KeepassWrapper.bundleExtract(arguments) val activity = context as? Activity
title = res.first var isFull by remember { mutableStateOf(false) }
code = res.second var bitmap by remember { mutableStateOf<Bitmap?>(null) }
fmt = res.third
updatePreview() SideEffect {
updateLayout() activity?.window?.attributes = activity.window?.attributes?.apply {
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
}
private fun updatePreview() {
binding.title.text = title
try { try {
val barcodeBitmap = generateBarcode( bitmap = generateBarcode(entry.code, entry.format, 1024)
code, fmt, 1024 } catch (_: Exception) {
bitmap = null
Toast.makeText(context, "Invalid barcode format", Toast.LENGTH_SHORT).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() {
if (isLandscape()) { BoxWithConstraints(
binding.title.visibility = View.GONE modifier = Modifier
setScreenBrightness(BRIGHTNESS_OVERRIDE_FULL) .fillMaxSize().padding(8.dp),
} else { contentAlignment = Alignment.Center
binding.title.visibility = View.VISIBLE ) {
setScreenBrightness(BRIGHTNESS_OVERRIDE_NONE) bitmap?.let {
val modifier = Modifier
.fillMaxSize()
.width(maxWidth)
.height(maxHeight)
.padding(16.dp)
.aspectRatio(it.width.toFloat()/it.height.toFloat())
.rotate(if (isFull) 90f else 0f)
.scale(if(isFull) min(it.width.dp/maxHeight,it.height.dp/maxWidth) else 1f)
Image(
bitmap = it.asImageBitmap(),
contentDescription = "Barcode",
modifier = modifier,
contentScale = ContentScale.Fit,
)
} ?: CircularProgressIndicator(color = Color.White)
} }
}
private fun isLandscape(): Boolean {
return (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)
}
private fun setScreenBrightness(brightness: Float) {
requireActivity().window?.attributes?.screenBrightness = brightness
}
} }

View File

@@ -1,45 +0,0 @@
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
)
}
}

View File

@@ -1,94 +0,0 @@
package net.helcel.fidelity.pluginSDK
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONException
object AccessManager {
private const val PREF_KEY_SCOPE = "scope"
private const val PREF_KEY_TOKEN = "token"
private fun stringArrayToString(values: ArrayList<String?>): String? {
if (values.isEmpty()) return null
val a = JSONArray()
values.forEach { a.put(it) }
return a.toString()
}
private fun stringToStringArray(s: String?): ArrayList<String> {
val strings = ArrayList<String>()
if (s.isNullOrEmpty()) return strings
try {
val a = JSONArray(s)
for (i in 0 until a.length())
strings.add(a.optString(i))
} catch (e: JSONException) {
e.printStackTrace()
}
return strings
}
fun storeAccessToken(
ctx: Context,
hostPackage: String?,
accessToken: String?,
scopes: ArrayList<String?>
) {
val prefs = getPrefsForHost(ctx, hostPackage)
val edit = prefs.edit()
edit.putString(PREF_KEY_TOKEN, accessToken)
val scopesString = stringArrayToString(scopes)
edit.putString(PREF_KEY_SCOPE, scopesString)
edit.apply()
val hostPrefs = ctx.getSharedPreferences("KP2A.PluginAccess.hosts", Context.MODE_PRIVATE)
if (!hostPrefs.contains(hostPackage))
hostPrefs.edit().putString(hostPackage, "").apply()
}
private fun getPrefsForHost(
ctx: Context,
hostPackage: String?
): SharedPreferences {
return ctx.getSharedPreferences("KP2A.PluginAccess.$hostPackage", Context.MODE_PRIVATE)
}
fun tryGetAccessToken(ctx: Context, hostPackage: String?, scopes: ArrayList<String?>): String? {
if (hostPackage.isNullOrEmpty()) return null
val prefs = getPrefsForHost(ctx, hostPackage)
val scopesString = prefs.getString(PREF_KEY_SCOPE, "")
val currentScope = stringToStringArray(scopesString)
if (!isSubset(scopes, currentScope))
return null
return prefs.getString(PREF_KEY_TOKEN, null)
}
private fun isSubset(
requiredScopes: ArrayList<String?>,
availableScopes: ArrayList<String>
): Boolean {
return availableScopes.containsAll(requiredScopes)
}
fun removeAccessToken(
ctx: Context, hostPackage: String?,
accessToken: String?
) {
val prefs = getPrefsForHost(ctx, hostPackage)
if (prefs.getString(PREF_KEY_TOKEN, "") == accessToken) {
val edit = prefs.edit()
edit.clear()
edit.apply()
}
val hostPrefs = ctx.getSharedPreferences("KP2A.PluginAccess.hosts", Context.MODE_PRIVATE)
if (hostPrefs.contains(hostPackage)) {
hostPrefs.edit().remove(hostPackage).apply()
}
}
}

View File

@@ -1,9 +0,0 @@
package net.helcel.fidelity.pluginSDK
@Suppress("unused")
object KeepassDef {
var TitleField: String = "Title"
var UserNameField: String = "UserName"
var PasswordField: String = "Password"
var UrlField: String = "URL"
}

View File

@@ -1,49 +0,0 @@
package net.helcel.fidelity.pluginSDK
import android.content.Intent
import org.json.JSONException
import org.json.JSONObject
object Kp2aControl {
fun getAddEntryIntent(
fields: HashMap<String, String>,
protectedFields: ArrayList<String>?
): Intent {
val outputData = JSONObject((fields as Map<*, *>)).toString()
val startKp2aIntent = Intent(Strings.ACTION_START_WITH_TASK)
startKp2aIntent.addCategory(Intent.CATEGORY_DEFAULT)
startKp2aIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
startKp2aIntent.putExtra("KP2A_APPTASK", "CreateEntryThenCloseTask")
startKp2aIntent.putExtra("ShowUserNotifications", "true")
startKp2aIntent.putExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA, outputData)
if (protectedFields != null)
startKp2aIntent.putStringArrayListExtra(
Strings.EXTRA_PROTECTED_FIELDS_LIST,
protectedFields
)
return startKp2aIntent
}
fun getQueryEntryForOwnPackageIntent(): Intent {
return Intent(Strings.ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE)
}
fun getEntryFieldsFromIntent(intent: Intent?): HashMap<String, String> {
val res = HashMap<String, String>()
try {
val json = JSONObject(intent?.getStringExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA) ?: "")
val itr = json.keys()
while (itr.hasNext()) {
val key = itr.next()
val value = json[key].toString()
res[key] = value
}
} catch (e: JSONException) {
e.printStackTrace()
} catch (e: NullPointerException) {
e.printStackTrace()
}
return res
}
}

View File

@@ -1,51 +0,0 @@
package net.helcel.fidelity.pluginSDK
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class PluginAccessBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val action = intent.action ?: return
when (action) {
Strings.ACTION_TRIGGER_REQUEST_ACCESS -> requestAccess(ctx, intent)
Strings.ACTION_RECEIVE_ACCESS -> receiveAccess(ctx, intent)
Strings.ACTION_REVOKE_ACCESS -> revokeAccess(ctx, intent)
else -> {}
}
}
private fun revokeAccess(ctx: Context, intent: Intent) {
val senderPackage = intent.getStringExtra(Strings.EXTRA_SENDER)
val accessToken = intent.getStringExtra(Strings.EXTRA_ACCESS_TOKEN)
AccessManager.removeAccessToken(ctx, senderPackage, accessToken)
}
private fun receiveAccess(ctx: Context, intent: Intent) {
val senderPackage = intent.getStringExtra(Strings.EXTRA_SENDER)
val accessToken = intent.getStringExtra(Strings.EXTRA_ACCESS_TOKEN)
AccessManager.storeAccessToken(ctx, senderPackage, accessToken, scopes)
}
private fun requestAccess(ctx: Context, intent: Intent) {
val senderPackage = intent.getStringExtra(Strings.EXTRA_SENDER)
val requestToken = intent.getStringExtra(Strings.EXTRA_REQUEST_TOKEN)
val rpi = Intent(Strings.ACTION_REQUEST_ACCESS)
rpi.setPackage(senderPackage)
rpi.putExtra(Strings.EXTRA_SENDER, ctx.packageName)
rpi.putExtra(Strings.EXTRA_REQUEST_TOKEN, requestToken)
val token: String? = AccessManager.tryGetAccessToken(ctx, senderPackage, scopes)
rpi.putExtra(Strings.EXTRA_ACCESS_TOKEN, token)
rpi.putStringArrayListExtra(Strings.EXTRA_SCOPES, scopes)
ctx.sendBroadcast(rpi)
}
private val scopes: ArrayList<String?> = ArrayList(
listOf(
Strings.SCOPE_QUERY_CREDENTIALS_FOR_OWN_PACKAGE,
)
)
}

View File

@@ -1,31 +0,0 @@
package net.helcel.fidelity.pluginSDK
@Suppress("unused")
object Strings {
const val SCOPE_DATABASE_ACTIONS = "keepass2android.SCOPE_DATABASE_ACTIONS"
const val SCOPE_CURRENT_ENTRY = "keepass2android.SCOPE_CURRENT_ENTRY"
const val SCOPE_QUERY_CREDENTIALS_FOR_OWN_PACKAGE =
"keepass2android.SCOPE_QUERY_CREDENTIALS_FOR_OWN_PACKAGE"
const val SCOPE_QUERY_CREDENTIALS = "keepass2android.SCOPE_QUERY_CREDENTIALS"
const val EXTRA_SCOPES = "keepass2android.EXTRA_SCOPES"
const val EXTRA_PLUGIN_PACKAGE = "keepass2android.EXTRA_PLUGIN_PACKAGE"
const val EXTRA_SENDER = "keepass2android.EXTRA_SENDER"
const val EXTRA_REQUEST_TOKEN = "keepass2android.EXTRA_REQUEST_TOKEN"
const val ACTION_START_WITH_TASK = "keepass2android.ACTION_START_WITH_TASK"
const val ACTION_TRIGGER_REQUEST_ACCESS = "keepass2android.ACTION_TRIGGER_REQUEST_ACCESS"
const val ACTION_REQUEST_ACCESS = "keepass2android.ACTION_REQUEST_ACCESS"
const val ACTION_RECEIVE_ACCESS = "keepass2android.ACTION_RECEIVE_ACCESS"
const val ACTION_REVOKE_ACCESS = "keepass2android.ACTION_REVOKE_ACCESS"
const val EXTRA_ENTRY_OUTPUT_DATA = "keepass2android.EXTRA_ENTRY_OUTPUT_DATA"
const val EXTRA_PROTECTED_FIELDS_LIST = "keepass2android.EXTRA_PROTECTED_FIELDS_LIST"
const val EXTRA_ACCESS_TOKEN = "keepass2android.EXTRA_ACCESS_TOKEN"
const val ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE =
"keepass2android.ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE"
}

View File

@@ -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")
} }
} }
} }

View File

@@ -6,6 +6,8 @@ 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 {
@@ -31,13 +33,11 @@ object BarcodeGenerator {
val bitMatrix: BitMatrix = writer.encode(content, format, width, height) val bitMatrix: BitMatrix = writer.encode(content, format, width, height)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val bitmap = createBitmap(width, height)
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.setPixel( bitmap[x, y] = getPixelColor(bitMatrix, x, y)
x, y, getPixelColor(bitMatrix, x, y)
)
} }
} }
return bitmap return bitmap

View File

@@ -26,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 (e: NotFoundException) { } catch (_: NotFoundException) {
cb(null, null) cb(null, null)
} catch (e: ReaderException) { } catch (_: ReaderException) {
cb(null, null) cb(null, null)
} }
} }

View File

@@ -0,0 +1,142 @@
package net.helcel.fidelity.tools
import android.content.Context
import android.net.Uri
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
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 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"
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)
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 executor = ContextCompat.getMainExecutor(activity)
val biometricPrompt = BiometricPrompt(
activity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { cont.resume(result.cryptoObject?.cipher) {} }
override fun onAuthenticationError(code: Int, msg: CharSequence) { cont.resume(null) {} }
override fun onAuthenticationFailed() { cont.resume(null) {} }
}
)
val iv = if(enc) null else prefs[KeePassKeys.IV]?.let { Base64.decode(it, Base64.DEFAULT) }
if (!enc && iv == null) { cont.resume(null) {} }
val cipher = getCipherForDecryption(getOrCreateBiometricKey(), iv)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock KeePass")
.setSubtitle("Authenticate to access your KeePass database")
.setNegativeButtonText("Cancel")
.build()
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
}
fun retrieveResponseFromChallenge(
hardwareKey: HardwareKey,
seed: ByteArray?,
): ByteArray {
val response: ByteArray = "".toByteArray()
return response
}

View File

@@ -1,50 +0,0 @@
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
}
}

View File

@@ -1,31 +0,0 @@
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)
}
}

View File

@@ -0,0 +1,185 @@
package net.helcel.fidelity.tools
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import kotlinx.serialization.Serializable
import java.io.ByteArrayInputStream
import kotlinx.serialization.json.Json
import androidx.core.content.edit
import com.kunzisoft.keepass.database.element.Database
import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.Group
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.serialization.builtins.ListSerializer
import java.io.File
import java.util.UUID
object FidelityKeepassFields {
const val FIDELITYFORMAT = "FidelityFormat"
const val FIDELITYCODE = "FidelityCode"
}
@Serializable
data class FidelityEntry(
val uid: String? = null,
val title: String = "",
val code: String = "",
val format: String = "",
val protected: Boolean = false,
val hidden: Boolean = false,
val pinned: Boolean = false,
val lastUse: Int = 0,
)
object FidelityRepository {
private var db: Database = Database()
private var binaryDir: File? = null
val entries = mutableStateListOf<FidelityEntry>()
val activeEntry = mutableStateOf(FidelityEntry())
fun getRoot(): Group? {
return db.rootGroup
}
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) },
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,
cred.key?.let { ctx.contentResolver.openInputStream(cred.key)?.readBytes() },
hardwareKey
)
}
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.stringValue,
format=format.protectedValue.stringValue,
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)
}
fun saveEntries(context: Context) {
val prefs = context.getSharedPreferences("fidelity_prefs", Context.MODE_PRIVATE)
prefs.edit { putString("entries", Json.encodeToString(
ListSerializer(FidelityEntry.serializer()),
entries
)) }
}
fun loadEntries(context: Context) {
val prefs = context.getSharedPreferences("fidelity_prefs", Context.MODE_PRIVATE)
try {
val json = prefs.getString("entries", null) ?: return
val list = Json.decodeFromString(
ListSerializer(FidelityEntry.serializer()),
json
)
entries.clear()
entries.addAll(list)
}catch(_: Exception){
prefs.edit{ putString("entries",Json.encodeToString(
ListSerializer(FidelityEntry.serializer()),emptyList()))
}
}
}
fun addEntry(ctx: Context, entry: FidelityEntry) {
val dbEntry = db.getEntryById(NodeIdUUID(UUID.fromString(entry.uid))) ?: db.createEntry()
val dbParent = db.getGroupById(NodeIdUUID(UUID.fromString(entry.uid)))
dbEntry?.apply {
putExtraField(
Field(
FidelityKeepassFields.FIDELITYCODE,
ProtectedString(entry.protected, entry.code)
)
)
putExtraField(
Field(
FidelityKeepassFields.FIDELITYFORMAT,
ProtectedString(string= entry.format)
)
)
if(dbParent!=null) title = entry.title
dbParent?.addChildEntry(dbEntry)
}
entries.removeIf {it.uid == entry.uid}
entries.add(entry.copy(uid=dbEntry?.nodeId?.id.toString()))
saveEntries(ctx)
}
}

View File

@@ -1,85 +0,0 @@
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()
}
}

View File

@@ -1,17 +0,0 @@
<?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>

View File

@@ -1,113 +0,0 @@
<?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>

View File

@@ -1,94 +0,0 @@
<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>

View File

@@ -1,37 +0,0 @@
<?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>

View File

@@ -1,39 +0,0 @@
<?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>

View File

@@ -1,8 +0,0 @@
<?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" />

View File

@@ -1,30 +0,0 @@
<?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>

View File

@@ -1,3 +0,0 @@
<resources>
</resources>

View File

@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources>
<string name="kp2aplugin_title" tools:keep="@string/kp2aplugin_title">Fidelity</string> <string name="key_theme">App theme</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="system">System</string>
<string name="kp2aplugin_author" tools:keep="@string/kp2aplugin_author">Soraefir</string> <string name="light">Light</string>
<string name="dark">Dark</string>
<string name="app_name">Keepass Fidelity</string> <string name="key_stats">Statistics</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>

View File

@@ -1,11 +0,0 @@
<?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>

View File

@@ -1,5 +1,13 @@
// 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.kotlin_version = '1.8.20'
// ext.android_core_version = '1.10.1'
// ext.android_appcompat_version = '1.6.1'
// ext.android_material_version = '1.9.0'
ext.android_test_version = '1.5.2'
}
plugins { plugins {
id 'com.android.application' version '8.13.0' apply false id 'com.android.application' version '8.13.0' apply false
id 'com.android.library' version '8.13.0' apply false id 'com.android.library' version '8.13.0' apply false

1
external/KeePassDX vendored Submodule

Submodule external/KeePassDX added at a7d0467127

View File

@@ -14,6 +14,11 @@ 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'