diff --git a/app/src/main/java/net/helcel/fidelity/activity/MainActivity.kt b/app/src/main/java/net/helcel/fidelity/activity/MainActivity.kt index c94a66a..feb89c5 100644 --- a/app/src/main/java/net/helcel/fidelity/activity/MainActivity.kt +++ b/app/src/main/java/net/helcel/fidelity/activity/MainActivity.kt @@ -1,11 +1,19 @@ package net.helcel.fidelity.activity import android.annotation.SuppressLint +import android.content.Intent import android.content.pm.ActivityInfo import android.os.Bundle import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material.MaterialTheme import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.FragmentActivity import androidx.navigation.compose.NavHost @@ -15,20 +23,32 @@ import net.helcel.fidelity.activity.fragment.CreateEntryScreen import net.helcel.fidelity.activity.fragment.FileScanner import net.helcel.fidelity.activity.fragment.InitialScreen import net.helcel.fidelity.activity.fragment.LauncherScreen +import net.helcel.fidelity.activity.fragment.ModeSelectScreen import net.helcel.fidelity.activity.fragment.ScannerScreen import net.helcel.fidelity.activity.fragment.ViewEntryScreen -import net.helcel.fidelity.tools.FidelityRepository.entries +import net.helcel.fidelity.tools.AppMode +import net.helcel.fidelity.tools.AppModeStore +import net.helcel.fidelity.tools.FidelityEntry +import net.helcel.fidelity.tools.FidelityRepository.cacheEntry +import net.helcel.fidelity.tools.FidelityRepository.findEntry import net.helcel.fidelity.tools.FidelityRepository.loadEntries +import net.helcel.fidelity.tools.FidelityRepository.transientEntry import net.helcel.fidelity.tools.KeePassStore.hasCredentials +import net.helcel.fidelity.tools.Kp2a class MainActivity : FragmentActivity() { + // Entry pushed by Keepass2Android when the user opens this app from an entry. + private val incomingEntry = mutableStateOf(null) + @SuppressLint("SourceLockedOrientationActivity") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) actionBar?.hide() requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + AppModeStore.load(this) loadEntries(this.baseContext) + incomingEntry.value = Kp2a.entryFromIntent(intent) setContent { SysTheme { @@ -39,24 +59,49 @@ class MainActivity : FragmentActivity() { if (!navController.popBackStack()) finish() } LaunchedEffect(Unit) { - if(!hasCredentials(context)) navController.navigate("init") + when (AppModeStore.mode.value) { + null -> navController.navigate("mode") + AppMode.STANDALONE -> + if (!hasCredentials(context)) navController.navigate("init") + AppMode.KP2A -> {} + } } - 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") ?: "") + LaunchedEffect(incomingEntry.value) { + val entry = incomingEntry.value ?: return@LaunchedEffect + incomingEntry.value = null + if (AppModeStore.isKp2a) cacheEntry(context, entry) + else transientEntry.value = entry + navController.navigate("view/${entry.uid}") + } + // The app draws edge-to-edge (targetSdk >= 35): keep every screen clear of the + // status and navigation bars, and paint the bars with the theme background. + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.background) + .systemBarsPadding() + ) { + NavHost(navController = navController, startDestination = "launcher") { + composable("exit") { finish() } + composable("mode") { ModeSelectScreen(navController) } + composable("launcher") { LauncherScreen(navController) } + composable("init") { InitialScreen(navController) } + composable("scanCam") { ScannerScreen(navController) } + composable("scanFile") { FileScanner(navController) } + composable("edit") { CreateEntryScreen(navController) } + composable("view/{entryId}") { e -> + val entry = findEntry(e.arguments?.getString("entryId")) + if (entry == null) return@composable navController.navigate("launcher") + ViewEntryScreen(navController, entry) } - if (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 } + } } diff --git a/app/src/main/java/net/helcel/fidelity/activity/fragment/CreateEntry.kt b/app/src/main/java/net/helcel/fidelity/activity/fragment/CreateEntry.kt index a90d2c9..3f20575 100644 --- a/app/src/main/java/net/helcel/fidelity/activity/fragment/CreateEntry.kt +++ b/app/src/main/java/net/helcel/fidelity/activity/fragment/CreateEntry.kt @@ -1,5 +1,6 @@ package net.helcel.fidelity.activity.fragment +import android.content.Context import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -13,8 +14,11 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.Checkbox import androidx.compose.material.CheckboxDefaults @@ -31,6 +35,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Camera import androidx.compose.material.icons.filled.FileOpen +import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -47,21 +52,27 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import com.google.zxing.FormatException -import com.kunzisoft.keepass.database.element.Entry +import com.kunzisoft.keepass.database.element.Group import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.helcel.fidelity.R import net.helcel.fidelity.activity.ToastHelper +import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.ensureUnlocked import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onCameraScan import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onFileScan +import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSaveKp2a +import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSaveStandalone import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSubmit import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onSave +import net.helcel.fidelity.tools.AppModeStore import net.helcel.fidelity.tools.BarcodeGenerator.generateBarcode import net.helcel.fidelity.tools.FidelityEntry -import net.helcel.fidelity.tools.FidelityRepository import net.helcel.fidelity.tools.FidelityRepository.activeEntry -import net.helcel.fidelity.tools.FidelityRepository.addEntry +import net.helcel.fidelity.tools.KeepassDatabase +import net.helcel.fidelity.tools.KeepassDatabase.addEntry +import net.helcel.fidelity.tools.FidelityRepository.cacheEntry +import net.helcel.fidelity.tools.Kp2a import kotlin.time.Duration.Companion.milliseconds @@ -79,6 +90,22 @@ fun CreateEntryScreen(navController: NavHostController?) { var isLoading by remember { mutableStateOf(false) } val ctx = LocalContext.current val scope = rememberCoroutineScope() + val kp2a = AppModeStore.isKp2a + // Standalone only: where a new card is created (root unless changed). Existing cards + // (uid set) stay where they are. + var group by remember { mutableStateOf(KeepassDatabase.getRoot()) } + val showGroup = !kp2a && entry.uid == null + + fun pickGroup() { + isLoading = true + scope.launch { + if (ensureUnlocked(ctx, navController!!)) { + group = group ?: KeepassDatabase.getRoot() + showDialog = true + } + isLoading = false + } + } LaunchedEffect(entry) { isValidBarcode = false @@ -103,17 +130,10 @@ fun CreateEntryScreen(navController: NavHostController?) { } if (showDialog) { - TreeSelectorDialog( - onDismiss = { - showDialog = false - if(it!=null){ - entry = entry.copy(uid = it.nodeId?.id.toString()) - if(it is Entry){ - entry = entry.copy(title = it.title) - } - } - } - ) + TreeSelectorDialog(groupsOnly = true) { + showDialog = false + if (it is Group) group = it + } } val formats = stringArrayResource(R.array.format_array) @@ -121,16 +141,18 @@ fun CreateEntryScreen(navController: NavHostController?) { modifier = Modifier .fillMaxSize() .background(MaterialTheme.colors.background) + .imePadding(), ) { Column( modifier = Modifier - .padding(16.dp, 32.dp), + .verticalScroll(rememberScrollState()) + .padding(16.dp, 32.dp) + .padding(bottom = 120.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { OutlinedTextField( value = entry.title, - enabled = entry.uid!=null, onValueChange = { entry = entry.copy(title = it) errorTitle = "" @@ -140,8 +162,7 @@ fun CreateEntryScreen(navController: NavHostController?) { modifier = Modifier.fillMaxWidth(), singleLine = true, colors = TextFieldDefaults.textFieldColors( - textColor = if(entry.uid!=null)MaterialTheme.colors.onBackground - else MaterialTheme.colors.secondary + textColor = MaterialTheme.colors.onBackground ), ) if (errorTitle.isNotEmpty()) { @@ -201,6 +222,23 @@ fun CreateEntryScreen(navController: NavHostController?) { Icon(Icons.Default.FileOpen, contentDescription = null) } } + if (showGroup) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text("Group: ", color = MaterialTheme.colors.onBackground) + Text( + group?.title ?: "(database root)", + color = MaterialTheme.colors.onBackground, + modifier = Modifier.weight(1f), + maxLines = 1, + ) + Button(onClick = { pickGroup() }) { + Icon(Icons.Default.FolderOpen, contentDescription = "Choose group") + } + } + } if (barcodeBitmap != null) { Image( bitmap = barcodeBitmap!!.asImageBitmap(), @@ -229,41 +267,22 @@ fun CreateEntryScreen(navController: NavHostController?) { errorCode = c errorFormat = f }, - isValidBarcode + isValidBarcode, ) { - if (FidelityRepository.getRoot() == null) { + if (kp2a) { + onSaveKp2a(ctx, navController!!, entry) + } else { isLoading = true scope.launch { - onRefresh(ctx, navController!!) + onSaveStandalone(ctx, navController!!, entry, group) 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 cErr = "" var fErr = "" - if (entry.uid!=null && entry.title.isBlank()) tErr = "Title cannot be empty" + if (entry.title.isBlank()) tErr = "Title cannot be empty" if (entry.code.isBlank()) cErr = "Code cannot be empty" if (entry.format.isBlank()) fErr = "Format cannot be empty" @@ -358,6 +377,38 @@ private fun onSubmitIfValid( } object CreateEntryEventHandler { + /** Standalone: the database must be open before groups can be browsed or cards written. */ + suspend fun ensureUnlocked(context: Context, navController: NavHostController): Boolean = + KeepassDatabase.getRoot() != null || onRefresh(context, navController) + + /** Standalone: updates the existing entry, or creates one in [group] (the root when null). */ + suspend fun onSaveStandalone( + context: Context, + navController: NavHostController, + entry: FidelityEntry, + group: Group?, + ) { + if (!ensureUnlocked(context, navController)) return + val root = KeepassDatabase.getRoot() ?: return + val target = + if (entry.uid != null) entry + else entry.copy(uid = (group ?: root).nodeId.id.toString()) + addEntry(context, target) + if (onSave(context, navController)) onSubmit(navController) + } + + /** + * KP2A creates the entry in its own task and never reports back, so the card is cached + * right away; a later fetch replaces the local uid with KP2A's. + */ + fun onSaveKp2a(context: Context, navController: NavHostController, entry: FidelityEntry) { + val kpEntry = entry.copy(uid = Kp2a.localUid(entry.title)) + if (Kp2a.launchAdd(context, kpEntry)) { + cacheEntry(context, kpEntry) + onSubmit(navController) + } + } + fun onSubmit(navController: NavHostController){ navController.popBackStack() activeEntry.value = activeEntry.value.copy( diff --git a/app/src/main/java/net/helcel/fidelity/activity/fragment/FidelityRow.kt b/app/src/main/java/net/helcel/fidelity/activity/fragment/FidelityRow.kt new file mode 100644 index 0000000..61c6159 --- /dev/null +++ b/app/src/main/java/net/helcel/fidelity/activity/fragment/FidelityRow.kt @@ -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") + } + } + } +} diff --git a/app/src/main/java/net/helcel/fidelity/activity/fragment/Launcher.kt b/app/src/main/java/net/helcel/fidelity/activity/fragment/Launcher.kt index 87b8a51..4fec94e 100644 --- a/app/src/main/java/net/helcel/fidelity/activity/fragment/Launcher.kt +++ b/app/src/main/java/net/helcel/fidelity/activity/fragment/Launcher.kt @@ -1,10 +1,12 @@ package net.helcel.fidelity.activity.fragment +import android.app.Activity import android.content.Context import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -19,25 +21,20 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.DropdownMenu -import androidx.compose.material.DropdownMenuItem -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.FloatingActionButton import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text +import androidx.compose.material.TextButton import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close -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.Key import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults +import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable @@ -56,28 +53,23 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import net.helcel.fidelity.activity.ToastHelper import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.isSearchVisible import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onAdd -import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onEdit -import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onHide -import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onPin import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onQuery import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onView import net.helcel.fidelity.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.FidelityRepository.activeEntry -import net.helcel.fidelity.tools.FidelityRepository.end +import net.helcel.fidelity.tools.FidelityRepository.cacheEntry import net.helcel.fidelity.tools.FidelityRepository.entries -import net.helcel.fidelity.tools.FidelityRepository.genCredentials -import net.helcel.fidelity.tools.FidelityRepository.importDB -import net.helcel.fidelity.tools.FidelityRepository.start -import net.helcel.fidelity.tools.KeePassStore.loadCredentials +import net.helcel.fidelity.tools.KeepassDatabase +import net.helcel.fidelity.tools.FidelityRepository.loadEntries +import net.helcel.fidelity.tools.FidelityRepository.removeEntry +import net.helcel.fidelity.tools.Kp2a @Preview @OptIn(ExperimentalMaterial3Api::class) @@ -91,6 +83,20 @@ fun LauncherScreen( val context = LocalContext.current val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } + val kp2a = AppModeStore.isKp2a + + val kp2aQueryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult + val entry = Kp2a.entryFromIntent(result.data) + if (entry == null) { + ToastHelper.show(context, "Entry has no fidelity code") + return@rememberLauncherForActivityResult + } + cacheEntry(context, entry) + onView(navController, entry) + } BackHandler(enabled = isSearchVisible) { onQuery() @@ -118,7 +124,9 @@ fun LauncherScreen( onRefresh = { isRefreshingState = true scope.launch { - onRefresh(context, navController) + // KP2A owns the database: nothing to sync, just reload the cache. + if (kp2a) loadEntries(context) + else onRefresh(context, navController) isRefreshingState = false } }, @@ -126,6 +134,45 @@ fun LauncherScreen( modifier = Modifier.fillMaxSize() ) { Column(modifier = Modifier.fillMaxSize()) { + // Top bar: hidden-cards toggle on the left, storage mode on the right. + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val hiddenTint = + if (showHidden) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary + TextButton(onClick = { showHidden = !showHidden }) { + Icon( + Icons.Default.HideSource, + contentDescription = "Show Hidden", + modifier = Modifier.size(16.dp), + tint = hiddenTint + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + if (showHidden) "Showing hidden" else "Show hidden", + style = MaterialTheme.typography.caption, + color = hiddenTint + ) + } + TextButton(onClick = { navController.navigate("mode") }) { + Icon( + Icons.Default.SwapHoriz, + contentDescription = "Switch mode", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colors.secondary + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + if (kp2a) "Keepass2Android" else "Standalone", + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.secondary + ) + } + } if (isSearchVisible) { LaunchedEffect(Unit) { focusRequester.requestFocus() @@ -187,18 +234,20 @@ fun LauncherScreen( ) { 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 (kp2a) + FloatingActionButton( + onClick = { Kp2a.launchQuery(context, kp2aQueryLauncher) }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 88.dp), + backgroundColor = MaterialTheme.colors.secondary, + ) { + Icon( + Icons.Default.Key, + tint = MaterialTheme.colors.onSecondary, + contentDescription = "Fetch from Keepass2Android" + ) + } } if (isRefreshingState) @@ -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 { var isSearchVisible by mutableStateOf(false) var searchQuery by mutableStateOf("") - var CRED: CredentialResult.Success? = null fun onAdd(navController: NavHostController) { navController.navigate("edit") @@ -325,48 +278,22 @@ object LauncherEventHandlers { if (!isSearchVisible) searchQuery = "" } - suspend fun onSave(context: Context, navController: NavHostController){ - try { - if (CRED == null) { - 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()) + /** Standalone: writes the database back. Missing credentials send the user to setup. */ + suspend fun onSave(context: Context, navController: NavHostController): Boolean { + if (!KeepassDatabase.ensureCredentials(context)) { navController.navigate("init") + return false } + return KeepassDatabase.save(context) } - suspend fun onRefresh(context: Context, navController: NavHostController) { - try { - if (CRED == null) { - 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()) + /** Standalone: (re)opens the database and imports its cards. Missing credentials send the user to setup. */ + suspend fun onRefresh(context: Context, navController: NavHostController): Boolean { + if (!KeepassDatabase.ensureCredentials(context)) { navController.navigate("init") + return false } + return KeepassDatabase.unlock(context) } fun onView(navController: NavHostController, entry: FidelityEntry) { @@ -389,8 +316,12 @@ object LauncherEventHandlers { entries[index] = entry.copy(hidden = !entry.hidden) } + fun onRemove(context: Context, entry: FidelityEntry) { + removeEntry(context, entry) + } + fun onEdit(navController: NavHostController, entry: FidelityEntry){ activeEntry.value = entry navController.navigate("edit") } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/helcel/fidelity/activity/fragment/ModeSelect.kt b/app/src/main/java/net/helcel/fidelity/activity/fragment/ModeSelect.kt new file mode 100644 index 0000000..0c8cc1e --- /dev/null +++ b/app/src/main/java/net/helcel/fidelity/activity/fragment/ModeSelect.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/net/helcel/fidelity/activity/fragment/SelectEntry.kt b/app/src/main/java/net/helcel/fidelity/activity/fragment/SelectEntry.kt index 046ff6c..4fa625a 100644 --- a/app/src/main/java/net/helcel/fidelity/activity/fragment/SelectEntry.kt +++ b/app/src/main/java/net/helcel/fidelity/activity/fragment/SelectEntry.kt @@ -19,7 +19,9 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.CreditCard import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Folder import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -32,11 +34,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.kunzisoft.keepass.database.element.Group import com.kunzisoft.keepass.database.element.node.Node -import net.helcel.fidelity.tools.FidelityRepository +import net.helcel.fidelity.tools.KeepassDatabase +/** + * Browses the loaded database. With [groupsOnly] entries are hidden, so the result is the group + * a new card is created in; otherwise an existing entry can be picked to attach the card to. + */ @Preview @Composable -fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) { +fun TreeSelectorDialog(groupsOnly: Boolean = false, onDismiss: (Node?) -> Unit = {}) { Dialog( onDismissRequest = {onDismiss(null)}, content = { @@ -46,8 +52,8 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) { RoundedCornerShape(8.dp) ) ) { - var currentRoot by remember { mutableStateOf(FidelityRepository.getRoot()) } - var selection by remember { mutableStateOf(FidelityRepository.getRoot()) } + var currentRoot by remember { mutableStateOf(KeepassDatabase.getRoot()) } + var selection by remember { mutableStateOf(KeepassDatabase.getRoot()) } Column( modifier = Modifier.fillMaxWidth().padding(8.dp) @@ -76,32 +82,29 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) { LazyColumn(modifier = Modifier.fillMaxHeight(0.75f)) { items(currentRoot?.getChildGroups() ?: emptyList()) { entry -> val isSel = (entry.nodeId == selection?.nodeId) + val hasChildren = entry.getChildGroups().isNotEmpty() || + (!groupsOnly && entry.getChildEntries().isNotEmpty()) Row( modifier = Modifier .fillMaxWidth() .background(color = if (isSel) MaterialTheme.colors.primary else MaterialTheme.colors.background) .clickable { - if (entry.getChildEntries().isNotEmpty()) { - currentRoot = entry - selection = entry - } else if (entry.getChildGroups().isNotEmpty()) { - currentRoot = entry - selection = entry - } else { - selection = entry - } + selection = entry + if (hasChildren) currentRoot = entry } .padding(8.dp) ) { - if (entry.getChildEntries().isNotEmpty() || entry.getChildGroups() - .isNotEmpty() - ) { + Icon( + imageVector = Icons.Default.Folder, + contentDescription = "group", + tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground + ) + if (hasChildren) Icon( imageVector = Icons.Default.ExpandMore, contentDescription = null, tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground ) - } Text( entry.title, modifier = Modifier.padding(start = 8.dp), @@ -109,7 +112,7 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) { ) } } - items(currentRoot?.getChildEntries() ?: emptyList()) { entry -> + items(if (groupsOnly) emptyList() else currentRoot?.getChildEntries() ?: emptyList()) { entry -> val isSel = (entry.nodeId == selection?.nodeId) Row( modifier = Modifier @@ -120,6 +123,11 @@ fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) { } .padding(8.dp) ) { + Icon( + imageVector = Icons.Default.CreditCard, + contentDescription = "entry", + tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground + ) Text( entry.title, modifier = Modifier.padding(start = 8.dp), diff --git a/app/src/main/java/net/helcel/fidelity/activity/fragment/Setup.kt b/app/src/main/java/net/helcel/fidelity/activity/fragment/Setup.kt index 291e612..2704489 100644 --- a/app/src/main/java/net/helcel/fidelity/activity/fragment/Setup.kt +++ b/app/src/main/java/net/helcel/fidelity/activity/fragment/Setup.kt @@ -28,6 +28,7 @@ import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text +import androidx.compose.material.TextButton import androidx.compose.material.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -52,9 +53,10 @@ import kotlinx.coroutines.withContext import net.helcel.fidelity.activity.ToastHelper import net.helcel.fidelity.activity.fragment.SetupEventHandlers.onOpen import net.helcel.fidelity.tools.CredentialResult -import net.helcel.fidelity.tools.FidelityRepository.genCredentials -import net.helcel.fidelity.tools.FidelityRepository.importDB -import net.helcel.fidelity.tools.FidelityRepository.start +import net.helcel.fidelity.tools.KeepassDatabase +import net.helcel.fidelity.tools.KeepassDatabase.genCredentials +import net.helcel.fidelity.tools.KeepassDatabase.importDB +import net.helcel.fidelity.tools.KeepassDatabase.start import net.helcel.fidelity.tools.KeePassStore.loadCredentials import net.helcel.fidelity.tools.KeePassStore.packCredentials import net.helcel.fidelity.tools.KeePassStore.saveCredentials @@ -223,9 +225,6 @@ fun InitialScreen( val res = onOpen(context, dbFile!!, password, keyFile) if(res != null){ ToastHelper.show(context, "Successful... Importing") - withContext(Dispatchers.IO) { - start(context, dbFile!!,genCredentials(context, res)) - } importDB(context) navController!!.popBackStack() navController.navigate("launcher") @@ -238,6 +237,13 @@ fun InitialScreen( ) { Text("Continue") } + TextButton( + enabled = !loading, + onClick = { navController!!.navigate("mode") }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Use Keepass2Android instead", color = MaterialTheme.colors.secondary) + } } Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier .fillMaxSize() @@ -254,12 +260,16 @@ fun InitialScreen( } object SetupEventHandlers { + /** Opens the file with the given credentials, then stores them for later sessions. */ suspend fun onOpen(context: Context, db: Uri, p: String, key: Uri?): CredentialResult.Success? { try { val packCred = packCredentials(db, p, key) - withContext(Dispatchers.IO) { - start(context, db, genCredentials(context, packCred) - ) + val opened = withContext(Dispatchers.IO) { + start(context, db, genCredentials(context, packCred)) + } + if (!opened) { + ToastHelper.show(context, "Unable to open the database: check the password and key file") + return null } val res = withContext(Dispatchers.Main) { @@ -267,7 +277,10 @@ object SetupEventHandlers { } return when (res) { CredentialResult.AuthFailed, CredentialResult.NoData -> null - is CredentialResult.Success -> res + is CredentialResult.Success -> { + KeepassDatabase.credentials = res + res + } } } catch (e: Exception) { ToastHelper.show(context, e.message.toString()) diff --git a/app/src/main/java/net/helcel/fidelity/activity/fragment/ViewEntry.kt b/app/src/main/java/net/helcel/fidelity/activity/fragment/ViewEntry.kt index 6baa288..0cf7076 100644 --- a/app/src/main/java/net/helcel/fidelity/activity/fragment/ViewEntry.kt +++ b/app/src/main/java/net/helcel/fidelity/activity/fragment/ViewEntry.kt @@ -20,6 +20,7 @@ import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -62,11 +63,19 @@ fun ViewEntryScreen( activity?.window?.attributes = activity.window?.attributes?.apply { screenBrightness = if (isFull) 1f else BRIGHTNESS_OVERRIDE_NONE } + } + // Generate once per entry (not on every recomposition) and surface zxing's reason, + // e.g. "Contents do not pass checksum" for a mistyped EAN. + LaunchedEffect(entry) { try { bitmap = generateBarcode(entry.code, entry.format, 1024) - } catch (_: Exception) { + } catch (e: Exception) { bitmap = null - Toast.makeText(context, "Invalid barcode format", Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + "Invalid barcode: " + (e.message ?: "unsupported format"), + Toast.LENGTH_LONG + ).show() } } BackHandler { diff --git a/app/src/main/java/net/helcel/fidelity/tools/AppMode.kt b/app/src/main/java/net/helcel/fidelity/tools/AppMode.kt new file mode 100644 index 0000000..4f23464 --- /dev/null +++ b/app/src/main/java/net/helcel/fidelity/tools/AppMode.kt @@ -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(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 + } +} diff --git a/app/src/main/java/net/helcel/fidelity/tools/BiometricStore.kt b/app/src/main/java/net/helcel/fidelity/tools/BiometricStore.kt index a22f98c..81484e9 100644 --- a/app/src/main/java/net/helcel/fidelity/tools/BiometricStore.kt +++ b/app/src/main/java/net/helcel/fidelity/tools/BiometricStore.kt @@ -2,9 +2,12 @@ package net.helcel.fidelity.tools import android.content.Context import android.net.Uri +import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 +import android.widget.Toast +import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat import javax.crypto.Cipher @@ -16,6 +19,7 @@ import com.kunzisoft.keepass.utils.parseUri import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.suspendCancellableCoroutine +import net.helcel.fidelity.activity.ToastHelper import java.security.KeyStore import javax.crypto.KeyGenerator import javax.crypto.SecretKey @@ -36,6 +40,17 @@ sealed class CredentialResult { private const val KEY_ALIAS = "keepass_bio_key" +// Android 11+ can unlock a keystore key with the device PIN/pattern/password as well, which +// keeps standalone mode usable on devices without (enrolled) biometrics. Below that, a +// CryptoObject can only be released by a biometric. +private val deviceCredentialSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + +private val allowedAuthenticators: Int + get() = if (deviceCredentialSupported) + BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL + else + BiometricManager.Authenticators.BIOMETRIC_STRONG + fun getOrCreateBiometricKey(): SecretKey { val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey } @@ -47,7 +62,13 @@ fun getOrCreateBiometricKey(): SecretKey { ).apply { setBlockModes(KeyProperties.BLOCK_MODE_GCM) 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) }.build() @@ -108,6 +129,17 @@ object KeePassStore { suspend fun showBiometricPrompt(activity: FragmentActivity, enc: Boolean): Cipher? { val prefs = activity.securePrefs.data.first() return suspendCancellableCoroutine { cont -> + val status = BiometricManager.from(activity).canAuthenticate(allowedAuthenticators) + if (status != BiometricManager.BIOMETRIC_SUCCESS) { + ToastHelper.show( + activity, + if (deviceCredentialSupported) "Set up a screen lock or fingerprint first" + else "Enroll a fingerprint first", + Toast.LENGTH_LONG + ) + cont.resume(null) { _, _, _ -> } + return@suspendCancellableCoroutine + } val executor = ContextCompat.getMainExecutor(activity) val biometricPrompt = BiometricPrompt( activity, @@ -119,20 +151,22 @@ suspend fun showBiometricPrompt(activity: FragmentActivity, enc: Boolean): Ciphe override fun onAuthenticationError(code: Int, msg: CharSequence) { cont.resume(null) { _, _, _ -> } } - override fun onAuthenticationFailed() { - cont.resume(null) { _, _, _ -> } - } + // onAuthenticationFailed is a retryable miss: the prompt stays open, so the + // coroutine must stay suspended until it either succeeds or errors out. } ) val iv = if(enc) null else prefs[KeePassKeys.IV]?.let { Base64.decode(it, Base64.DEFAULT) } if (!enc && iv == null) { cont.resume(null) { _, _, _ -> } + return@suspendCancellableCoroutine } val cipher = getCipherForDecryption(getOrCreateBiometricKey(), iv) val promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle("Unlock KeePass") .setSubtitle("Authenticate to access your KeePass database") - .setNegativeButtonText("Cancel") + .setAllowedAuthenticators(allowedAuthenticators) + // A negative button is mandatory without, and forbidden with, device credential. + .apply { if (!deviceCredentialSupported) setNegativeButtonText("Cancel") } .build() biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) diff --git a/app/src/main/java/net/helcel/fidelity/tools/FidelityEntry.kt b/app/src/main/java/net/helcel/fidelity/tools/FidelityEntry.kt new file mode 100644 index 0000000..dbdc16b --- /dev/null +++ b/app/src/main/java/net/helcel/fidelity/tools/FidelityEntry.kt @@ -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, +) diff --git a/app/src/main/java/net/helcel/fidelity/tools/FidelityRepository.kt b/app/src/main/java/net/helcel/fidelity/tools/FidelityRepository.kt new file mode 100644 index 0000000..a0f5a9e --- /dev/null +++ b/app/src/main/java/net/helcel/fidelity/tools/FidelityRepository.kt @@ -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() + + /** Card being edited on the create screen. */ + val activeEntry = mutableStateOf(FidelityEntry()) + + /** Entry handed over by Keepass2Android for viewing without being cached. */ + val transientEntry = mutableStateOf(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 } +} diff --git a/app/src/main/java/net/helcel/fidelity/tools/Keepass.kt b/app/src/main/java/net/helcel/fidelity/tools/KeepassDatabase.kt similarity index 59% rename from app/src/main/java/net/helcel/fidelity/tools/Keepass.kt rename to app/src/main/java/net/helcel/fidelity/tools/KeepassDatabase.kt index 410aff4..455c634 100644 --- a/app/src/main/java/net/helcel/fidelity/tools/Keepass.kt +++ b/app/src/main/java/net/helcel/fidelity/tools/KeepassDatabase.kt @@ -2,12 +2,6 @@ package net.helcel.fidelity.tools import android.content.Context import android.net.Uri -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import kotlinx.serialization.Serializable -import java.io.ByteArrayInputStream -import kotlinx.serialization.json.Json -import androidx.core.content.edit import com.kunzisoft.keepass.database.element.Database import com.kunzisoft.keepass.database.element.Field import com.kunzisoft.keepass.database.element.Group @@ -17,39 +11,75 @@ import com.kunzisoft.keepass.database.element.node.NodeIdUUID import com.kunzisoft.keepass.database.element.security.ProtectedString import com.kunzisoft.keepass.hardware.HardwareKey import com.kunzisoft.keepass.utils.getBinaryDir -import kotlinx.serialization.builtins.ListSerializer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.helcel.fidelity.activity.ToastHelper +import net.helcel.fidelity.tools.FidelityRepository.entries +import net.helcel.fidelity.tools.FidelityRepository.saveEntries +import net.helcel.fidelity.tools.KeePassStore.loadCredentials +import java.io.ByteArrayInputStream import java.io.File import java.util.UUID -object FidelityKeepassFields { - const val FIDELITYFORMAT = "FidelityFormat" - const val FIDELITYCODE = "FidelityCode" -} - -@Serializable -data class FidelityEntry( - val uid: String? = null, - val title: String = "", - val code: String = "", - val format: String = "", - val protected: Boolean = false, - - val hidden: Boolean = false, - val pinned: Boolean = false, - val lastUse: Int = 0, -) - -object FidelityRepository { +/** + * Standalone mode: the KDBX file opened through the bundled KeePassDX engine. + * + * [unlock] and [save] are the session-level operations the screens use; the rest is the + * raw file handling underneath them. + */ +object KeepassDatabase { private var db: Database = Database() private var binaryDir: File? = null - val entries = mutableStateListOf() - val activeEntry = mutableStateOf(FidelityEntry()) + /** Credentials released by the user for this process, so authentication happens once. */ + var credentials: CredentialResult.Success? = null fun getRoot(): Group? { return db.rootGroup } + /** Makes sure [credentials] are available, asking the user to authenticate if needed. */ + suspend fun ensureCredentials(context: Context): Boolean { + if (credentials != null) return true + return when (val res = loadCredentials(context)) { + is CredentialResult.Success -> { + credentials = res + true + } + CredentialResult.AuthFailed, CredentialResult.NoData -> { + ToastHelper.show(context, "Unable to Load Credentials") + false + } + } + } + + /** Opens the file with [credentials] and imports its cards into [FidelityRepository]. */ + suspend fun unlock(context: Context): Boolean { + val cred = credentials ?: return false + val opened = withContext(Dispatchers.IO) { + start(context, cred.db, genCredentials(context, cred)) + } + if (!opened) { + ToastHelper.show(context, "Unable to open the database") + return false + } + importDB(context) + return true + } + + /** Writes the database back to its file. */ + suspend fun save(context: Context): Boolean { + val cred = credentials ?: return false + val saved = try { + withContext(Dispatchers.IO) { end(context, cred.db, genCredentials(context, cred)) } + } catch (e: Exception) { + println(e) + false + } + if (!saved) ToastHelper.show(context, "Unable to save the database") + return saved + } + fun start(ctx: Context, uri: Uri?, c: MasterCredential): Boolean { if (binaryDir == null) binaryDir = ctx.getBinaryDir() if (uri == null) return false @@ -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) { val seenID= arrayListOf() fun importDBRec(group: Group) { @@ -119,8 +150,6 @@ object FidelityRepository { } else { entries.add(newEntry) } - - } group.getChildGroups().forEach { importDBRec(it) } } @@ -133,36 +162,19 @@ object FidelityRepository { saveEntries(context) } - fun saveEntries(context: Context) { - val prefs = context.getSharedPreferences("fidelity_prefs", Context.MODE_PRIVATE) - prefs.edit { putString("entries", Json.encodeToString( - ListSerializer(FidelityEntry.serializer()), - entries - )) } - } - - fun loadEntries(context: Context) { - val prefs = context.getSharedPreferences("fidelity_prefs", Context.MODE_PRIVATE) - try { - val json = prefs.getString("entries", null) ?: return - val list = Json.decodeFromString( - ListSerializer(FidelityEntry.serializer()), - json - ) - - entries.clear() - entries.addAll(list) - }catch(_: Exception){ - prefs.edit{ putString("entries",Json.encodeToString( - ListSerializer(FidelityEntry.serializer()),emptyList())) - } - } - } - + /** + * Writes the card into the loaded database. [FidelityEntry.uid] is either the id of an + * existing entry, which is updated in place, or the id of the group to create it in. + */ fun addEntry(ctx: Context, entry: FidelityEntry) { - val dbEntry = db.getEntryById(NodeIdUUID(UUID.fromString(entry.uid))) ?: db.createEntry() - val dbParent = db.getGroupById(NodeIdUUID(UUID.fromString(entry.uid))) - dbEntry?.apply { + val id = NodeIdUUID(UUID.fromString(entry.uid)) + val existing = db.getEntryById(id) + val dbEntry = existing ?: db.createEntry() ?: return + dbEntry.apply { + title = entry.title + // Keepass2Android lists entries for this app by URL, so cards written here stay + // reachable from KP2A mode as well. + if (url.isBlank()) url = Kp2a.appUrl(ctx) putExtraField( Field( FidelityKeepassFields.FIDELITYCODE, @@ -175,11 +187,11 @@ object FidelityRepository { ProtectedString(true, entry.format.toCharArray()) ) ) - if(dbParent!=null) title = entry.title - dbParent?.addChildEntry(dbEntry) } + if (existing != null) db.updateEntry(dbEntry) + else db.addEntryTo(dbEntry, db.getGroupById(id) ?: db.rootGroup ?: return) entries.removeIf {it.uid == entry.uid} - entries.add(entry.copy(uid=dbEntry?.nodeId?.id.toString())) + entries.add(entry.copy(uid=dbEntry.nodeId.id.toString())) saveEntries(ctx) } } diff --git a/app/src/main/java/net/helcel/fidelity/tools/Kp2a.kt b/app/src/main/java/net/helcel/fidelity/tools/Kp2a.kt new file mode 100644 index 0000000..be0aff1 --- /dev/null +++ b/app/src/main/java/net/helcel/fidelity/tools/Kp2a.kt @@ -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): 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() + 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 { + 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() + } + } +}