Integrate Keepass2android functionality with standalone
This commit is contained in:
@@ -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,19 +59,38 @@ class MainActivity : FragmentActivity() {
|
|||||||
if (!navController.popBackStack()) finish()
|
if (!navController.popBackStack()) finish()
|
||||||
}
|
}
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
when (AppModeStore.mode.value) {
|
||||||
|
null -> navController.navigate("mode")
|
||||||
|
AppMode.STANDALONE ->
|
||||||
if (!hasCredentials(context)) navController.navigate("init")
|
if (!hasCredentials(context)) navController.navigate("init")
|
||||||
|
AppMode.KP2A -> {}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(incomingEntry.value) {
|
||||||
|
val entry = incomingEntry.value ?: return@LaunchedEffect
|
||||||
|
incomingEntry.value = null
|
||||||
|
if (AppModeStore.isKp2a) cacheEntry(context, entry)
|
||||||
|
else transientEntry.value = entry
|
||||||
|
navController.navigate("view/${entry.uid}")
|
||||||
|
}
|
||||||
|
// The app draws edge-to-edge (targetSdk >= 35): keep every screen clear of the
|
||||||
|
// status and navigation bars, and paint the bars with the theme background.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
.systemBarsPadding()
|
||||||
|
) {
|
||||||
NavHost(navController = navController, startDestination = "launcher") {
|
NavHost(navController = navController, startDestination = "launcher") {
|
||||||
composable("exit") { finish() }
|
composable("exit") { finish() }
|
||||||
|
composable("mode") { ModeSelectScreen(navController) }
|
||||||
composable("launcher") { LauncherScreen(navController) }
|
composable("launcher") { LauncherScreen(navController) }
|
||||||
composable("init") { InitialScreen(navController) }
|
composable("init") { InitialScreen(navController) }
|
||||||
composable("scanCam") { ScannerScreen(navController) }
|
composable("scanCam") { ScannerScreen(navController) }
|
||||||
composable("scanFile") { FileScanner(navController) }
|
composable("scanFile") { FileScanner(navController) }
|
||||||
composable("edit") { CreateEntryScreen(navController) }
|
composable("edit") { CreateEntryScreen(navController) }
|
||||||
composable("view/{entryId}") { e ->
|
composable("view/{entryId}") { e ->
|
||||||
val entry = entries.find {
|
val entry = findEntry(e.arguments?.getString("entryId"))
|
||||||
it.uid == (e.arguments?.getString("entryId") ?: "")
|
|
||||||
}
|
|
||||||
if (entry == null) return@composable navController.navigate("launcher")
|
if (entry == null) return@composable navController.navigate("launcher")
|
||||||
ViewEntryScreen(navController, entry)
|
ViewEntryScreen(navController, entry)
|
||||||
}
|
}
|
||||||
@@ -60,3 +99,9 @@ class MainActivity : FragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,34 +130,29 @@ fun CreateEntryScreen(navController: NavHostController?) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showDialog) {
|
if (showDialog) {
|
||||||
TreeSelectorDialog(
|
TreeSelectorDialog(groupsOnly = true) {
|
||||||
onDismiss = {
|
|
||||||
showDialog = false
|
showDialog = false
|
||||||
if(it!=null){
|
if (it is Group) group = it
|
||||||
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)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
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,17 +234,19 @@ fun LauncherScreen(
|
|||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Add, contentDescription = "Add")
|
Icon(Icons.Default.Add, contentDescription = "Add")
|
||||||
}
|
}
|
||||||
|
if (kp2a)
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = {
|
onClick = { Kp2a.launchQuery(context, kp2aQueryLauncher) },
|
||||||
showHidden=!showHidden
|
modifier = Modifier
|
||||||
}, modifier = Modifier
|
.align(Alignment.BottomEnd)
|
||||||
.align(Alignment.BottomStart)
|
.padding(end = 16.dp, bottom = 88.dp),
|
||||||
.padding(16.dp).size(24.dp),
|
backgroundColor = MaterialTheme.colors.secondary,
|
||||||
backgroundColor = if(showHidden) MaterialTheme.colors.onBackground else 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"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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()) {
|
|
||||||
currentRoot = entry
|
|
||||||
selection = entry
|
selection = entry
|
||||||
} else if (entry.getChildGroups().isNotEmpty()) {
|
if (hasChildren) currentRoot = entry
|
||||||
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,6 +62,12 @@ 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)
|
||||||
|
if (deviceCredentialSupported)
|
||||||
|
setUserAuthenticationParameters(
|
||||||
|
0,
|
||||||
|
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
|
||||||
|
)
|
||||||
|
else
|
||||||
setUserAuthenticationRequired(true)
|
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 }
|
||||||
|
}
|
||||||
+74
-62
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user