Integrate Keepass2android functionality with standalone

This commit is contained in:
2026-09-16 23:04:06 +02:00
parent d05b34573e
commit 5a76638f27
14 changed files with 990 additions and 322 deletions
@@ -1,11 +1,19 @@
package net.helcel.fidelity.activity package net.helcel.fidelity.activity
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@@ -15,20 +23,32 @@ import net.helcel.fidelity.activity.fragment.CreateEntryScreen
import net.helcel.fidelity.activity.fragment.FileScanner import net.helcel.fidelity.activity.fragment.FileScanner
import net.helcel.fidelity.activity.fragment.InitialScreen import net.helcel.fidelity.activity.fragment.InitialScreen
import net.helcel.fidelity.activity.fragment.LauncherScreen import net.helcel.fidelity.activity.fragment.LauncherScreen
import net.helcel.fidelity.activity.fragment.ModeSelectScreen
import net.helcel.fidelity.activity.fragment.ScannerScreen import net.helcel.fidelity.activity.fragment.ScannerScreen
import net.helcel.fidelity.activity.fragment.ViewEntryScreen import net.helcel.fidelity.activity.fragment.ViewEntryScreen
import net.helcel.fidelity.tools.FidelityRepository.entries import net.helcel.fidelity.tools.AppMode
import net.helcel.fidelity.tools.AppModeStore
import net.helcel.fidelity.tools.FidelityEntry
import net.helcel.fidelity.tools.FidelityRepository.cacheEntry
import net.helcel.fidelity.tools.FidelityRepository.findEntry
import net.helcel.fidelity.tools.FidelityRepository.loadEntries import net.helcel.fidelity.tools.FidelityRepository.loadEntries
import net.helcel.fidelity.tools.FidelityRepository.transientEntry
import net.helcel.fidelity.tools.KeePassStore.hasCredentials import net.helcel.fidelity.tools.KeePassStore.hasCredentials
import net.helcel.fidelity.tools.Kp2a
class MainActivity : FragmentActivity() { class MainActivity : FragmentActivity() {
// Entry pushed by Keepass2Android when the user opens this app from an entry.
private val incomingEntry = mutableStateOf<FidelityEntry?>(null)
@SuppressLint("SourceLockedOrientationActivity") @SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
actionBar?.hide() actionBar?.hide()
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
AppModeStore.load(this)
loadEntries(this.baseContext) loadEntries(this.baseContext)
incomingEntry.value = Kp2a.entryFromIntent(intent)
setContent { setContent {
SysTheme { SysTheme {
@@ -39,24 +59,49 @@ class MainActivity : FragmentActivity() {
if (!navController.popBackStack()) finish() if (!navController.popBackStack()) finish()
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if(!hasCredentials(context)) navController.navigate("init") when (AppModeStore.mode.value) {
null -> navController.navigate("mode")
AppMode.STANDALONE ->
if (!hasCredentials(context)) navController.navigate("init")
AppMode.KP2A -> {}
}
} }
NavHost(navController = navController, startDestination = "launcher") { LaunchedEffect(incomingEntry.value) {
composable("exit") { finish() } val entry = incomingEntry.value ?: return@LaunchedEffect
composable("launcher") { LauncherScreen(navController) } incomingEntry.value = null
composable("init"){ InitialScreen (navController)} if (AppModeStore.isKp2a) cacheEntry(context, entry)
composable("scanCam") { ScannerScreen(navController) } else transientEntry.value = entry
composable("scanFile") { FileScanner(navController) } navController.navigate("view/${entry.uid}")
composable("edit"){ CreateEntryScreen(navController) } }
composable("view/{entryId}") { e -> // The app draws edge-to-edge (targetSdk >= 35): keep every screen clear of the
val entry = entries.find { // status and navigation bars, and paint the bars with the theme background.
it.uid == (e.arguments?.getString("entryId") ?: "") Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.systemBarsPadding()
) {
NavHost(navController = navController, startDestination = "launcher") {
composable("exit") { finish() }
composable("mode") { ModeSelectScreen(navController) }
composable("launcher") { LauncherScreen(navController) }
composable("init") { InitialScreen(navController) }
composable("scanCam") { ScannerScreen(navController) }
composable("scanFile") { FileScanner(navController) }
composable("edit") { CreateEntryScreen(navController) }
composable("view/{entryId}") { e ->
val entry = findEntry(e.arguments?.getString("entryId"))
if (entry == null) return@composable navController.navigate("launcher")
ViewEntryScreen(navController, entry)
} }
if (entry == null) return@composable navController.navigate("launcher")
ViewEntryScreen(navController,entry)
} }
} }
} }
} }
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
Kp2a.entryFromIntent(intent)?.let { incomingEntry.value = it }
}
} }
@@ -1,5 +1,6 @@
package net.helcel.fidelity.activity.fragment package net.helcel.fidelity.activity.fragment
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -13,8 +14,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button import androidx.compose.material.Button
import androidx.compose.material.Checkbox import androidx.compose.material.Checkbox
import androidx.compose.material.CheckboxDefaults import androidx.compose.material.CheckboxDefaults
@@ -31,6 +35,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Camera import androidx.compose.material.icons.filled.Camera
import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -47,21 +52,27 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.google.zxing.FormatException import com.google.zxing.FormatException
import com.kunzisoft.keepass.database.element.Entry import com.kunzisoft.keepass.database.element.Group
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.helcel.fidelity.R import net.helcel.fidelity.R
import net.helcel.fidelity.activity.ToastHelper import net.helcel.fidelity.activity.ToastHelper
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.ensureUnlocked
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onCameraScan import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onCameraScan
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onFileScan import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onFileScan
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSaveKp2a
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSaveStandalone
import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSubmit import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSubmit
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onSave import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onSave
import net.helcel.fidelity.tools.AppModeStore
import net.helcel.fidelity.tools.BarcodeGenerator.generateBarcode import net.helcel.fidelity.tools.BarcodeGenerator.generateBarcode
import net.helcel.fidelity.tools.FidelityEntry import net.helcel.fidelity.tools.FidelityEntry
import net.helcel.fidelity.tools.FidelityRepository
import net.helcel.fidelity.tools.FidelityRepository.activeEntry import net.helcel.fidelity.tools.FidelityRepository.activeEntry
import net.helcel.fidelity.tools.FidelityRepository.addEntry import net.helcel.fidelity.tools.KeepassDatabase
import net.helcel.fidelity.tools.KeepassDatabase.addEntry
import net.helcel.fidelity.tools.FidelityRepository.cacheEntry
import net.helcel.fidelity.tools.Kp2a
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -79,6 +90,22 @@ fun CreateEntryScreen(navController: NavHostController?) {
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
val ctx = LocalContext.current val ctx = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val kp2a = AppModeStore.isKp2a
// Standalone only: where a new card is created (root unless changed). Existing cards
// (uid set) stay where they are.
var group by remember { mutableStateOf(KeepassDatabase.getRoot()) }
val showGroup = !kp2a && entry.uid == null
fun pickGroup() {
isLoading = true
scope.launch {
if (ensureUnlocked(ctx, navController!!)) {
group = group ?: KeepassDatabase.getRoot()
showDialog = true
}
isLoading = false
}
}
LaunchedEffect(entry) { LaunchedEffect(entry) {
isValidBarcode = false isValidBarcode = false
@@ -103,17 +130,10 @@ fun CreateEntryScreen(navController: NavHostController?) {
} }
if (showDialog) { if (showDialog) {
TreeSelectorDialog( TreeSelectorDialog(groupsOnly = true) {
onDismiss = { showDialog = false
showDialog = false if (it is Group) group = it
if(it!=null){ }
entry = entry.copy(uid = it.nodeId?.id.toString())
if(it is Entry){
entry = entry.copy(title = it.title)
}
}
}
)
} }
val formats = stringArrayResource(R.array.format_array) val formats = stringArrayResource(R.array.format_array)
@@ -121,16 +141,18 @@ fun CreateEntryScreen(navController: NavHostController?) {
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colors.background) .background(MaterialTheme.colors.background)
.imePadding(),
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.padding(16.dp, 32.dp), .verticalScroll(rememberScrollState())
.padding(16.dp, 32.dp)
.padding(bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) )
{ {
OutlinedTextField( OutlinedTextField(
value = entry.title, value = entry.title,
enabled = entry.uid!=null,
onValueChange = { onValueChange = {
entry = entry.copy(title = it) entry = entry.copy(title = it)
errorTitle = "" errorTitle = ""
@@ -140,8 +162,7 @@ fun CreateEntryScreen(navController: NavHostController?) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
colors = TextFieldDefaults.textFieldColors( colors = TextFieldDefaults.textFieldColors(
textColor = if(entry.uid!=null)MaterialTheme.colors.onBackground textColor = MaterialTheme.colors.onBackground
else MaterialTheme.colors.secondary
), ),
) )
if (errorTitle.isNotEmpty()) { if (errorTitle.isNotEmpty()) {
@@ -201,6 +222,23 @@ fun CreateEntryScreen(navController: NavHostController?) {
Icon(Icons.Default.FileOpen, contentDescription = null) Icon(Icons.Default.FileOpen, contentDescription = null)
} }
} }
if (showGroup) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text("Group: ", color = MaterialTheme.colors.onBackground)
Text(
group?.title ?: "(database root)",
color = MaterialTheme.colors.onBackground,
modifier = Modifier.weight(1f),
maxLines = 1,
)
Button(onClick = { pickGroup() }) {
Icon(Icons.Default.FolderOpen, contentDescription = "Choose group")
}
}
}
if (barcodeBitmap != null) { if (barcodeBitmap != null) {
Image( Image(
bitmap = barcodeBitmap!!.asImageBitmap(), bitmap = barcodeBitmap!!.asImageBitmap(),
@@ -229,41 +267,22 @@ fun CreateEntryScreen(navController: NavHostController?) {
errorCode = c errorCode = c
errorFormat = f errorFormat = f
}, },
isValidBarcode isValidBarcode,
) { ) {
if (FidelityRepository.getRoot() == null) { if (kp2a) {
onSaveKp2a(ctx, navController!!, entry)
} else {
isLoading = true isLoading = true
scope.launch { scope.launch {
onRefresh(ctx, navController!!) onSaveStandalone(ctx, navController!!, entry, group)
isLoading = false 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()), enabled = isValidBarcode && entry.title.isNotEmpty(),
) { ) {
Text(if(entry.uid==null)"Select Entry" else "Save", style = MaterialTheme.typography.h6) Text("Save", style = MaterialTheme.typography.h6)
} }
} }
@@ -346,7 +365,7 @@ private fun onSubmitIfValid(
var tErr = "" var tErr = ""
var cErr = "" var cErr = ""
var fErr = "" var fErr = ""
if (entry.uid!=null && entry.title.isBlank()) tErr = "Title cannot be empty" if (entry.title.isBlank()) tErr = "Title cannot be empty"
if (entry.code.isBlank()) cErr = "Code cannot be empty" if (entry.code.isBlank()) cErr = "Code cannot be empty"
if (entry.format.isBlank()) fErr = "Format cannot be empty" if (entry.format.isBlank()) fErr = "Format cannot be empty"
@@ -358,6 +377,38 @@ private fun onSubmitIfValid(
} }
object CreateEntryEventHandler { object CreateEntryEventHandler {
/** Standalone: the database must be open before groups can be browsed or cards written. */
suspend fun ensureUnlocked(context: Context, navController: NavHostController): Boolean =
KeepassDatabase.getRoot() != null || onRefresh(context, navController)
/** Standalone: updates the existing entry, or creates one in [group] (the root when null). */
suspend fun onSaveStandalone(
context: Context,
navController: NavHostController,
entry: FidelityEntry,
group: Group?,
) {
if (!ensureUnlocked(context, navController)) return
val root = KeepassDatabase.getRoot() ?: return
val target =
if (entry.uid != null) entry
else entry.copy(uid = (group ?: root).nodeId.id.toString())
addEntry(context, target)
if (onSave(context, navController)) onSubmit(navController)
}
/**
* KP2A creates the entry in its own task and never reports back, so the card is cached
* right away; a later fetch replaces the local uid with KP2A's.
*/
fun onSaveKp2a(context: Context, navController: NavHostController, entry: FidelityEntry) {
val kpEntry = entry.copy(uid = Kp2a.localUid(entry.title))
if (Kp2a.launchAdd(context, kpEntry)) {
cacheEntry(context, kpEntry)
onSubmit(navController)
}
}
fun onSubmit(navController: NavHostController){ fun onSubmit(navController: NavHostController){
navController.popBackStack() navController.popBackStack()
activeEntry.value = activeEntry.value.copy( activeEntry.value = activeEntry.value.copy(
@@ -0,0 +1,152 @@
package net.helcel.fidelity.activity.fragment
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.HideSource
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onEdit
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onHide
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onPin
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRemove
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onView
import net.helcel.fidelity.tools.AppModeStore
import net.helcel.fidelity.tools.FidelityEntry
/** One card in the launcher grid: tap to view, long-press for the actions menu. */
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun FidelityRow(
navController: NavHostController,
e: FidelityEntry
) {
var expanded by remember { mutableStateOf(false) }
val context = LocalContext.current
Box(modifier = Modifier.fillMaxWidth()) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp)
.combinedClickable(
onClick = { onView(navController, e) },
onLongClick = { expanded = true },
),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colors.primary,
contentColor = MaterialTheme.colors.background
),
) {
Box(modifier = Modifier.fillMaxSize().padding(2.dp)) {
Row(modifier = Modifier.padding(14.dp)) {
Text(
text = e.title,
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onPrimary
)
}
Row(modifier = Modifier.align(Alignment.TopEnd)) {
if (e.hidden)
Icon(
Icons.Default.HideSource, contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colors.onPrimary
)
if (e.hidden && e.pinned)
Spacer(modifier = Modifier.width(8.dp))
if (e.pinned)
Icon(
Icons.Default.PushPin, contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colors.onPrimary
)
}
}
}
DropdownMenu(
modifier = Modifier,
expanded = expanded,
onDismissRequest = { expanded = false }
) {
// KP2A entries can only be edited in KP2A itself; offer to drop the cached copy.
if (AppModeStore.isKp2a)
DropdownMenuItem(onClick = {
expanded = false
onRemove(context, e)
}) {
Icon(
Icons.Default.Delete,
contentDescription = "remove",
)
Spacer(modifier= Modifier.width(8.dp))
Text("Remove")
}
else
DropdownMenuItem(onClick = {
expanded = false
onEdit(navController, e)
}) {
Icon(
Icons.Default.Edit,
contentDescription = "edit",
)
Spacer(modifier= Modifier.width(8.dp))
Text("Edit")
}
DropdownMenuItem(onClick = {
expanded = false
onPin(e)
}) {
Icon(
Icons.Default.PushPin,
contentDescription = "pin",
)
Spacer(modifier= Modifier.width(8.dp))
if(e.pinned) Text("Unpin")
else Text("Pin")
}
DropdownMenuItem(onClick = {
expanded = false
onHide(e)
}) {
Icon(
Icons.Default.HideSource,
contentDescription = "hide",
)
Spacer(modifier= Modifier.width(8.dp))
if(e.hidden) Text("Unhide")
else Text("Hide")
}
}
}
}
@@ -1,10 +1,12 @@
package net.helcel.fidelity.activity.fragment package net.helcel.fidelity.activity.fragment
import android.app.Activity
import android.content.Context import android.content.Context
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -19,25 +21,20 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FloatingActionButton import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TextFieldDefaults import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.HideSource import androidx.compose.material.icons.filled.HideSource
import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -56,28 +53,23 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.fidelity.activity.ToastHelper import net.helcel.fidelity.activity.ToastHelper
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.isSearchVisible import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.isSearchVisible
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onAdd import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onAdd
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onEdit
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onHide
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onPin
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onQuery import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onQuery
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onView import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onView
import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.searchQuery import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.searchQuery
import net.helcel.fidelity.tools.CredentialResult import net.helcel.fidelity.tools.AppModeStore
import net.helcel.fidelity.tools.FidelityEntry import net.helcel.fidelity.tools.FidelityEntry
import net.helcel.fidelity.tools.FidelityRepository.activeEntry import net.helcel.fidelity.tools.FidelityRepository.activeEntry
import net.helcel.fidelity.tools.FidelityRepository.end import net.helcel.fidelity.tools.FidelityRepository.cacheEntry
import net.helcel.fidelity.tools.FidelityRepository.entries import net.helcel.fidelity.tools.FidelityRepository.entries
import net.helcel.fidelity.tools.FidelityRepository.genCredentials import net.helcel.fidelity.tools.KeepassDatabase
import net.helcel.fidelity.tools.FidelityRepository.importDB import net.helcel.fidelity.tools.FidelityRepository.loadEntries
import net.helcel.fidelity.tools.FidelityRepository.start import net.helcel.fidelity.tools.FidelityRepository.removeEntry
import net.helcel.fidelity.tools.KeePassStore.loadCredentials import net.helcel.fidelity.tools.Kp2a
@Preview @Preview
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -91,6 +83,20 @@ fun LauncherScreen(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val kp2a = AppModeStore.isKp2a
val kp2aQueryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val entry = Kp2a.entryFromIntent(result.data)
if (entry == null) {
ToastHelper.show(context, "Entry has no fidelity code")
return@rememberLauncherForActivityResult
}
cacheEntry(context, entry)
onView(navController, entry)
}
BackHandler(enabled = isSearchVisible) { BackHandler(enabled = isSearchVisible) {
onQuery() onQuery()
@@ -118,7 +124,9 @@ fun LauncherScreen(
onRefresh = { onRefresh = {
isRefreshingState = true isRefreshingState = true
scope.launch { scope.launch {
onRefresh(context, navController) // KP2A owns the database: nothing to sync, just reload the cache.
if (kp2a) loadEntries(context)
else onRefresh(context, navController)
isRefreshingState = false isRefreshingState = false
} }
}, },
@@ -126,6 +134,45 @@ fun LauncherScreen(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Top bar: hidden-cards toggle on the left, storage mode on the right.
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val hiddenTint =
if (showHidden) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary
TextButton(onClick = { showHidden = !showHidden }) {
Icon(
Icons.Default.HideSource,
contentDescription = "Show Hidden",
modifier = Modifier.size(16.dp),
tint = hiddenTint
)
Spacer(modifier = Modifier.width(4.dp))
Text(
if (showHidden) "Showing hidden" else "Show hidden",
style = MaterialTheme.typography.caption,
color = hiddenTint
)
}
TextButton(onClick = { navController.navigate("mode") }) {
Icon(
Icons.Default.SwapHoriz,
contentDescription = "Switch mode",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colors.secondary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
if (kp2a) "Keepass2Android" else "Standalone",
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.secondary
)
}
}
if (isSearchVisible) { if (isSearchVisible) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.requestFocus() focusRequester.requestFocus()
@@ -187,18 +234,20 @@ fun LauncherScreen(
) { ) {
Icon(Icons.Default.Add, contentDescription = "Add") Icon(Icons.Default.Add, contentDescription = "Add")
} }
FloatingActionButton( if (kp2a)
onClick = { FloatingActionButton(
showHidden=!showHidden onClick = { Kp2a.launchQuery(context, kp2aQueryLauncher) },
}, modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomEnd)
.padding(16.dp).size(24.dp), .padding(end = 16.dp, bottom = 88.dp),
backgroundColor = if(showHidden) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary, backgroundColor = MaterialTheme.colors.secondary,
) { ) {
Icon(Icons.Default.HideSource, Icon(
tint= if(showHidden) MaterialTheme.colors.background else MaterialTheme.colors.onSecondary, Icons.Default.Key,
contentDescription = "Show Hidden") tint = MaterialTheme.colors.onSecondary,
} contentDescription = "Fetch from Keepass2Android"
)
}
} }
if (isRefreshingState) if (isRefreshingState)
@@ -216,105 +265,9 @@ fun LauncherScreen(
} }
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun FidelityRow(
navController: NavHostController,
e: FidelityEntry
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp)
.combinedClickable(
onClick = { onView(navController, e) },
onLongClick = { expanded = true },
),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colors.primary,
contentColor = MaterialTheme.colors.background
),
) {
Box(modifier = Modifier.fillMaxSize().padding(2.dp)) {
Row(modifier = Modifier.padding(14.dp)) {
Text(
text = e.title,
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onPrimary
)
}
Row(modifier = Modifier.align(Alignment.TopEnd)) {
if (e.hidden)
Icon(
Icons.Default.HideSource, contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colors.onPrimary
)
if (e.hidden && e.pinned)
Spacer(modifier = Modifier.width(8.dp))
if (e.pinned)
Icon(
Icons.Default.PushPin, contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colors.onPrimary
)
}
}
}
DropdownMenu(
modifier = Modifier,
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(onClick = {
expanded = false
onEdit(navController, e)
}) {
Icon(
Icons.Default.Edit,
contentDescription = "edit",
)
Spacer(modifier= Modifier.width(8.dp))
Text("Edit")
}
DropdownMenuItem(onClick = {
expanded = false
onPin(e)
}) {
Icon(
Icons.Default.PushPin,
contentDescription = "pin",
)
Spacer(modifier= Modifier.width(8.dp))
if(e.pinned) Text("Unpin")
else Text("Pin")
}
DropdownMenuItem(onClick = {
expanded = false
onHide(e)
}) {
Icon(
Icons.Default.HideSource,
contentDescription = "hide",
)
Spacer(modifier= Modifier.width(8.dp))
if(e.hidden) Text("Unhide")
else Text("Hide")
}
}
}
}
object LauncherEventHandlers { object LauncherEventHandlers {
var isSearchVisible by mutableStateOf(false) var isSearchVisible by mutableStateOf(false)
var searchQuery by mutableStateOf("") var searchQuery by mutableStateOf("")
var CRED: CredentialResult.Success? = null
fun onAdd(navController: NavHostController) { fun onAdd(navController: NavHostController) {
navController.navigate("edit") navController.navigate("edit")
@@ -325,48 +278,22 @@ object LauncherEventHandlers {
if (!isSearchVisible) searchQuery = "" if (!isSearchVisible) searchQuery = ""
} }
suspend fun onSave(context: Context, navController: NavHostController){ /** Standalone: writes the database back. Missing credentials send the user to setup. */
try { suspend fun onSave(context: Context, navController: NavHostController): Boolean {
if (CRED == null) { if (!KeepassDatabase.ensureCredentials(context)) {
when (val res = loadCredentials(context)) {
CredentialResult.AuthFailed, CredentialResult.NoData -> ToastHelper.show(context, "Unable to Load Credentials")
is CredentialResult.Success -> CRED = res
}
}
CRED!!
val cred = withContext(Dispatchers.IO) {
genCredentials(context, CRED!!)
}
if (withContext(Dispatchers.IO) {
end(context, CRED!!.db, cred)
})
throw Exception("Error in saving")
} catch (e: Exception) {
println(e.toString())
navController.navigate("init") navController.navigate("init")
return false
} }
return KeepassDatabase.save(context)
} }
suspend fun onRefresh(context: Context, navController: NavHostController) { /** Standalone: (re)opens the database and imports its cards. Missing credentials send the user to setup. */
try { suspend fun onRefresh(context: Context, navController: NavHostController): Boolean {
if (CRED == null) { if (!KeepassDatabase.ensureCredentials(context)) {
when (val res = loadCredentials(context)) {
CredentialResult.AuthFailed, CredentialResult.NoData -> ToastHelper.show(context, "Unable to Load Credentials")
is CredentialResult.Success -> CRED = res
}
}
CRED!!
val cred = withContext(Dispatchers.IO) {
genCredentials(context, CRED!!)
}
if (withContext(Dispatchers.IO) {
start(context, CRED!!.db, cred)
})
importDB(context)
} catch (e: Exception) {
println(e.toString())
navController.navigate("init") navController.navigate("init")
return false
} }
return KeepassDatabase.unlock(context)
} }
fun onView(navController: NavHostController, entry: FidelityEntry) { fun onView(navController: NavHostController, entry: FidelityEntry) {
@@ -389,6 +316,10 @@ object LauncherEventHandlers {
entries[index] = entry.copy(hidden = !entry.hidden) entries[index] = entry.copy(hidden = !entry.hidden)
} }
fun onRemove(context: Context, entry: FidelityEntry) {
removeEntry(context, entry)
}
fun onEdit(navController: NavHostController, entry: FidelityEntry){ fun onEdit(navController: NavHostController, entry: FidelityEntry){
activeEntry.value = entry activeEntry.value = entry
navController.navigate("edit") navController.navigate("edit")
@@ -0,0 +1,154 @@
package net.helcel.fidelity.activity.fragment
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import kotlinx.coroutines.launch
import net.helcel.fidelity.tools.AppMode
import net.helcel.fidelity.tools.AppModeStore
import net.helcel.fidelity.tools.FidelityRepository.loadEntries
import net.helcel.fidelity.tools.KeePassStore.hasCredentials
import net.helcel.fidelity.tools.Kp2a
@Preview
@Composable
fun ModeSelectScreen(navController: NavHostController?) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val current = AppModeStore.mode.value
val kp2aInstalled = remember { Kp2a.isAvailable(context) }
BackHandler {
// Nothing to go back to before the first choice has been made.
if (current == null) navController!!.navigate("exit")
else navController!!.popBackStack()
}
fun select(mode: AppMode) {
if (mode != current) {
AppModeStore.set(context, mode)
loadEntries(context)
}
navController!!.popBackStack("launcher", inclusive = false)
scope.launch {
if (mode == AppMode.STANDALONE && !hasCredentials(context))
navController.navigate("init")
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center
) {
Text(
"Storage Mode",
style = MaterialTheme.typography.h5,
color = MaterialTheme.colors.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Where should your fidelity cards be stored?",
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground
)
Spacer(modifier = Modifier.height(16.dp))
ModeCard(
icon = Icons.Default.FolderOpen,
title = "Standalone",
description = "Open a KeePass database (.kdbx) directly. " +
"Entries are read and written by this app.",
selected = current == AppMode.STANDALONE,
) { select(AppMode.STANDALONE) }
Spacer(modifier = Modifier.height(12.dp))
ModeCard(
icon = Icons.Default.Extension,
title = "Keepass2Android plugin",
description = "Use the Keepass2Android app as the database. " +
"Entries are queried from and created in Keepass2Android.",
selected = current == AppMode.KP2A,
) { select(AppMode.KP2A) }
if (!kp2aInstalled) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"Keepass2Android does not seem to be installed.",
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.error
)
}
}
}
}
@Composable
private fun ModeCard(
icon: ImageVector,
title: String,
description: String,
selected: Boolean,
onClick: () -> Unit,
) {
val container = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.surface
val content = if (selected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = container,
contentColor = content
),
border = if (selected) null else CardDefaults.outlinedCardBorder(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp), tint = content)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(title, style = MaterialTheme.typography.h6, color = content)
Spacer(modifier = Modifier.height(4.dp))
Text(description, style = MaterialTheme.typography.body2, color = content)
}
}
}
}
@@ -19,7 +19,9 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Undo import androidx.compose.material.icons.automirrored.filled.Undo
import androidx.compose.material.icons.filled.CreditCard
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Folder
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -32,11 +34,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import com.kunzisoft.keepass.database.element.Group import com.kunzisoft.keepass.database.element.Group
import com.kunzisoft.keepass.database.element.node.Node import com.kunzisoft.keepass.database.element.node.Node
import net.helcel.fidelity.tools.FidelityRepository import net.helcel.fidelity.tools.KeepassDatabase
/**
* Browses the loaded database. With [groupsOnly] entries are hidden, so the result is the group
* a new card is created in; otherwise an existing entry can be picked to attach the card to.
*/
@Preview @Preview
@Composable @Composable
fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) { fun TreeSelectorDialog(groupsOnly: Boolean = false, onDismiss: (Node?) -> Unit = {}) {
Dialog( Dialog(
onDismissRequest = {onDismiss(null)}, onDismissRequest = {onDismiss(null)},
content = { content = {
@@ -46,8 +52,8 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
RoundedCornerShape(8.dp) RoundedCornerShape(8.dp)
) )
) { ) {
var currentRoot by remember { mutableStateOf(FidelityRepository.getRoot()) } var currentRoot by remember { mutableStateOf(KeepassDatabase.getRoot()) }
var selection by remember { mutableStateOf<Node?>(FidelityRepository.getRoot()) } var selection by remember { mutableStateOf<Node?>(KeepassDatabase.getRoot()) }
Column( Column(
modifier = Modifier.fillMaxWidth().padding(8.dp) modifier = Modifier.fillMaxWidth().padding(8.dp)
@@ -76,32 +82,29 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
LazyColumn(modifier = Modifier.fillMaxHeight(0.75f)) { LazyColumn(modifier = Modifier.fillMaxHeight(0.75f)) {
items(currentRoot?.getChildGroups() ?: emptyList()) { entry -> items(currentRoot?.getChildGroups() ?: emptyList()) { entry ->
val isSel = (entry.nodeId == selection?.nodeId) val isSel = (entry.nodeId == selection?.nodeId)
val hasChildren = entry.getChildGroups().isNotEmpty() ||
(!groupsOnly && entry.getChildEntries().isNotEmpty())
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(color = if (isSel) MaterialTheme.colors.primary else MaterialTheme.colors.background) .background(color = if (isSel) MaterialTheme.colors.primary else MaterialTheme.colors.background)
.clickable { .clickable {
if (entry.getChildEntries().isNotEmpty()) { selection = entry
currentRoot = entry if (hasChildren) currentRoot = entry
selection = entry
} else if (entry.getChildGroups().isNotEmpty()) {
currentRoot = entry
selection = entry
} else {
selection = entry
}
} }
.padding(8.dp) .padding(8.dp)
) { ) {
if (entry.getChildEntries().isNotEmpty() || entry.getChildGroups() Icon(
.isNotEmpty() imageVector = Icons.Default.Folder,
) { contentDescription = "group",
tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground
)
if (hasChildren)
Icon( Icon(
imageVector = Icons.Default.ExpandMore, imageVector = Icons.Default.ExpandMore,
contentDescription = null, contentDescription = null,
tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground
) )
}
Text( Text(
entry.title, entry.title,
modifier = Modifier.padding(start = 8.dp), modifier = Modifier.padding(start = 8.dp),
@@ -109,7 +112,7 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
) )
} }
} }
items(currentRoot?.getChildEntries() ?: emptyList()) { entry -> items(if (groupsOnly) emptyList() else currentRoot?.getChildEntries() ?: emptyList()) { entry ->
val isSel = (entry.nodeId == selection?.nodeId) val isSel = (entry.nodeId == selection?.nodeId)
Row( Row(
modifier = Modifier modifier = Modifier
@@ -120,6 +123,11 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) {
} }
.padding(8.dp) .padding(8.dp)
) { ) {
Icon(
imageVector = Icons.Default.CreditCard,
contentDescription = "entry",
tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground
)
Text( Text(
entry.title, entry.title,
modifier = Modifier.padding(start = 8.dp), modifier = Modifier.padding(start = 8.dp),
@@ -28,6 +28,7 @@ import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TextFieldDefaults import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -52,9 +53,10 @@ import kotlinx.coroutines.withContext
import net.helcel.fidelity.activity.ToastHelper import net.helcel.fidelity.activity.ToastHelper
import net.helcel.fidelity.activity.fragment.SetupEventHandlers.onOpen import net.helcel.fidelity.activity.fragment.SetupEventHandlers.onOpen
import net.helcel.fidelity.tools.CredentialResult import net.helcel.fidelity.tools.CredentialResult
import net.helcel.fidelity.tools.FidelityRepository.genCredentials import net.helcel.fidelity.tools.KeepassDatabase
import net.helcel.fidelity.tools.FidelityRepository.importDB import net.helcel.fidelity.tools.KeepassDatabase.genCredentials
import net.helcel.fidelity.tools.FidelityRepository.start import net.helcel.fidelity.tools.KeepassDatabase.importDB
import net.helcel.fidelity.tools.KeepassDatabase.start
import net.helcel.fidelity.tools.KeePassStore.loadCredentials import net.helcel.fidelity.tools.KeePassStore.loadCredentials
import net.helcel.fidelity.tools.KeePassStore.packCredentials import net.helcel.fidelity.tools.KeePassStore.packCredentials
import net.helcel.fidelity.tools.KeePassStore.saveCredentials import net.helcel.fidelity.tools.KeePassStore.saveCredentials
@@ -223,9 +225,6 @@ fun InitialScreen(
val res = onOpen(context, dbFile!!, password, keyFile) val res = onOpen(context, dbFile!!, password, keyFile)
if(res != null){ if(res != null){
ToastHelper.show(context, "Successful... Importing") ToastHelper.show(context, "Successful... Importing")
withContext(Dispatchers.IO) {
start(context, dbFile!!,genCredentials(context, res))
}
importDB(context) importDB(context)
navController!!.popBackStack() navController!!.popBackStack()
navController.navigate("launcher") navController.navigate("launcher")
@@ -238,6 +237,13 @@ fun InitialScreen(
) { ) {
Text("Continue") Text("Continue")
} }
TextButton(
enabled = !loading,
onClick = { navController!!.navigate("mode") },
modifier = Modifier.fillMaxWidth()
) {
Text("Use Keepass2Android instead", color = MaterialTheme.colors.secondary)
}
} }
Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -254,12 +260,16 @@ fun InitialScreen(
} }
object SetupEventHandlers { object SetupEventHandlers {
/** Opens the file with the given credentials, then stores them for later sessions. */
suspend fun onOpen(context: Context, db: Uri, p: String, key: Uri?): CredentialResult.Success? { suspend fun onOpen(context: Context, db: Uri, p: String, key: Uri?): CredentialResult.Success? {
try { try {
val packCred = packCredentials(db, p, key) val packCred = packCredentials(db, p, key)
withContext(Dispatchers.IO) { val opened = withContext(Dispatchers.IO) {
start(context, db, genCredentials(context, packCred) start(context, db, genCredentials(context, packCred))
) }
if (!opened) {
ToastHelper.show(context, "Unable to open the database: check the password and key file")
return null
} }
val res = withContext(Dispatchers.Main) { val res = withContext(Dispatchers.Main) {
@@ -267,7 +277,10 @@ object SetupEventHandlers {
} }
return when (res) { return when (res) {
CredentialResult.AuthFailed, CredentialResult.NoData -> null CredentialResult.AuthFailed, CredentialResult.NoData -> null
is CredentialResult.Success -> res is CredentialResult.Success -> {
KeepassDatabase.credentials = res
res
}
} }
} catch (e: Exception) { } catch (e: Exception) {
ToastHelper.show(context, e.message.toString()) ToastHelper.show(context, e.message.toString())
@@ -20,6 +20,7 @@ import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -62,11 +63,19 @@ fun ViewEntryScreen(
activity?.window?.attributes = activity.window?.attributes?.apply { activity?.window?.attributes = activity.window?.attributes?.apply {
screenBrightness = if (isFull) 1f else BRIGHTNESS_OVERRIDE_NONE screenBrightness = if (isFull) 1f else BRIGHTNESS_OVERRIDE_NONE
} }
}
// Generate once per entry (not on every recomposition) and surface zxing's reason,
// e.g. "Contents do not pass checksum" for a mistyped EAN.
LaunchedEffect(entry) {
try { try {
bitmap = generateBarcode(entry.code, entry.format, 1024) bitmap = generateBarcode(entry.code, entry.format, 1024)
} catch (_: Exception) { } catch (e: Exception) {
bitmap = null bitmap = null
Toast.makeText(context, "Invalid barcode format", Toast.LENGTH_SHORT).show() Toast.makeText(
context,
"Invalid barcode: " + (e.message ?: "unsupported format"),
Toast.LENGTH_LONG
).show()
} }
} }
BackHandler { BackHandler {
@@ -0,0 +1,39 @@
package net.helcel.fidelity.tools
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.edit
const val FIDELITY_PREFS = "fidelity_prefs"
enum class AppMode {
/** Opens a KDBX file directly through the bundled KeePassDX engine. */
STANDALONE,
/** Delegates storage to the Keepass2Android app through its plugin interface. */
KP2A,
}
object AppModeStore {
private const val KEY_MODE = "app_mode"
/** Null until the user has picked a mode on the selection screen. */
val mode = mutableStateOf<AppMode?>(null)
val isKp2a: Boolean
get() = mode.value == AppMode.KP2A
fun load(context: Context): AppMode? {
val prefs = context.getSharedPreferences(FIDELITY_PREFS, Context.MODE_PRIVATE)
mode.value = prefs.getString(KEY_MODE, null)?.let { name ->
AppMode.entries.firstOrNull { it.name == name }
}
return mode.value
}
fun set(context: Context, newMode: AppMode) {
context.getSharedPreferences(FIDELITY_PREFS, Context.MODE_PRIVATE)
.edit { putString(KEY_MODE, newMode.name) }
mode.value = newMode
}
}
@@ -2,9 +2,12 @@ package net.helcel.fidelity.tools
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties import android.security.keystore.KeyProperties
import android.util.Base64 import android.util.Base64
import android.widget.Toast
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import javax.crypto.Cipher import javax.crypto.Cipher
@@ -16,6 +19,7 @@ import com.kunzisoft.keepass.utils.parseUri
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import net.helcel.fidelity.activity.ToastHelper
import java.security.KeyStore import java.security.KeyStore
import javax.crypto.KeyGenerator import javax.crypto.KeyGenerator
import javax.crypto.SecretKey import javax.crypto.SecretKey
@@ -36,6 +40,17 @@ sealed class CredentialResult {
private const val KEY_ALIAS = "keepass_bio_key" private const val KEY_ALIAS = "keepass_bio_key"
// Android 11+ can unlock a keystore key with the device PIN/pattern/password as well, which
// keeps standalone mode usable on devices without (enrolled) biometrics. Below that, a
// CryptoObject can only be released by a biometric.
private val deviceCredentialSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
private val allowedAuthenticators: Int
get() = if (deviceCredentialSupported)
BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
else
BiometricManager.Authenticators.BIOMETRIC_STRONG
fun getOrCreateBiometricKey(): SecretKey { fun getOrCreateBiometricKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey } keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
@@ -47,7 +62,13 @@ fun getOrCreateBiometricKey(): SecretKey {
).apply { ).apply {
setBlockModes(KeyProperties.BLOCK_MODE_GCM) setBlockModes(KeyProperties.BLOCK_MODE_GCM)
setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
setUserAuthenticationRequired(true) if (deviceCredentialSupported)
setUserAuthenticationParameters(
0,
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
)
else
setUserAuthenticationRequired(true)
setInvalidatedByBiometricEnrollment(true) setInvalidatedByBiometricEnrollment(true)
}.build() }.build()
@@ -108,6 +129,17 @@ object KeePassStore {
suspend fun showBiometricPrompt(activity: FragmentActivity, enc: Boolean): Cipher? { suspend fun showBiometricPrompt(activity: FragmentActivity, enc: Boolean): Cipher? {
val prefs = activity.securePrefs.data.first() val prefs = activity.securePrefs.data.first()
return suspendCancellableCoroutine { cont -> return suspendCancellableCoroutine { cont ->
val status = BiometricManager.from(activity).canAuthenticate(allowedAuthenticators)
if (status != BiometricManager.BIOMETRIC_SUCCESS) {
ToastHelper.show(
activity,
if (deviceCredentialSupported) "Set up a screen lock or fingerprint first"
else "Enroll a fingerprint first",
Toast.LENGTH_LONG
)
cont.resume(null) { _, _, _ -> }
return@suspendCancellableCoroutine
}
val executor = ContextCompat.getMainExecutor(activity) val executor = ContextCompat.getMainExecutor(activity)
val biometricPrompt = BiometricPrompt( val biometricPrompt = BiometricPrompt(
activity, activity,
@@ -119,20 +151,22 @@ suspend fun showBiometricPrompt(activity: FragmentActivity, enc: Boolean): Ciphe
override fun onAuthenticationError(code: Int, msg: CharSequence) { override fun onAuthenticationError(code: Int, msg: CharSequence) {
cont.resume(null) { _, _, _ -> } cont.resume(null) { _, _, _ -> }
} }
override fun onAuthenticationFailed() { // onAuthenticationFailed is a retryable miss: the prompt stays open, so the
cont.resume(null) { _, _, _ -> } // coroutine must stay suspended until it either succeeds or errors out.
}
} }
) )
val iv = if(enc) null else prefs[KeePassKeys.IV]?.let { Base64.decode(it, Base64.DEFAULT) } val iv = if(enc) null else prefs[KeePassKeys.IV]?.let { Base64.decode(it, Base64.DEFAULT) }
if (!enc && iv == null) { if (!enc && iv == null) {
cont.resume(null) { _, _, _ -> } cont.resume(null) { _, _, _ -> }
return@suspendCancellableCoroutine
} }
val cipher = getCipherForDecryption(getOrCreateBiometricKey(), iv) val cipher = getCipherForDecryption(getOrCreateBiometricKey(), iv)
val promptInfo = BiometricPrompt.PromptInfo.Builder() val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock KeePass") .setTitle("Unlock KeePass")
.setSubtitle("Authenticate to access your KeePass database") .setSubtitle("Authenticate to access your KeePass database")
.setNegativeButtonText("Cancel") .setAllowedAuthenticators(allowedAuthenticators)
// A negative button is mandatory without, and forbidden with, device credential.
.apply { if (!deviceCredentialSupported) setNegativeButtonText("Cancel") }
.build() .build()
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
@@ -0,0 +1,29 @@
package net.helcel.fidelity.tools
import kotlinx.serialization.Serializable
/** Names of the custom KeePass fields a card is stored in. */
object FidelityKeepassFields {
const val FIDELITYFORMAT = "FidelityFormat"
const val FIDELITYCODE = "FidelityCode"
}
/**
* A loyalty card as shown by the app.
*
* [uid] is the KeePass entry UUID in standalone mode, or the id Keepass2Android reports
* (falling back to a title-derived one) in plugin mode. The last three fields are
* per-device preferences that never reach the database.
*/
@Serializable
data class FidelityEntry(
val uid: String? = null,
val title: String = "",
val code: String = "",
val format: String = "",
val protected: Boolean = false,
val hidden: Boolean = false,
val pinned: Boolean = false,
val lastUse: Int = 0,
)
@@ -0,0 +1,82 @@
package net.helcel.fidelity.tools
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.edit
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
/**
* The on-device list of cards shown by the launcher, persisted in shared preferences.
*
* In standalone mode it mirrors the cards found in the KDBX file (see
* [KeepassDatabase.importDB]); in Keepass2Android mode it is a cache of the entries the
* user has fetched so far.
*/
object FidelityRepository {
val entries = mutableStateListOf<FidelityEntry>()
/** Card being edited on the create screen. */
val activeEntry = mutableStateOf(FidelityEntry())
/** Entry handed over by Keepass2Android for viewing without being cached. */
val transientEntry = mutableStateOf<FidelityEntry?>(null)
// Each mode keeps its own cache: standalone uids are KDBX UUIDs, KP2A ones are not.
private fun entriesKey() = if (AppModeStore.isKp2a) "entries_kp2a" else "entries"
fun saveEntries(context: Context) {
val prefs = context.getSharedPreferences(FIDELITY_PREFS, Context.MODE_PRIVATE)
// In KP2A mode "protected" means the code must never leave the KP2A database.
val persisted = if (AppModeStore.isKp2a) entries.filter { !it.protected } else entries
prefs.edit { putString(entriesKey(), Json.encodeToString(
ListSerializer(FidelityEntry.serializer()),
persisted
)) }
}
fun loadEntries(context: Context) {
val prefs = context.getSharedPreferences(FIDELITY_PREFS, Context.MODE_PRIVATE)
entries.clear()
try {
val json = prefs.getString(entriesKey(), null) ?: return
val list = Json.decodeFromString(
ListSerializer(FidelityEntry.serializer()),
json
)
entries.addAll(list)
}catch(_: Exception){
prefs.edit{ putString(entriesKey(),Json.encodeToString(
ListSerializer(FidelityEntry.serializer()),emptyList()))
}
}
}
/**
* Upserts an entry coming from Keepass2Android into the local cache, keeping the
* per-device flags (pin/hide/last use) of the entry it replaces. Matching also
* falls back to title+code because an optimistically cached entry gets a local uid
* until KP2A returns it with its own.
*/
fun cacheEntry(context: Context, entry: FidelityEntry) {
val idx = entries.indexOfFirst {
it.uid == entry.uid || (it.title == entry.title && it.code == entry.code)
}
if (idx >= 0) {
val old = entries[idx]
entries[idx] = entry.copy(pinned = old.pinned, hidden = old.hidden, lastUse = old.lastUse)
} else {
entries.add(entry)
}
saveEntries(context)
}
fun removeEntry(context: Context, entry: FidelityEntry) {
entries.removeIf { it.uid == entry.uid }
saveEntries(context)
}
fun findEntry(uid: String?): FidelityEntry? =
entries.find { it.uid == uid } ?: transientEntry.value?.takeIf { it.uid == uid }
}
@@ -2,12 +2,6 @@ package net.helcel.fidelity.tools
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import kotlinx.serialization.Serializable
import java.io.ByteArrayInputStream
import kotlinx.serialization.json.Json
import androidx.core.content.edit
import com.kunzisoft.keepass.database.element.Database import com.kunzisoft.keepass.database.element.Database
import com.kunzisoft.keepass.database.element.Field import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.Group import com.kunzisoft.keepass.database.element.Group
@@ -17,39 +11,75 @@ import com.kunzisoft.keepass.database.element.node.NodeIdUUID
import com.kunzisoft.keepass.database.element.security.ProtectedString import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.hardware.HardwareKey import com.kunzisoft.keepass.hardware.HardwareKey
import com.kunzisoft.keepass.utils.getBinaryDir import com.kunzisoft.keepass.utils.getBinaryDir
import kotlinx.serialization.builtins.ListSerializer import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.helcel.fidelity.activity.ToastHelper
import net.helcel.fidelity.tools.FidelityRepository.entries
import net.helcel.fidelity.tools.FidelityRepository.saveEntries
import net.helcel.fidelity.tools.KeePassStore.loadCredentials
import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
object FidelityKeepassFields { /**
const val FIDELITYFORMAT = "FidelityFormat" * Standalone mode: the KDBX file opened through the bundled KeePassDX engine.
const val FIDELITYCODE = "FidelityCode" *
} * [unlock] and [save] are the session-level operations the screens use; the rest is the
* raw file handling underneath them.
@Serializable */
data class FidelityEntry( object KeepassDatabase {
val uid: String? = null,
val title: String = "",
val code: String = "",
val format: String = "",
val protected: Boolean = false,
val hidden: Boolean = false,
val pinned: Boolean = false,
val lastUse: Int = 0,
)
object FidelityRepository {
private var db: Database = Database() private var db: Database = Database()
private var binaryDir: File? = null private var binaryDir: File? = null
val entries = mutableStateListOf<FidelityEntry>()
val activeEntry = mutableStateOf(FidelityEntry())
/** Credentials released by the user for this process, so authentication happens once. */
var credentials: CredentialResult.Success? = null
fun getRoot(): Group? { fun getRoot(): Group? {
return db.rootGroup return db.rootGroup
} }
/** Makes sure [credentials] are available, asking the user to authenticate if needed. */
suspend fun ensureCredentials(context: Context): Boolean {
if (credentials != null) return true
return when (val res = loadCredentials(context)) {
is CredentialResult.Success -> {
credentials = res
true
}
CredentialResult.AuthFailed, CredentialResult.NoData -> {
ToastHelper.show(context, "Unable to Load Credentials")
false
}
}
}
/** Opens the file with [credentials] and imports its cards into [FidelityRepository]. */
suspend fun unlock(context: Context): Boolean {
val cred = credentials ?: return false
val opened = withContext(Dispatchers.IO) {
start(context, cred.db, genCredentials(context, cred))
}
if (!opened) {
ToastHelper.show(context, "Unable to open the database")
return false
}
importDB(context)
return true
}
/** Writes the database back to its file. */
suspend fun save(context: Context): Boolean {
val cred = credentials ?: return false
val saved = try {
withContext(Dispatchers.IO) { end(context, cred.db, genCredentials(context, cred)) }
} catch (e: Exception) {
println(e)
false
}
if (!saved) ToastHelper.show(context, "Unable to save the database")
return saved
}
fun start(ctx: Context, uri: Uri?, c: MasterCredential): Boolean { fun start(ctx: Context, uri: Uri?, c: MasterCredential): Boolean {
if (binaryDir == null) binaryDir = ctx.getBinaryDir() if (binaryDir == null) binaryDir = ctx.getBinaryDir()
if (uri == null) return false if (uri == null) return false
@@ -90,6 +120,7 @@ object FidelityRepository {
) )
} }
/** Replaces the card list with the cards found in the database, keeping per-device flags. */
fun importDB(context: Context) { fun importDB(context: Context) {
val seenID= arrayListOf<String>() val seenID= arrayListOf<String>()
fun importDBRec(group: Group) { fun importDBRec(group: Group) {
@@ -119,8 +150,6 @@ object FidelityRepository {
} else { } else {
entries.add(newEntry) entries.add(newEntry)
} }
} }
group.getChildGroups().forEach { importDBRec(it) } group.getChildGroups().forEach { importDBRec(it) }
} }
@@ -133,36 +162,19 @@ object FidelityRepository {
saveEntries(context) saveEntries(context)
} }
fun saveEntries(context: Context) { /**
val prefs = context.getSharedPreferences("fidelity_prefs", Context.MODE_PRIVATE) * Writes the card into the loaded database. [FidelityEntry.uid] is either the id of an
prefs.edit { putString("entries", Json.encodeToString( * existing entry, which is updated in place, or the id of the group to create it in.
ListSerializer(FidelityEntry.serializer()), */
entries
)) }
}
fun loadEntries(context: Context) {
val prefs = context.getSharedPreferences("fidelity_prefs", Context.MODE_PRIVATE)
try {
val json = prefs.getString("entries", null) ?: return
val list = Json.decodeFromString(
ListSerializer(FidelityEntry.serializer()),
json
)
entries.clear()
entries.addAll(list)
}catch(_: Exception){
prefs.edit{ putString("entries",Json.encodeToString(
ListSerializer(FidelityEntry.serializer()),emptyList()))
}
}
}
fun addEntry(ctx: Context, entry: FidelityEntry) { fun addEntry(ctx: Context, entry: FidelityEntry) {
val dbEntry = db.getEntryById(NodeIdUUID(UUID.fromString(entry.uid))) ?: db.createEntry() val id = NodeIdUUID(UUID.fromString(entry.uid))
val dbParent = db.getGroupById(NodeIdUUID(UUID.fromString(entry.uid))) val existing = db.getEntryById(id)
dbEntry?.apply { val dbEntry = existing ?: db.createEntry() ?: return
dbEntry.apply {
title = entry.title
// Keepass2Android lists entries for this app by URL, so cards written here stay
// reachable from KP2A mode as well.
if (url.isBlank()) url = Kp2a.appUrl(ctx)
putExtraField( putExtraField(
Field( Field(
FidelityKeepassFields.FIDELITYCODE, FidelityKeepassFields.FIDELITYCODE,
@@ -175,11 +187,11 @@ object FidelityRepository {
ProtectedString(true, entry.format.toCharArray()) ProtectedString(true, entry.format.toCharArray())
) )
) )
if(dbParent!=null) title = entry.title
dbParent?.addChildEntry(dbEntry)
} }
if (existing != null) db.updateEntry(dbEntry)
else db.addEntryTo(dbEntry, db.getGroupById(id) ?: db.rootGroup ?: return)
entries.removeIf {it.uid == entry.uid} entries.removeIf {it.uid == entry.uid}
entries.add(entry.copy(uid=dbEntry?.nodeId?.id.toString())) entries.add(entry.copy(uid=dbEntry.nodeId.id.toString()))
saveEntries(ctx) saveEntries(ctx)
} }
} }
@@ -0,0 +1,109 @@
package net.helcel.fidelity.tools
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import net.helcel.fidelity.activity.ToastHelper
import net.helcel.fidelity.pluginSDK.KeepassDef
import net.helcel.fidelity.pluginSDK.Kp2aControl
import net.helcel.fidelity.pluginSDK.Strings
import org.json.JSONArray
import org.json.JSONException
import java.util.UUID
/**
* Bridge between [FidelityEntry] and the Keepass2Android plugin interface.
*
* Entries live in the KP2A database; this app only keeps a local cache of the
* non-protected ones (see [FidelityRepository.cacheEntry]).
*/
object Kp2a {
private const val CODE_FIELD = FidelityKeepassFields.FIDELITYCODE
private const val FORMAT_FIELD = FidelityKeepassFields.FIDELITYFORMAT
// Legacy flag written by pre-1.3 versions: "true" means "do not cache locally".
private const val PROTECT_CODE_FIELD = "FidelityProtectedCode"
private const val NOT_INSTALLED = "Keepass2Android Not Installed"
/** URL that marks a database entry as belonging to this app. */
fun appUrl(context: Context): String = "androidapp://" + context.packageName
fun isAvailable(context: Context): Boolean =
Kp2aControl.getQueryEntryForOwnPackageIntent()
.resolveActivity(context.packageManager) != null
/** Asks KP2A to pick one of the entries whose URL points at this app. */
fun launchQuery(context: Context, launcher: ActivityResultLauncher<Intent>): Boolean {
return try {
launcher.launch(Kp2aControl.getQueryEntryForOwnPackageIntent())
true
} catch (_: ActivityNotFoundException) {
ToastHelper.show(context, NOT_INSTALLED)
false
}
}
/**
* Opens KP2A on a pre-filled "new entry" form. KP2A runs in its own task, so no
* activity result comes back: the caller must cache the entry optimistically.
*/
fun launchAdd(context: Context, entry: FidelityEntry): Boolean {
val fields = HashMap<String, String>()
fields[KeepassDef.TitleField] = entry.title
fields[KeepassDef.UrlField] = appUrl(context)
fields[CODE_FIELD] = entry.code
fields[FORMAT_FIELD] = entry.format
fields[PROTECT_CODE_FIELD] = entry.protected.toString()
val protectedFields = if (entry.protected) arrayListOf(CODE_FIELD) else null
return try {
context.startActivity(Kp2aControl.getAddEntryIntent(fields, protectedFields))
true
} catch (_: ActivityNotFoundException) {
ToastHelper.show(context, NOT_INSTALLED)
false
}
}
/** Stable id for entries KP2A did not tag with its own UUID. */
fun localUid(title: String): String =
UUID.nameUUIDFromBytes("kp2a:$title".toByteArray(Charsets.UTF_8)).toString()
/**
* Extracts a fidelity entry from an intent produced by KP2A (query result, or the
* launch intent when the user opens this app from an entry). Returns null when the
* intent carries no fidelity data.
*/
fun entryFromIntent(intent: Intent?): FidelityEntry? {
if (intent?.hasExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA) != true) return null
val fields = Kp2aControl.getEntryFieldsFromIntent(intent)
val code = fields[CODE_FIELD] ?: return null
val format = fields[FORMAT_FIELD] ?: return null
val title = fields[KeepassDef.TitleField] ?: ""
val protected = fields[PROTECT_CODE_FIELD]?.toBooleanStrictOrNull()
?: protectedFieldsFromIntent(intent).contains(CODE_FIELD)
return FidelityEntry(
uid = intent.getStringExtra(Strings.EXTRA_ENTRY_ID) ?: localUid(title),
title = title,
code = code,
format = format,
protected = protected,
)
}
// KP2A sends the protected field names either as a string list or a JSON array.
private fun protectedFieldsFromIntent(intent: Intent): List<String> {
intent.getStringArrayListExtra(Strings.EXTRA_PROTECTED_FIELDS_LIST)?.let { return it }
val json = intent.getStringExtra(Strings.EXTRA_PROTECTED_FIELDS_LIST) ?: return emptyList()
return try {
val a = JSONArray(json)
List(a.length()) { a.optString(it) }
} catch (_: JSONException) {
emptyList()
}
}
}