diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..9bfa737 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "external/KeePassDX"] + path = external/KeePassDX + url = https://github.com/Kunzisoft/KeePassDX.git diff --git a/app/build.gradle b/app/build.gradle index ae2fe2f..36f23ab 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,44 +2,55 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.20' + id 'org.jetbrains.kotlin.plugin.compose' version '2.2.20' } -def keystorePropertiesFile = rootProject.file("app/keystore.properties") -def keystoreProperties = new Properties() -keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) - - android { namespace 'net.helcel.fidelity' - compileSdk 34 + compileSdk 36 defaultConfig { applicationId 'net.helcel.fidelity' - resValue "string", "app_name", "Keepass Fidelity" + versionName "1.0d" + buildConfigField("String", "APP_NAME", "\"Keepass Fidelity\"") + manifestPlaceholders["APP_NAME"] = "Keepass Fidelity" minSdk 28 - targetSdk 34 + targetSdk 36 } signingConfigs { create("release") { - keyAlias keystoreProperties['keyAlias'] - keyPassword keystoreProperties['keyPassword'] - storeFile file(keystoreProperties['storeFile']) - storePassword keystoreProperties['storePassword'] + try { + def keystorePropertiesFile = rootProject.file("app/keystore.properties") + def keystoreProperties = new Properties() + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) + + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile file(keystoreProperties['storeFile']) + storePassword keystoreProperties['storePassword'] + } catch (FileNotFoundException e) { + println("File not found: ${e.message}") + } } } + buildTypes { debug { debuggable true - signingConfig = signingConfigs.getByName("release") } release { minifyEnabled true shrinkResources false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + signedRelease { + minifyEnabled true + shrinkResources false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' signingConfig = signingConfigs.getByName("release") } } @@ -48,17 +59,15 @@ android { compileOptions { coreLibraryDesugaringEnabled true - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 encoding 'utf-8' } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - buildFeatures { viewBinding true + compose true + buildConfig true } dependenciesInfo { @@ -67,18 +76,49 @@ android { // Disables dependency metadata when building Android App Bundles. includeInBundle = false } + composeOptions { + kotlinCompilerExtensionVersion = "2.2.20" + } + kotlin { + jvmToolchain(21) + } + + lint { + disable 'UsingMaterialAndMaterial3Libraries' + disable 'PreviewAnnotationInFunctionWithParameters' + } } dependencies { + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.material3:material3:1.4.0' + implementation 'androidx.compose.material:material:1.9.2' + implementation 'androidx.compose.material:material-icons-extended:1.7.8' + implementation 'androidx.navigation:navigation-compose:2.9.5' + implementation 'androidx.preference:preference-ktx:1.2.1' + + implementation "androidx.biometric:biometric:1.2.0-alpha05" + implementation "androidx.security:security-crypto:1.1.0" + implementation "androidx.datastore:datastore-preferences:1.1.7" + implementation "androidx.security:security-crypto:1.1.0" + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5' implementation 'androidx.camera:camera-lifecycle:1.5.0' implementation 'androidx.camera:camera-view:1.5.0' runtimeOnly 'androidx.camera:camera-camera2:1.5.0' - implementation 'com.google.code.gson:gson:2.13.2' implementation 'com.google.android.material:material:1.13.0' + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0' implementation 'com.google.zxing:core:3.5.3' + + implementation project(":database") + implementation project(":crypto") + + implementation platform('androidx.compose:compose-bom:2025.09.01') + debugImplementation 'androidx.compose.ui:ui-tooling:1.9.2' + debugImplementation 'androidx.compose.ui:ui-tooling-preview' + } \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index add4e4c..086512c 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -2,12 +2,5 @@ # fields. Proguard removes such information by default, keep it. -keepattributes Signature -# This is also needed for R8 in compat mode since multiple -# optimizations will remove the generic signature such as class -# merging and argument removal. See: -# https://r8.googlesource.com/r8/+/refs/heads/main/compatibility-faq.md#troubleshooting-gson-gson --keep class com.google.gson.reflect.TypeToken { *; } --keep class * extends com.google.gson.reflect.TypeToken - # Optional. For using GSON @Expose annotation -keepattributes AnnotationDefault,RuntimeVisibleAnnotations \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 870413c..2d4ea7a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,37 +1,20 @@ - - + - + android:exported="true"> - - - - - - - - \ No newline at end of file diff --git a/app/src/main/java/net/helcel/fidelity/activity/Helper.kt b/app/src/main/java/net/helcel/fidelity/activity/Helper.kt new file mode 100644 index 0000000..f886d33 --- /dev/null +++ b/app/src/main/java/net/helcel/fidelity/activity/Helper.kt @@ -0,0 +1,65 @@ +package net.helcel.fidelity.activity + +import android.content.Context +import android.os.Build +import android.widget.Toast +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.Colors +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource + +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.preference.PreferenceManager +import net.helcel.fidelity.R + + +object ToastHelper{ + fun show(context: Context, message: String, duration: Int = Toast.LENGTH_SHORT) { + Toast.makeText(context, message, duration).show() + } +} + +@Composable +fun SysTheme( + content: @Composable () -> Unit +) { + val context = LocalContext.current + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val themeKey = prefs.getString(stringResource(R.string.key_theme), stringResource(R.string.system)) + val darkTheme = when (themeKey) { + stringResource(R.string.system) -> isSystemInDarkTheme() + stringResource(R.string.light) -> false + stringResource(R.string.dark) -> true + else -> isSystemInDarkTheme() + } + val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if(darkTheme) dynamicDarkColorScheme(LocalContext.current ) else dynamicLightColorScheme(LocalContext.current ) + } else { + if(darkTheme) darkColorScheme() else lightColorScheme() + } + val m2colors = Colors( + primary = colorScheme.primary, + primaryVariant = colorScheme.primaryContainer, + secondary = colorScheme.secondary, + background = colorScheme.background, + surface = colorScheme.surface, + onPrimary = colorScheme.onPrimary, + onSecondary = colorScheme.onSecondary, + onBackground = colorScheme.onBackground, + onSurface = colorScheme.onSurface, + secondaryVariant = colorScheme.secondary, + error = colorScheme.error, + onError = colorScheme.onError, + isLight = !darkTheme, + ) + + MaterialTheme( + colors = m2colors, + content = content + ) +} 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 f6e7c28..c94a66a 100644 --- a/app/src/main/java/net/helcel/fidelity/activity/MainActivity.kt +++ b/app/src/main/java/net/helcel/fidelity/activity/MainActivity.kt @@ -1,65 +1,62 @@ package net.helcel.fidelity.activity import android.annotation.SuppressLint -import android.content.Context -import android.content.SharedPreferences import android.content.pm.ActivityInfo import android.os.Bundle -import androidx.activity.addCallback -import androidx.appcompat.app.AppCompatActivity -import net.helcel.fidelity.R -import net.helcel.fidelity.activity.fragment.Launcher -import net.helcel.fidelity.activity.fragment.ViewEntry -import net.helcel.fidelity.databinding.ActMainBinding -import net.helcel.fidelity.pluginSDK.Kp2aControl.getEntryFieldsFromIntent -import net.helcel.fidelity.tools.CacheManager -import net.helcel.fidelity.tools.KeepassWrapper.bundleCreate -import net.helcel.fidelity.tools.KeepassWrapper.entryExtract - -@SuppressLint("SourceLockedOrientationActivity") -class MainActivity : AppCompatActivity() { - - private lateinit var binding: ActMainBinding - private lateinit var sharedPreferences: SharedPreferences +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.fragment.app.FragmentActivity +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import net.helcel.fidelity.activity.fragment.CreateEntryScreen +import net.helcel.fidelity.activity.fragment.FileScanner +import net.helcel.fidelity.activity.fragment.InitialScreen +import net.helcel.fidelity.activity.fragment.LauncherScreen +import net.helcel.fidelity.activity.fragment.ScannerScreen +import net.helcel.fidelity.activity.fragment.ViewEntryScreen +import net.helcel.fidelity.tools.FidelityRepository.entries +import net.helcel.fidelity.tools.FidelityRepository.loadEntries +import net.helcel.fidelity.tools.KeePassStore.hasCredentials +class MainActivity : FragmentActivity() { + @SuppressLint("SourceLockedOrientationActivity") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - sharedPreferences = - this.getSharedPreferences(CacheManager.PREF_NAME, Context.MODE_PRIVATE) - CacheManager.loadFidelity(sharedPreferences) + actionBar?.hide() + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + loadEntries(this.baseContext) - binding = ActMainBinding.inflate(layoutInflater) - setContentView(binding.root) - onBackPressedDispatcher.addCallback(this) { - if (supportFragmentManager.backStackEntryCount > 0) { - supportFragmentManager.popBackStackImmediate() - loadLauncher() - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } else { - finish() + setContent { + SysTheme { + val navController = rememberNavController() + val context = LocalContext.current + + BackHandler { + if (!navController.popBackStack()) finish() + } + LaunchedEffect(Unit) { + if(!hasCredentials(context)) navController.navigate("init") + } + NavHost(navController = navController, startDestination = "launcher") { + composable("exit") { finish() } + composable("launcher") { LauncherScreen(navController) } + composable("init"){ InitialScreen (navController)} + composable("scanCam") { ScannerScreen(navController) } + composable("scanFile") { FileScanner(navController) } + composable("edit"){ CreateEntryScreen(navController) } + composable("view/{entryId}") { e -> + val entry = entries.find { + it.uid == (e.arguments?.getString("entryId") ?: "") + } + if (entry == null) return@composable navController.navigate("launcher") + ViewEntryScreen(navController,entry) + } + } } } - - if (intent.extras != null) - loadViewEntry() - else if (savedInstanceState == null) - loadLauncher() - } - - private fun loadLauncher() { - supportFragmentManager.beginTransaction() - .replace(R.id.container, Launcher()) - .commit() - } - - private fun loadViewEntry() { - val viewEntry = ViewEntry() - val data = getEntryFieldsFromIntent(intent) - viewEntry.arguments = bundleCreate(entryExtract(data)) - supportFragmentManager.beginTransaction() - .replace(R.id.container, viewEntry) - .commit() } } - diff --git a/app/src/main/java/net/helcel/fidelity/activity/adapter/FidelityListAdapter.kt b/app/src/main/java/net/helcel/fidelity/activity/adapter/FidelityListAdapter.kt deleted file mode 100644 index 2827e8c..0000000 --- a/app/src/main/java/net/helcel/fidelity/activity/adapter/FidelityListAdapter.kt +++ /dev/null @@ -1,49 +0,0 @@ -package net.helcel.fidelity.activity.adapter - - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import android.widget.LinearLayout -import androidx.recyclerview.widget.RecyclerView -import net.helcel.fidelity.databinding.ListItemFidelityBinding - -class FidelityListAdapter( - private val triples: ArrayList>, - private val onItemClicked: (Triple) -> Unit -) : - RecyclerView.Adapter() { - - private lateinit var binding: ListItemFidelityBinding - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FidelityViewHolder { - binding = ListItemFidelityBinding.inflate(LayoutInflater.from(parent.context)) - binding.root.setLayoutParams( - LinearLayout.LayoutParams( - MATCH_PARENT, WRAP_CONTENT - ) - ) - return FidelityViewHolder(binding.root) - } - - override fun onBindViewHolder(holder: FidelityViewHolder, position: Int) { - val triple = triples[position] - holder.bind(triple) - } - - override fun getItemCount(): Int = triples.size - - inner class FidelityViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - - fun bind(triple: Triple) { - val text = "${triple.first}" - binding.textView.text = text - binding.card.setOnClickListener { onItemClicked(triple) } - } - - - } - -} 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 158a80a..c04e156 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,167 +1,370 @@ package net.helcel.fidelity.activity.fragment -import android.content.ActivityNotFoundException -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import android.widget.ArrayAdapter -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import com.google.android.material.textfield.TextInputEditText +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.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.width +import androidx.compose.material.Button +import androidx.compose.material.Checkbox +import androidx.compose.material.CheckboxDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ExposedDropdownMenuBox +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.FileOpen +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController import com.google.zxing.FormatException +import com.kunzisoft.keepass.database.element.Entry +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import net.helcel.fidelity.R -import net.helcel.fidelity.databinding.FragCreateEntryBinding -import net.helcel.fidelity.pluginSDK.Kp2aControl +import net.helcel.fidelity.activity.ToastHelper +import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onCameraScan +import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onFileScan +import net.helcel.fidelity.activity.fragment.CreateEntryEventHandler.onSubmit +import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh +import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onSave import net.helcel.fidelity.tools.BarcodeGenerator.generateBarcode -import net.helcel.fidelity.tools.CacheManager -import net.helcel.fidelity.tools.ErrorToaster -import net.helcel.fidelity.tools.KeepassWrapper - -private const val DEBOUNCE_DELAY = 500L - -class CreateEntry : Fragment() { - - private val handler = Handler(Looper.getMainLooper()) - private lateinit var binding: FragCreateEntryBinding - - private val resultLauncherAdd = KeepassWrapper.resultLauncher(this) { - val r = KeepassWrapper.entryExtract(it) - if (!KeepassWrapper.isProtected(it)) { - CacheManager.addFidelity(r) - } - startViewEntry(r.first, r.second, r.third) - } - - private var isValidBarcode: Boolean = false - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragCreateEntryBinding.inflate(layoutInflater) - - val formats = resources.getStringArray(R.array.format_array) - val arrayAdapter = ArrayAdapter(requireContext(), R.layout.list_item_dropdown, formats) - binding.editTextFormat.setAdapter(arrayAdapter) - - val res = KeepassWrapper.bundleExtract(arguments) - binding.editTextCode.setText(res.second) - binding.editTextFormat.setText(res.third, false) - - binding.editTextCode.addTextChangedListener { changeListener() } - binding.editTextFormat.addTextChangedListener { changeListener() } - binding.editTextFormat.addTextChangedListener { binding.editTextFormat.error = null } - binding.btnSave.setOnClickListener { submit() } - - binding.editTextTitle.onDone { submit() } - binding.editTextCode.onDone { submit() } +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 - updatePreview() - return binding.root - } +@Preview +@Composable +fun CreateEntryScreen(navController: NavHostController?) { + var entry by remember { activeEntry } + var errorTitle by remember { mutableStateOf("") } + var errorCode by remember { mutableStateOf("") } + var errorFormat by remember { mutableStateOf("") } - private fun updatePreview() { - try { - val barcodeBitmap = generateBarcode( - binding.editTextCode.text.toString(), - binding.editTextFormat.text.toString(), - 600 - ) - binding.imageViewPreview.setImageBitmap(barcodeBitmap) - isValidBarcode = true - } catch (e: FormatException) { - binding.imageViewPreview.setImageBitmap(null) - binding.editTextCode.error = "Invalid format" - } catch (e: IllegalArgumentException) { - binding.imageViewPreview.setImageBitmap(null) - binding.editTextCode.error = e.message - } catch (e: Exception) { - binding.imageViewPreview.setImageBitmap(null) - e.printStackTrace() - } - } + var barcodeBitmap by remember { mutableStateOf(null) } + var isValidBarcode by remember { mutableStateOf(false) } + var showDialog by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(false) } + val ctx = LocalContext.current + val scope = rememberCoroutineScope() - private fun isValidForm(): Boolean { - var valid = true - if (binding.editTextFormat.text.isNullOrEmpty()) { - valid = false - binding.editTextFormat.error = "Format cannot be empty" - } - if (binding.editTextCode.text.isNullOrEmpty()) { - valid = false - binding.editTextCode.error = "Code cannot be empty" - } - if (binding.editTextTitle.text.isNullOrEmpty()) { - valid = false - binding.editTextTitle.error = "Title cannot be empty" - } - return valid - } - - - private fun startViewEntry(title: String?, code: String?, fmt: String?) { - val viewEntryFragment = ViewEntry() - viewEntryFragment.arguments = KeepassWrapper.bundleCreate(title, code, fmt) - - requireActivity().supportFragmentManager.beginTransaction() - .replace(R.id.container, viewEntryFragment).commit() - } - - - private fun changeListener() { + LaunchedEffect(entry) { isValidBarcode = false - handler.removeCallbacksAndMessages(null) - handler.postDelayed({ - updatePreview() - }, DEBOUNCE_DELAY) - } - - - private fun TextInputEditText.onDone(callback: () -> Unit) { - setOnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_DONE) { - callback.invoke() - return@setOnEditorActionListener true - } - false + delay(500) + if (entry.code.isEmpty()) return@LaunchedEffect + try { + val bmp = generateBarcode(entry.code, entry.format, 600) + barcodeBitmap = bmp + isValidBarcode = true + errorCode = "" + } catch (_: FormatException) { + barcodeBitmap = null + errorCode = "Invalid Format" + } catch (e: IllegalArgumentException) { + barcodeBitmap = null + errorCode = if (e.message == "com.google.zxing.FormatException") "Invalid Format" + else e.message ?: "Invalid Argument" + } catch (e: Exception) { + barcodeBitmap = null + ToastHelper.show(ctx, e.message ?: e.toString()) } } - private fun submit() { - if (!isValidForm() || !isValidBarcode) { - ErrorToaster.formIncomplete(context) - } else { - val kpEntry = KeepassWrapper.entryCreate( - this, - binding.editTextTitle.text.toString(), - binding.editTextCode.text.toString(), - binding.editTextFormat.text.toString(), - binding.checkboxProtected.isChecked, + 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) + } + } + } + ) + } + val formats = stringArrayResource(R.array.format_array) + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.background) + ) { + Column( + modifier = Modifier + .padding(16.dp, 32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) + { + OutlinedTextField( + value = entry.title, + enabled = entry.uid!=null, + onValueChange = { + entry = entry.copy(title = it) + errorTitle = "" + }, + label = { Text(text = "Title") }, + isError = errorTitle.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = TextFieldDefaults.textFieldColors( + textColor = if(entry.uid!=null)MaterialTheme.colors.onBackground + else MaterialTheme.colors.secondary + ), ) - try { - resultLauncherAdd.launch( - Kp2aControl.getAddEntryIntent( - kpEntry.first, - kpEntry.second - ) + if (errorTitle.isNotEmpty()) { + Text(errorTitle, color = MaterialTheme.colors.error) + } + + OutlinedTextField( + value = entry.code, + onValueChange = { + entry = entry.copy(code = it) + errorCode = "" + }, + colors = TextFieldDefaults.textFieldColors( + textColor = MaterialTheme.colors.onBackground + ), + label = { Text("Code") }, + isError = errorCode.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + if (errorCode.isNotEmpty()) { + Text(errorCode, color = MaterialTheme.colors.error) + } + + FormatDropdown( + formats, + entry.format, + errorFormat.ifEmpty { null }, + ) { + entry = entry.copy(format = it) + errorFormat = "" + } + if (errorFormat.isNotEmpty()) { + Text(errorFormat, color = MaterialTheme.colors.error) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Checkbox( + checked = entry.protected, + onCheckedChange = { + entry = entry.copy(protected = it) + }, + colors = CheckboxDefaults.colors() ) - } catch (e: ActivityNotFoundException) { - ErrorToaster.noKP2AFound(context) - } catch (e: Exception) { - e.printStackTrace() + Text("Protected", color = MaterialTheme.colors.onBackground) + + Spacer(modifier = Modifier.weight(1f)) + Button(onClick = { onCameraScan(navController!!) }) { + Icon(Icons.Default.Camera, contentDescription = null) + } + Spacer(modifier = Modifier.width(8.dp)) + Button(onClick = { onFileScan(navController!!) }) { + Icon(Icons.Default.FileOpen, contentDescription = null) + } } - if (!binding.checkboxProtected.isChecked) { - val r = KeepassWrapper.entryExtract(kpEntry.first) - CacheManager.addFidelity(r) + if (barcodeBitmap != null) { + Image( + bitmap = barcodeBitmap!!.asImageBitmap(), + contentDescription = "Barcode preview", + modifier = Modifier + .fillMaxWidth() + .height(150.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + } + + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = { + onSubmitIfValid( + entry, + setErrors = { t, c, f -> + errorTitle = t + errorCode = c + errorFormat = f + }, + isValidBarcode + ) { + if (FidelityRepository.getRoot() == null) { + isLoading = true + scope.launch { + onRefresh(ctx, navController!!) + isLoading = false + if(entry.uid!=null){ + addEntry(ctx,entry) + isLoading = true + onSave(ctx,navController) + isLoading = false + onSubmit(navController) + }else { + showDialog = true + } + } + } else { + if(entry.uid!=null){ + addEntry(ctx,entry) + isLoading = true + scope.launch { + onSave(ctx, navController!!) + isLoading = false + onSubmit(navController) + } + }else { + showDialog = true + } + } + } + }, + enabled = isValidBarcode.and(entry.uid==null || entry.title.isNotEmpty()), + ) { + Text(if(entry.uid==null)"Select Entry" else "Save", style = MaterialTheme.typography.h6) + } + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.background.copy(alpha = 0.75f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { } + ), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } - activity?.supportFragmentManager?.popBackStack() } } +} +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun FormatDropdown( + formats: Array, + format: String, + errorFormat: String?, + onFormatChange: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = format, + onValueChange = {}, + readOnly = true, // important for dropdown + label = { Text("Format", color=MaterialTheme.colors.onBackground) }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = "Expand", + ) + }, + colors = TextFieldDefaults.textFieldColors( + textColor = MaterialTheme.colors.onBackground + ), + isError = errorFormat != null, + modifier = Modifier.fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + formats.forEach { option -> + DropdownMenuItem( + onClick = { + onFormatChange(option) + expanded = false + } + ) { + Text(option) + } + } + } + } +} + + + +private fun onSubmitIfValid( + entry: FidelityEntry, + setErrors: (String, String, String) -> Unit, + isValidBarcode: Boolean, + onValid: (FidelityEntry) -> Unit +) { + var tErr = "" + var cErr = "" + var fErr = "" + if (entry.uid!=null && entry.title.isBlank()) tErr = "Title cannot be empty" + if (entry.code.isBlank()) cErr = "Code cannot be empty" + if (entry.format.isBlank()) fErr = "Format cannot be empty" + + setErrors(tErr, cErr, fErr) + + if (tErr.isEmpty() && cErr.isEmpty() && fErr.isEmpty() && isValidBarcode) { + onValid(entry.copy()) + } +} + +object CreateEntryEventHandler { + fun onSubmit(navController: NavHostController){ + navController.popBackStack() + activeEntry.value = activeEntry.value.copy(null,"","","",false) + } + + fun onFileScan(navController: NavHostController){ + navController.navigate("scanFile") + } + fun onCameraScan(navController: NavHostController){ + navController.navigate("scanCam") + } } \ No newline at end of file diff --git a/app/src/main/java/net/helcel/fidelity/activity/fragment/FileScanner.kt b/app/src/main/java/net/helcel/fidelity/activity/fragment/FileScanner.kt deleted file mode 100644 index 9e4437c..0000000 --- a/app/src/main/java/net/helcel/fidelity/activity/fragment/FileScanner.kt +++ /dev/null @@ -1,107 +0,0 @@ -package net.helcel.fidelity.activity.fragment - -import android.Manifest -import android.graphics.BitmapFactory -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.Fragment -import net.helcel.fidelity.R -import net.helcel.fidelity.tools.BarcodeScanner -import net.helcel.fidelity.tools.ErrorToaster -import net.helcel.fidelity.tools.KeepassWrapper -import java.io.FileNotFoundException - -class FileScanner : Fragment() { - - private var code: String = "" - private var fmt: String = "" - - - private val resultPermission = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { - resultLauncherOpenMediaPick.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) - } - - private val resultLauncherOpenMediaBase = - registerForActivityResult(ActivityResultContracts.GetContent()) { - loadUri(it) - } - - private val resultLauncherOpenMediaPick = - registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { - loadUri(it) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - println(Build.VERSION.SDK_INT) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - resultPermission.launch(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) - } else { - // resultLauncherOpenMediaBase.launch("image/*") - resultLauncherOpenMediaPick.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) - } - return View(context) - } - - private fun startCreateEntry() { - val createEntryFragment = CreateEntry() - createEntryFragment.arguments = - KeepassWrapper.bundleCreate(null, this.code, this.fmt) - requireActivity().supportFragmentManager.beginTransaction() - .replace(R.id.container, createEntryFragment) - .commit() - } - - private fun scannerResult(code: String?, format: String?) { - if (!code.isNullOrEmpty() && !format.isNullOrEmpty()) { - this.code = code - this.fmt = format - } - val isDone = this.code.isNotEmpty() && this.fmt.isNotEmpty() - requireActivity().runOnUiThread { - if (isDone) { - startCreateEntry() - } else { - parentFragmentManager.popBackStack() - ErrorToaster.nothingFound(context) - } - } - } - - private fun loadUri(it: Uri?) { - try { - run { - require(it != null) - - val file = requireContext().contentResolver.openInputStream(it) - val image = BitmapFactory.decodeStream(file) - BarcodeScanner.bitmapUseCase(image) { code, format -> - scannerResult(code, format) - } - } - } catch (e: FileNotFoundException) { - e.printStackTrace() - println(e.message) - println(it) - ErrorToaster.noPermission(context) - parentFragmentManager.popBackStack() - } catch (e: IllegalArgumentException) { - ErrorToaster.nothingFound(context) - parentFragmentManager.popBackStack() - } catch (e: SecurityException) { - ErrorToaster.noPermission(context) - parentFragmentManager.popBackStack() - } - } - -} \ No newline at end of file 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 54ce24e..f3f2279 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,136 +1,345 @@ package net.helcel.fidelity.activity.fragment -import android.content.ActivityNotFoundException -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import net.helcel.fidelity.R -import net.helcel.fidelity.activity.adapter.FidelityListAdapter -import net.helcel.fidelity.databinding.FragLauncherBinding -import net.helcel.fidelity.pluginSDK.Kp2aControl -import net.helcel.fidelity.tools.CacheManager -import net.helcel.fidelity.tools.ErrorToaster -import net.helcel.fidelity.tools.KeepassWrapper +import android.content.Context +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 +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.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.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.HideSource +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onAdd +import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onEdit +import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onHide +import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onPin +import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onQuery +import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onRefresh +import net.helcel.fidelity.activity.fragment.LauncherEventHandlers.onView +import net.helcel.fidelity.tools.CredentialResult +import net.helcel.fidelity.tools.FidelityEntry +import net.helcel.fidelity.tools.FidelityRepository.activeEntry +import net.helcel.fidelity.tools.FidelityRepository.end +import net.helcel.fidelity.tools.FidelityRepository.entries +import net.helcel.fidelity.tools.FidelityRepository.genCredentials +import net.helcel.fidelity.tools.FidelityRepository.importDB +import net.helcel.fidelity.tools.FidelityRepository.start +import net.helcel.fidelity.tools.KeePassStore.loadCredentials - -class Launcher : Fragment() { - - private lateinit var binding: FragLauncherBinding - private lateinit var fidelityListAdapter: FidelityListAdapter - - private val resultLauncherQuery = KeepassWrapper.resultLauncher(this) { - val r = KeepassWrapper.entryExtract(it) - if (!KeepassWrapper.isProtected(it)) { - CacheManager.addFidelity(r) - } - startViewEntry(r.first, r.second, r.third) - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragLauncherBinding.inflate(layoutInflater) - binding.btnQuery.setOnClickListener { startGetFromKeepass() } - binding.btnAdd.setOnClickListener { - if (binding.menuAdd.visibility == View.GONE) - showMenuAdd() - else - hideMenuAdd() - } - - hideMenuAdd() - binding.btnScan.setOnClickListener { - startScanner() - hideMenuAdd() - } - binding.btnOpen.setOnClickListener { - startFileScanner() - hideMenuAdd() - } - - - binding.btnManual.setOnClickListener { - startCreateEntry() - hideMenuAdd() - } - - binding.fidelityList.layoutManager = - LinearLayoutManager(requireContext()) - fidelityListAdapter = FidelityListAdapter(CacheManager.getFidelity()) { - startViewEntry(it.first, it.second, it.third) - } - binding.fidelityList.adapter = fidelityListAdapter - - recyclerSlideHelper().attachToRecyclerView(binding.fidelityList) - return binding.root - } - - private fun hideMenuAdd() { - binding.btnAdd.setImageResource(R.drawable.cross) - binding.menuAdd.visibility = View.GONE - - } - - private fun showMenuAdd() { - binding.btnAdd.setImageResource(R.drawable.minus) - binding.menuAdd.visibility = View.VISIBLE - } - - - private fun startGetFromKeepass() { - try { - this.resultLauncherQuery.launch(Kp2aControl.getQueryEntryForOwnPackageIntent()) - } catch (e: ActivityNotFoundException) { - ErrorToaster.noKP2AFound(requireActivity()) +@Preview +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LauncherScreen( + navController: NavHostController?, +) { + if(navController==null) return + var isRefreshingState by remember { mutableStateOf(false) } + var showHidden by remember { mutableStateOf(false) } + val context = LocalContext.current + val scope = rememberCoroutineScope() + val sortedEntries = remember(entries) { + derivedStateOf { + entries.filter{showHidden || !it.hidden}.sortedWith( + compareByDescending { it.pinned } + .thenBy { it.hidden } + .thenByDescending { it.lastUse } + ) } } - private fun startFragment(fragment: Fragment) { - requireActivity().supportFragmentManager.beginTransaction() - .addToBackStack("Launcher") - .replace(R.id.container, fragment).commit() - } - private fun startScanner() { - startFragment(Scanner()) - } + Box(modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.background)) { - private fun startFileScanner() { - startFragment(FileScanner()) - } - - private fun startCreateEntry() { - startFragment(CreateEntry()) - } - - - private fun startViewEntry(title: String?, code: String?, fmt: String?) { - val viewEntryFragment = ViewEntry() - viewEntryFragment.arguments = KeepassWrapper.bundleCreate(title, code, fmt) - startFragment(viewEntryFragment) - } - - private fun recyclerSlideHelper(): ItemTouchHelper { - return ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( - 0, ItemTouchHelper.LEFT + PullToRefreshBox( + onRefresh = { + isRefreshingState = true + scope.launch { + onRefresh(context, navController) + isRefreshingState = false + } + }, + isRefreshing = isRefreshingState, + modifier = Modifier.fillMaxSize() ) { - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean = false - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - val pos = viewHolder.adapterPosition - CacheManager.rmFidelity(pos) - fidelityListAdapter.notifyItemRemoved(pos) + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxSize() + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(sortedEntries.value) { entry -> + FidelityRow(navController, entry) + } } - }) + FloatingActionButton( + onClick = { onQuery() }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), + ) { + Icon( + Icons.Default.Search, + contentDescription = "Query", + modifier = Modifier.size(32.dp) + ) + } + FloatingActionButton( + onClick = { onAdd(navController) }, modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + ) { + Icon(Icons.Default.Add, contentDescription = "Add") + } + FloatingActionButton( + onClick = { + showHidden=!showHidden + }, modifier = Modifier + .align(Alignment.BottomStart) + .padding(16.dp).size(24.dp), + backgroundColor = if(showHidden) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary, + ) { + Icon(Icons.Default.HideSource, + tint= if(showHidden) MaterialTheme.colors.background else MaterialTheme.colors.onSecondary, + contentDescription = "Show Hidden") + } + } + + if (isRefreshingState) + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.background.copy(alpha = 0.75f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { } + ) + ) + } + +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun FidelityRow( + navController: NavHostController, + e: FidelityEntry +) { + var expanded by remember { mutableStateOf(false) } + + Box(modifier = Modifier.fillMaxWidth()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp) + .combinedClickable( + onClick = { onView(navController, e) }, + onLongClick = { expanded = true }, + ), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colors.primary, + contentColor = MaterialTheme.colors.background + ), + ) { + Box(modifier = Modifier.fillMaxSize().padding(2.dp)) { + Row(modifier = Modifier.padding(14.dp)) { + Text( + text = e.title, + style = MaterialTheme.typography.h6, + color = MaterialTheme.colors.onPrimary + ) + } + Row(modifier = Modifier.align(Alignment.TopEnd)) { + if (e.hidden) + Icon( + Icons.Default.HideSource, contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colors.onPrimary + ) + if (e.hidden && e.pinned) + Spacer(modifier = Modifier.width(8.dp)) + if (e.pinned) + Icon( + Icons.Default.PushPin, contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colors.onPrimary + ) + + } + } + } + DropdownMenu( + modifier = Modifier, + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem(onClick = { + expanded = false + onEdit(navController, e) + }) { + Icon( + Icons.Default.Edit, + contentDescription = "edit", + ) + Spacer(modifier= Modifier.width(8.dp)) + Text("Edit") + } + DropdownMenuItem(onClick = { + expanded = false + onPin(e) + }) { + Icon( + Icons.Default.PushPin, + contentDescription = "pin", + ) + Spacer(modifier= Modifier.width(8.dp)) + if(e.pinned) Text("Unpin") + else Text("Pin") + } + DropdownMenuItem(onClick = { + expanded = false + onHide(e) + }) { + Icon( + Icons.Default.HideSource, + contentDescription = "hide", + ) + Spacer(modifier= Modifier.width(8.dp)) + if(e.hidden) Text("Unhide") + else Text("Hide") + } + } + } +} + + +object LauncherEventHandlers { + fun onAdd(navController: NavHostController) { + navController.navigate("edit") + } + + fun onQuery() { + //TODO + } + var CRED: CredentialResult.Success? = null + + suspend fun onSave(context: Context, navController: NavHostController){ + try { + if (CRED == null) { + val res = loadCredentials(context) + when (res) { + CredentialResult.AuthFailed, CredentialResult.NoData -> null + is CredentialResult.Success -> CRED = res + } + } + CRED!! + val cred = withContext(Dispatchers.IO) { + genCredentials(context, CRED!!) + } + if (withContext(Dispatchers.IO) { + end(context, CRED!!.db, cred) + }) + throw Exception("Error in saving") + } catch (e: Exception) { + println(e.toString()) + navController.navigate("init") + } + } + + suspend fun onRefresh(context: Context, navController: NavHostController) { + try { + if (CRED == null) { + val res = loadCredentials(context) + when (res) { + CredentialResult.AuthFailed, CredentialResult.NoData -> null + is CredentialResult.Success -> CRED = res + + } + } + CRED!! + val cred = withContext(Dispatchers.IO) { + genCredentials(context, CRED!!) + } + if (withContext(Dispatchers.IO) { + start(context, CRED!!.db, cred) + }) + importDB(context) + } catch (e: Exception) { + println(e.toString()) + navController.navigate("init") + } + } + + fun onView(navController: NavHostController, entry: FidelityEntry) { + navController.navigate("view/${entry.uid}") + val index = entries.indexOfFirst { it.uid == entry.uid } + if (index != -1) + entries[index] = entry.copy(lastUse = System.currentTimeMillis().toInt()) + + } + + fun onPin(entry: FidelityEntry){ + val index = entries.indexOfFirst { it.uid == entry.uid } + if (index != -1) + entries[index] = entry.copy(pinned = !entry.pinned) + } + + fun onHide(entry: FidelityEntry){ + val index = entries.indexOfFirst { it.uid == entry.uid } + if (index != -1) + entries[index] = entry.copy(hidden = !entry.hidden) + } + + fun onEdit(navController: NavHostController, entry: FidelityEntry){ + activeEntry.value = entry + navController.navigate("edit") } } \ No newline at end of file diff --git a/app/src/main/java/net/helcel/fidelity/activity/fragment/Scanner.kt b/app/src/main/java/net/helcel/fidelity/activity/fragment/Scanner.kt index f7fe055..07f9008 100644 --- a/app/src/main/java/net/helcel/fidelity/activity/fragment/Scanner.kt +++ b/app/src/main/java/net/helcel/fidelity/activity/fragment/Scanner.kt @@ -1,105 +1,224 @@ +@file:Suppress("PreviewAnnotationInFunctionWithParameters", + "PreviewAnnotationInFunctionWithParameters" +) + package net.helcel.fidelity.activity.fragment import android.Manifest -import android.content.ContentValues -import android.os.Bundle +import android.graphics.BitmapFactory import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import net.helcel.fidelity.R -import net.helcel.fidelity.databinding.FragScannerBinding +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigation.NavController +import androidx.navigation.NavHostController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.helcel.fidelity.activity.fragment.ScannerEventHandler.onResult +import net.helcel.fidelity.tools.BarcodeScanner import net.helcel.fidelity.tools.BarcodeScanner.analysisUseCase -import net.helcel.fidelity.tools.ErrorToaster -import net.helcel.fidelity.tools.KeepassWrapper +import net.helcel.fidelity.tools.FidelityRepository.activeEntry -class Scanner : Fragment() { +@androidx.compose.ui.tooling.preview.Preview +@Composable +fun ScannerScreen( + navController: NavController +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() - private lateinit var binding: FragScannerBinding - - private var code: String = "" - private var fmt: String = "" - - - private val resultPermissionRequest = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { - if (it) { - bindCameraUseCases() - } else { - parentFragmentManager.popBackStack() - ErrorToaster.noPermission(context) - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragScannerBinding.inflate(layoutInflater) - binding.btnScanDone.setOnClickListener { - startCreateEntry() - } - binding.btnScanDone.isEnabled = false - resultPermissionRequest.launch(Manifest.permission.CAMERA) - return binding.root + val cameraProviderFuture = remember { + ProcessCameraProvider.getInstance(context) } + var camera: Camera? by remember { mutableStateOf(null) } + var torchOn by remember { mutableStateOf(false) } + val done = remember { mutableStateOf(false) } + val previewView = remember { PreviewView(context) } - private fun startCreateEntry() { - val createEntryFragment = CreateEntry() - createEntryFragment.arguments = - KeepassWrapper.bundleCreate(null, this.code, this.fmt) - requireActivity().supportFragmentManager.beginTransaction() - .replace(R.id.container, createEntryFragment) - .commit() - } - - - private fun scannerResult(code: String?, format: String?) { - if (!code.isNullOrEmpty() && !format.isNullOrEmpty()) { - this.code = code - this.fmt = format - } - val isDone = this.code.isNotEmpty() && this.fmt.isNotEmpty() - activity?.runOnUiThread { - binding.btnScanDone.isEnabled = isDone - binding.ScanActive.isEnabled = !isDone - } - } - - private fun bindCameraUseCases() { - val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext()) - - cameraProviderFuture.addListener({ - val cameraProvider = cameraProviderFuture.get() - val previewUseCase = Preview.Builder() - .build() - .also { - it.setSurfaceProvider(binding.cameraView.surfaceProvider) + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { granted -> + if (granted) { + val cameraProvider = cameraProviderFuture.get() + val previewUseCase = Preview.Builder().build().also { + it.surfaceProvider = previewView.surfaceProvider + } + val analysisUseCase = analysisUseCase { detectedCode, detectedFormat -> + if (detectedCode.isNullOrEmpty() || detectedFormat.isNullOrEmpty()) return@analysisUseCase + if(done.value) return@analysisUseCase + scope.launch(Dispatchers.Main) { + activeEntry.value = + activeEntry.value.copy(code = detectedCode, format = detectedFormat) + done.value = true + onResult(navController) + } + return@analysisUseCase + } + try { + cameraProvider.unbindAll() + camera = cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + previewUseCase, + analysisUseCase + ) + } catch (e: Exception) { + Log.e("ScannerScreen", "Camera bind failed: ${e.message}") + } + } else { + Toast.makeText(context, "Camera permission denied", Toast.LENGTH_SHORT).show() + scope.launch(Dispatchers.Main){ + onResult(navController) } - val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA - val analysisUseCase = analysisUseCase { code, format -> - scannerResult(code, format) } - try { - cameraProvider.bindToLifecycle( - this, - cameraSelector, - previewUseCase, - analysisUseCase - ) - } catch (illegalStateException: IllegalStateException) { - Log.e(ContentValues.TAG, illegalStateException.message.orEmpty()) - } catch (illegalArgumentException: IllegalArgumentException) { - Log.e(ContentValues.TAG, illegalArgumentException.message.orEmpty()) - } - }, ContextCompat.getMainExecutor(requireContext())) + } + ) + + LaunchedEffect(Unit) { + permissionLauncher.launch(Manifest.permission.CAMERA) } -} \ No newline at end of file + + Box(modifier = Modifier.fillMaxSize()) { + AndroidView( + factory = { previewView }, + modifier = Modifier.fillMaxSize() + ) + ScannerOverlay( + modifier = Modifier.fillMaxSize() + ) + Button(onClick = { + torchOn = !torchOn + camera?.cameraControl?.enableTorch(torchOn) + }, modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) { + Icon(Icons.Default.FlashOn, contentDescription = null) + } + + if(!done.value) + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.BottomCenter) // same spot as buttons + .padding(bottom =80.dp), + ) + } +} + + +@Composable +fun ScannerOverlay( + modifier: Modifier = Modifier +) { + Canvas(modifier = modifier.fillMaxSize()) { + val widthF = size.width + val heightF = size.height + + drawRect( + color = Color(0x80000000), // semi-transparent black + size = size + ) + + val squareSize = 0.75f * minOf(widthF, heightF) + val left = (widthF - squareSize) / 2 + val top = (heightF - squareSize) / 2 + + drawRect( + color = Color.Transparent, + topLeft = Offset(left, top), + size = Size(squareSize, squareSize), + blendMode = BlendMode.Clear + ) + } +} + + +@Composable +fun FileScanner(navController: NavHostController) { + val context = LocalContext.current + + rememberCoroutineScope() + val pickImageLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + if (uri == null) { + Toast.makeText(context, "No file selected", Toast.LENGTH_SHORT).show() + onResult(navController) + return@rememberLauncherForActivityResult + } + try { + val inputStream = context.contentResolver.openInputStream(uri) + val bitmap = BitmapFactory.decodeStream(inputStream) + BarcodeScanner.bitmapUseCase(bitmap) { code, format -> + if (!code.isNullOrEmpty() && !format.isNullOrEmpty()) { + activeEntry.value = activeEntry.value.copy(code=code, format=format) + onResult(navController) + } else { + Toast.makeText(context, "No barcode found", Toast.LENGTH_SHORT).show() + onResult(navController) + } + } + } catch (e: Exception) { + e.printStackTrace() + Toast.makeText(context, "Failed to load image", Toast.LENGTH_SHORT).show() + onResult(navController) + } + + } + + LaunchedEffect(Unit) { + pickImageLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } + + BackHandler { + onResult(navController) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +object ScannerEventHandler { + fun onResult(navController: NavController) { + navController.popBackStack() + } +} 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 new file mode 100644 index 0000000..046ff6c --- /dev/null +++ b/app/src/main/java/net/helcel/fidelity/activity/fragment/SelectEntry.kt @@ -0,0 +1,144 @@ +package net.helcel.fidelity.activity.fragment + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.kunzisoft.keepass.database.element.Group +import com.kunzisoft.keepass.database.element.node.Node +import net.helcel.fidelity.tools.FidelityRepository + +@Preview +@Composable +fun TreeSelectorDialog(onDismiss: (Node?) -> Unit = {}) { + Dialog( + onDismissRequest = {onDismiss(null)}, + content = { + Column( + modifier = Modifier.fillMaxWidth().background( + MaterialTheme.colors.background, + RoundedCornerShape(8.dp) + ) + ) { + var currentRoot by remember { mutableStateOf(FidelityRepository.getRoot()) } + var selection by remember { mutableStateOf(FidelityRepository.getRoot()) } + + Column( + modifier = Modifier.fillMaxWidth().padding(8.dp) + ) { + + Row(verticalAlignment = Alignment.CenterVertically) { + Button( + onClick = { + selection = currentRoot + currentRoot = currentRoot?.parent + }, + enabled = currentRoot?.parent != null + ) { + Icon(Icons.AutoMirrored.Filled.Undo, contentDescription = "up") + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + currentRoot?.title ?: "?", + color = MaterialTheme.colors.onBackground, + style = MaterialTheme.typography.h6 + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Spacer(modifier = Modifier.height(8.dp)) + + LazyColumn(modifier = Modifier.fillMaxHeight(0.75f)) { + items(currentRoot?.getChildGroups() ?: emptyList()) { entry -> + val isSel = (entry.nodeId == selection?.nodeId) + Row( + modifier = Modifier + .fillMaxWidth() + .background(color = if (isSel) MaterialTheme.colors.primary else MaterialTheme.colors.background) + .clickable { + if (entry.getChildEntries().isNotEmpty()) { + currentRoot = entry + selection = entry + } else if (entry.getChildGroups().isNotEmpty()) { + currentRoot = entry + selection = entry + } else { + selection = entry + } + } + .padding(8.dp) + ) { + if (entry.getChildEntries().isNotEmpty() || entry.getChildGroups() + .isNotEmpty() + ) { + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = null, + tint = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground + ) + } + Text( + entry.title, + modifier = Modifier.padding(start = 8.dp), + color = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground + ) + } + } + items(currentRoot?.getChildEntries() ?: emptyList()) { entry -> + val isSel = (entry.nodeId == selection?.nodeId) + Row( + modifier = Modifier + .fillMaxWidth() + .background(color = if (isSel) MaterialTheme.colors.primary else MaterialTheme.colors.background) + .clickable { + selection = entry + } + .padding(8.dp) + ) { + Text( + entry.title, + modifier = Modifier.padding(start = 8.dp), + color = if (isSel) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onBackground + ) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + modifier = Modifier.align(Alignment.CenterHorizontally), + enabled = selection != null, + onClick = { + onDismiss(selection) + }) { + Text("Select " + if (selection is Group) "Group" else "Entry") + } + } + } + } + ) +} 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 new file mode 100644 index 0000000..b5f34c7 --- /dev/null +++ b/app/src/main/java/net/helcel/fidelity/activity/fragment/Setup.kt @@ -0,0 +1,274 @@ +package net.helcel.fidelity.activity.fragment + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.OpenDocument +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Button +import androidx.compose.material.Checkbox +import androidx.compose.material.CheckboxDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.helcel.fidelity.activity.ToastHelper +import net.helcel.fidelity.activity.fragment.SetupEventHandlers.onOpen +import net.helcel.fidelity.tools.CredentialResult +import net.helcel.fidelity.tools.FidelityRepository.genCredentials +import net.helcel.fidelity.tools.FidelityRepository.start +import net.helcel.fidelity.tools.KeePassStore.loadCredentials +import net.helcel.fidelity.tools.KeePassStore.packCredentials +import net.helcel.fidelity.tools.KeePassStore.saveCredentials + + +class GetPersistentContent : OpenDocument() { + @SuppressLint("InlinedApi") + override fun createIntent(context: Context, input: Array): Intent { + return super.createIntent(context, input).apply { + addCategory(Intent.CATEGORY_DEFAULT) + addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } + } +} + +@Preview +@Composable +fun InitialScreen( + navController: NavHostController? +) { + var loading by remember { mutableStateOf(false) } + var dbFile by remember { mutableStateOf(null) } + var password by remember { mutableStateOf("") } + var keyFile by remember { mutableStateOf(null) } + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val dbFilePickerLauncher = rememberLauncherForActivityResult( + contract = GetPersistentContent(), + ) { + if(it!=null) { + dbFile = it + scope.launch(Dispatchers.IO) { + context.contentResolver.takePersistableUriPermission( + it, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + } + } + + val keyFilePickerLauncher = rememberLauncherForActivityResult( + contract = GetPersistentContent() + ) { + if(it!=null) { + keyFile = it + scope.launch(Dispatchers.IO) { + context.contentResolver.takePersistableUriPermission( + it, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + } + } + BackHandler { + navController!!.navigate("exit") + } + LaunchedEffect(Unit) { + scope.launch(Dispatchers.Main) { + when(val res = loadCredentials(context)) { + CredentialResult.AuthFailed -> null + CredentialResult.NoData -> null + is CredentialResult.Success -> { + if (res.db != null) dbFile = res.db + if (res.key != null) keyFile = res.key + if (res.password != "" && password == "") password = res.password + } + } + } + } + Box(modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.background)) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .background(MaterialTheme.colors.background), + verticalArrangement = Arrangement.Center + ) { + Text( + "Keypass Database Setup", + style = MaterialTheme.typography.h5, + color = MaterialTheme.colors.onBackground + ) + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text("KDBX Database:", color = MaterialTheme.colors.onBackground) + Spacer(modifier = Modifier.width(8.dp)) + Checkbox( + enabled = !loading, + modifier = Modifier + .background( + MaterialTheme.colors.primary, + RoundedCornerShape(8.dp) + ) + .size(32.dp), + checked = dbFile != null, + onCheckedChange = { dbFilePickerLauncher.launch(arrayOf("*/*")) }, + colors = CheckboxDefaults.colors( + uncheckedColor = MaterialTheme.colors.primary, + checkedColor = MaterialTheme.colors.primary, + checkmarkColor = MaterialTheme.colors.onPrimary + ), + ) + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + enabled = !loading, + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Unspecified, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + colors = TextFieldDefaults.textFieldColors( + textColor = MaterialTheme.colors.onBackground + ), + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text("KDBX Key File:", color = MaterialTheme.colors.onBackground) + Spacer(modifier = Modifier.width(8.dp)) + Checkbox( + enabled = !loading, + modifier = Modifier + .background( + MaterialTheme.colors.primary, + RoundedCornerShape(8.dp) + ) + .size(32.dp), + checked = keyFile != null, + onCheckedChange = { keyFilePickerLauncher.launch(arrayOf("*/*")) }, + colors = CheckboxDefaults.colors( + uncheckedColor = MaterialTheme.colors.primary, + checkedColor = MaterialTheme.colors.primary, + checkmarkColor = MaterialTheme.colors.onPrimary + ), + ) + } + + + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + enabled = !loading && password.isNotBlank() && dbFile != null , + onClick = { + loading = true + scope.launch { + if(onOpen(context, dbFile!!, password, keyFile)){ + navController!!.popBackStack() + navController.navigate("init") + }else{ + ToastHelper.show(context, "Auth failed...") + navController!!.popBackStack() + navController.navigate("exit") + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Continue") + } + } + Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier + .fillMaxSize() + .padding(32.dp)){ + + if(loading ) + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.BottomCenter) // same spot as buttons + .padding(bottom = 80.dp), + ) + } + } +} + +object SetupEventHandlers { + suspend fun onOpen(context: Context, db: Uri, p: String, key: Uri?): Boolean { + try { + val packCred = packCredentials(db, p, key) + withContext(Dispatchers.IO) { + start(context, db, genCredentials(context, packCred) + ) + } + + val res = withContext(Dispatchers.Main) { + saveCredentials(context, packCred) + } + return when (res) { + CredentialResult.AuthFailed, CredentialResult.NoData -> false + is CredentialResult.Success -> true + } + } catch (e: Exception) { + ToastHelper.show(context, e.message.toString()) + println("Err${e.toString()}") + println(e.message) + return false + } + } +} \ No newline at end of file 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 a36e5c5..6baa288 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 @@ -1,86 +1,125 @@ package net.helcel.fidelity.activity.fragment -import android.annotation.SuppressLint -import android.content.pm.ActivityInfo -import android.content.res.Configuration -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL +import android.app.Activity +import android.graphics.Bitmap import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE -import androidx.fragment.app.Fragment -import com.google.zxing.FormatException -import net.helcel.fidelity.databinding.FragViewEntryBinding +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController import net.helcel.fidelity.tools.BarcodeGenerator.generateBarcode -import net.helcel.fidelity.tools.ErrorToaster -import net.helcel.fidelity.tools.KeepassWrapper +import net.helcel.fidelity.tools.FidelityEntry +import kotlin.let +import kotlin.math.min -@SuppressLint("SourceLockedOrientationActivity") -class ViewEntry : Fragment() { - private lateinit var binding: FragViewEntryBinding - private var title: String? = null - private var code: String? = null - private var fmt: String? = null +@Preview +@Composable +fun PreviewEntryScreen(){ + ViewEntryScreen(null, FidelityEntry("Title","AAA","QR")) +} - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragViewEntryBinding.inflate(layoutInflater) - val res = KeepassWrapper.bundleExtract(arguments) - title = res.first - code = res.second - fmt = res.third +@Composable +fun ViewEntryScreen( + navController: NavHostController?, + entry: FidelityEntry +) { + val context = LocalContext.current + val activity = context as? Activity + var isFull by remember { mutableStateOf(false) } + var bitmap by remember { mutableStateOf(null) } - updatePreview() - updateLayout() - - binding.imageViewPreview.setOnClickListener { - requireActivity().requestedOrientation = - if (isLandscape()) ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - else ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + SideEffect { + activity?.window?.attributes = activity.window?.attributes?.apply { + screenBrightness = if (isFull) 1f else BRIGHTNESS_OVERRIDE_NONE } - - return binding.root - } - - private fun updatePreview() { - binding.title.text = title try { - val barcodeBitmap = generateBarcode( - code, fmt, 1024 + bitmap = generateBarcode(entry.code, entry.format, 1024) + } catch (_: Exception) { + bitmap = null + Toast.makeText(context, "Invalid barcode format", Toast.LENGTH_SHORT).show() + } + } + BackHandler { + isFull=false + navController!!.popBackStack() + } + + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .clickable( + onClick = { isFull = !isFull }, + indication = null, // remove ripple effect + interactionSource = remember { MutableInteractionSource() } + ), + contentAlignment = Alignment.TopCenter + ) { + if (!isFull) { + Text( + text = entry.title, + color = Color.White, + style = MaterialTheme.typography.h4, + modifier = Modifier.padding(32.dp) ) - binding.imageViewPreview.setImageBitmap(barcodeBitmap) - } catch (e: FormatException) { - ErrorToaster.invalidFormat(requireActivity()) - binding.imageViewPreview.setImageBitmap(null) - } catch (e: IllegalArgumentException) { - binding.imageViewPreview.setImageBitmap(null) - ErrorToaster.invalidFormat(requireActivity()) - } catch (e: Exception) { - binding.imageViewPreview.setImageBitmap(null) - e.printStackTrace() } } - private fun updateLayout() { - if (isLandscape()) { - binding.title.visibility = View.GONE - setScreenBrightness(BRIGHTNESS_OVERRIDE_FULL) - } else { - binding.title.visibility = View.VISIBLE - setScreenBrightness(BRIGHTNESS_OVERRIDE_NONE) + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize().padding(8.dp), + contentAlignment = Alignment.Center + ) { + bitmap?.let { + + + val modifier = Modifier + .fillMaxSize() + .width(maxWidth) + .height(maxHeight) + .padding(16.dp) + .aspectRatio(it.width.toFloat()/it.height.toFloat()) + .rotate(if (isFull) 90f else 0f) + .scale(if(isFull) min(it.width.dp/maxHeight,it.height.dp/maxWidth) else 1f) + + Image( + bitmap = it.asImageBitmap(), + contentDescription = "Barcode", + modifier = modifier, + contentScale = ContentScale.Fit, + ) + } ?: CircularProgressIndicator(color = Color.White) } - } - - private fun isLandscape(): Boolean { - return (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) - } - - private fun setScreenBrightness(brightness: Float) { - requireActivity().window?.attributes?.screenBrightness = brightness - } } \ No newline at end of file diff --git a/app/src/main/java/net/helcel/fidelity/activity/view/ScannerView.kt b/app/src/main/java/net/helcel/fidelity/activity/view/ScannerView.kt deleted file mode 100644 index 7a5399b..0000000 --- a/app/src/main/java/net/helcel/fidelity/activity/view/ScannerView.kt +++ /dev/null @@ -1,45 +0,0 @@ -package net.helcel.fidelity.activity.view - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.PorterDuff -import android.graphics.PorterDuffXfermode -import android.util.AttributeSet -import android.view.View - -class ScannerView : View { - - private val overlayPaint = Paint().apply { - color = Color.parseColor("#80000000") // Semi-transparent black - style = Paint.Style.FILL - } - - private val clearPaint = Paint().apply { - xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) - } - - constructor(context: Context) : super(context) - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( - context, - attrs, - defStyleAttr - ) - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - - canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), overlayPaint) - - val centerX = width / 2f - val centerY = height / 2f - val squareSize = 0.75f * width.coerceAtMost(height) - canvas.drawRect( - centerX - squareSize / 2, centerY - squareSize / 2, - centerX + squareSize / 2, centerY + squareSize / 2, clearPaint - ) - } -} - diff --git a/app/src/main/java/net/helcel/fidelity/pluginSDK/AccessManager.kt b/app/src/main/java/net/helcel/fidelity/pluginSDK/AccessManager.kt deleted file mode 100644 index 8803473..0000000 --- a/app/src/main/java/net/helcel/fidelity/pluginSDK/AccessManager.kt +++ /dev/null @@ -1,94 +0,0 @@ -package net.helcel.fidelity.pluginSDK - -import android.content.Context -import android.content.SharedPreferences -import org.json.JSONArray -import org.json.JSONException - - -object AccessManager { - private const val PREF_KEY_SCOPE = "scope" - private const val PREF_KEY_TOKEN = "token" - - private fun stringArrayToString(values: ArrayList): String? { - if (values.isEmpty()) return null - val a = JSONArray() - values.forEach { a.put(it) } - return a.toString() - } - - private fun stringToStringArray(s: String?): ArrayList { - val strings = ArrayList() - if (s.isNullOrEmpty()) return strings - - try { - val a = JSONArray(s) - for (i in 0 until a.length()) - strings.add(a.optString(i)) - } catch (e: JSONException) { - e.printStackTrace() - } - return strings - } - - fun storeAccessToken( - ctx: Context, - hostPackage: String?, - accessToken: String?, - scopes: ArrayList - ) { - val prefs = getPrefsForHost(ctx, hostPackage) - val edit = prefs.edit() - edit.putString(PREF_KEY_TOKEN, accessToken) - val scopesString = stringArrayToString(scopes) - edit.putString(PREF_KEY_SCOPE, scopesString) - edit.apply() - - val hostPrefs = ctx.getSharedPreferences("KP2A.PluginAccess.hosts", Context.MODE_PRIVATE) - if (!hostPrefs.contains(hostPackage)) - hostPrefs.edit().putString(hostPackage, "").apply() - } - - private fun getPrefsForHost( - ctx: Context, - hostPackage: String? - ): SharedPreferences { - return ctx.getSharedPreferences("KP2A.PluginAccess.$hostPackage", Context.MODE_PRIVATE) - } - - fun tryGetAccessToken(ctx: Context, hostPackage: String?, scopes: ArrayList): String? { - if (hostPackage.isNullOrEmpty()) return null - - val prefs = getPrefsForHost(ctx, hostPackage) - val scopesString = prefs.getString(PREF_KEY_SCOPE, "") - val currentScope = stringToStringArray(scopesString) - if (!isSubset(scopes, currentScope)) - return null - return prefs.getString(PREF_KEY_TOKEN, null) - - } - - private fun isSubset( - requiredScopes: ArrayList, - availableScopes: ArrayList - ): Boolean { - return availableScopes.containsAll(requiredScopes) - } - - fun removeAccessToken( - ctx: Context, hostPackage: String?, - accessToken: String? - ) { - val prefs = getPrefsForHost(ctx, hostPackage) - if (prefs.getString(PREF_KEY_TOKEN, "") == accessToken) { - val edit = prefs.edit() - edit.clear() - edit.apply() - } - - val hostPrefs = ctx.getSharedPreferences("KP2A.PluginAccess.hosts", Context.MODE_PRIVATE) - if (hostPrefs.contains(hostPackage)) { - hostPrefs.edit().remove(hostPackage).apply() - } - } -} diff --git a/app/src/main/java/net/helcel/fidelity/pluginSDK/KeepassDef.kt b/app/src/main/java/net/helcel/fidelity/pluginSDK/KeepassDef.kt deleted file mode 100644 index 9b61168..0000000 --- a/app/src/main/java/net/helcel/fidelity/pluginSDK/KeepassDef.kt +++ /dev/null @@ -1,9 +0,0 @@ -package net.helcel.fidelity.pluginSDK - -@Suppress("unused") -object KeepassDef { - var TitleField: String = "Title" - var UserNameField: String = "UserName" - var PasswordField: String = "Password" - var UrlField: String = "URL" -} diff --git a/app/src/main/java/net/helcel/fidelity/pluginSDK/Kp2aControl.kt b/app/src/main/java/net/helcel/fidelity/pluginSDK/Kp2aControl.kt deleted file mode 100644 index 22dd49b..0000000 --- a/app/src/main/java/net/helcel/fidelity/pluginSDK/Kp2aControl.kt +++ /dev/null @@ -1,49 +0,0 @@ -package net.helcel.fidelity.pluginSDK - -import android.content.Intent -import org.json.JSONException -import org.json.JSONObject - -object Kp2aControl { - - fun getAddEntryIntent( - fields: HashMap, - protectedFields: ArrayList? - ): Intent { - val outputData = JSONObject((fields as Map<*, *>)).toString() - val startKp2aIntent = Intent(Strings.ACTION_START_WITH_TASK) - startKp2aIntent.addCategory(Intent.CATEGORY_DEFAULT) - startKp2aIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - startKp2aIntent.putExtra("KP2A_APPTASK", "CreateEntryThenCloseTask") - startKp2aIntent.putExtra("ShowUserNotifications", "true") - startKp2aIntent.putExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA, outputData) - if (protectedFields != null) - startKp2aIntent.putStringArrayListExtra( - Strings.EXTRA_PROTECTED_FIELDS_LIST, - protectedFields - ) - return startKp2aIntent - } - - fun getQueryEntryForOwnPackageIntent(): Intent { - return Intent(Strings.ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE) - } - - fun getEntryFieldsFromIntent(intent: Intent?): HashMap { - val res = HashMap() - try { - val json = JSONObject(intent?.getStringExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA) ?: "") - val itr = json.keys() - while (itr.hasNext()) { - val key = itr.next() - val value = json[key].toString() - res[key] = value - } - } catch (e: JSONException) { - e.printStackTrace() - } catch (e: NullPointerException) { - e.printStackTrace() - } - return res - } -} diff --git a/app/src/main/java/net/helcel/fidelity/pluginSDK/PluginAccessBroadcastReceiver.kt b/app/src/main/java/net/helcel/fidelity/pluginSDK/PluginAccessBroadcastReceiver.kt deleted file mode 100644 index 8280f7a..0000000 --- a/app/src/main/java/net/helcel/fidelity/pluginSDK/PluginAccessBroadcastReceiver.kt +++ /dev/null @@ -1,51 +0,0 @@ -package net.helcel.fidelity.pluginSDK - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent - -class PluginAccessBroadcastReceiver : BroadcastReceiver() { - override fun onReceive(ctx: Context, intent: Intent) { - val action = intent.action ?: return - when (action) { - Strings.ACTION_TRIGGER_REQUEST_ACCESS -> requestAccess(ctx, intent) - Strings.ACTION_RECEIVE_ACCESS -> receiveAccess(ctx, intent) - Strings.ACTION_REVOKE_ACCESS -> revokeAccess(ctx, intent) - else -> {} - } - } - - private fun revokeAccess(ctx: Context, intent: Intent) { - val senderPackage = intent.getStringExtra(Strings.EXTRA_SENDER) - val accessToken = intent.getStringExtra(Strings.EXTRA_ACCESS_TOKEN) - AccessManager.removeAccessToken(ctx, senderPackage, accessToken) - } - - - private fun receiveAccess(ctx: Context, intent: Intent) { - val senderPackage = intent.getStringExtra(Strings.EXTRA_SENDER) - val accessToken = intent.getStringExtra(Strings.EXTRA_ACCESS_TOKEN) - AccessManager.storeAccessToken(ctx, senderPackage, accessToken, scopes) - } - - private fun requestAccess(ctx: Context, intent: Intent) { - val senderPackage = intent.getStringExtra(Strings.EXTRA_SENDER) - val requestToken = intent.getStringExtra(Strings.EXTRA_REQUEST_TOKEN) - val rpi = Intent(Strings.ACTION_REQUEST_ACCESS) - rpi.setPackage(senderPackage) - rpi.putExtra(Strings.EXTRA_SENDER, ctx.packageName) - rpi.putExtra(Strings.EXTRA_REQUEST_TOKEN, requestToken) - - val token: String? = AccessManager.tryGetAccessToken(ctx, senderPackage, scopes) - rpi.putExtra(Strings.EXTRA_ACCESS_TOKEN, token) - - rpi.putStringArrayListExtra(Strings.EXTRA_SCOPES, scopes) - ctx.sendBroadcast(rpi) - } - - private val scopes: ArrayList = ArrayList( - listOf( - Strings.SCOPE_QUERY_CREDENTIALS_FOR_OWN_PACKAGE, - ) - ) -} diff --git a/app/src/main/java/net/helcel/fidelity/pluginSDK/Strings.kt b/app/src/main/java/net/helcel/fidelity/pluginSDK/Strings.kt deleted file mode 100644 index c337582..0000000 --- a/app/src/main/java/net/helcel/fidelity/pluginSDK/Strings.kt +++ /dev/null @@ -1,31 +0,0 @@ -package net.helcel.fidelity.pluginSDK - -@Suppress("unused") -object Strings { - - const val SCOPE_DATABASE_ACTIONS = "keepass2android.SCOPE_DATABASE_ACTIONS" - const val SCOPE_CURRENT_ENTRY = "keepass2android.SCOPE_CURRENT_ENTRY" - const val SCOPE_QUERY_CREDENTIALS_FOR_OWN_PACKAGE = - "keepass2android.SCOPE_QUERY_CREDENTIALS_FOR_OWN_PACKAGE" - const val SCOPE_QUERY_CREDENTIALS = "keepass2android.SCOPE_QUERY_CREDENTIALS" - - const val EXTRA_SCOPES = "keepass2android.EXTRA_SCOPES" - const val EXTRA_PLUGIN_PACKAGE = "keepass2android.EXTRA_PLUGIN_PACKAGE" - - const val EXTRA_SENDER = "keepass2android.EXTRA_SENDER" - const val EXTRA_REQUEST_TOKEN = "keepass2android.EXTRA_REQUEST_TOKEN" - const val ACTION_START_WITH_TASK = "keepass2android.ACTION_START_WITH_TASK" - - const val ACTION_TRIGGER_REQUEST_ACCESS = "keepass2android.ACTION_TRIGGER_REQUEST_ACCESS" - const val ACTION_REQUEST_ACCESS = "keepass2android.ACTION_REQUEST_ACCESS" - const val ACTION_RECEIVE_ACCESS = "keepass2android.ACTION_RECEIVE_ACCESS" - const val ACTION_REVOKE_ACCESS = "keepass2android.ACTION_REVOKE_ACCESS" - - - const val EXTRA_ENTRY_OUTPUT_DATA = "keepass2android.EXTRA_ENTRY_OUTPUT_DATA" - const val EXTRA_PROTECTED_FIELDS_LIST = "keepass2android.EXTRA_PROTECTED_FIELDS_LIST" - const val EXTRA_ACCESS_TOKEN = "keepass2android.EXTRA_ACCESS_TOKEN" - - const val ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE = - "keepass2android.ACTION_QUERY_CREDENTIALS_FOR_OWN_PACKAGE" -} diff --git a/app/src/main/java/net/helcel/fidelity/tools/BarcodeFormatConverter.kt b/app/src/main/java/net/helcel/fidelity/tools/BarcodeFormatConverter.kt index f2e8aa2..bb53450 100644 --- a/app/src/main/java/net/helcel/fidelity/tools/BarcodeFormatConverter.kt +++ b/app/src/main/java/net/helcel/fidelity/tools/BarcodeFormatConverter.kt @@ -48,7 +48,7 @@ object BarcodeFormatConverter { BarcodeFormat.RSS_14 -> "RSS_14" BarcodeFormat.RSS_EXPANDED -> "RSS_EXPANDED" BarcodeFormat.UPC_EAN_EXTENSION -> "UPC_EAN" - else -> throw Exception("Unsupported Format: $f") + //else -> throw Exception("Unsupported Format: $f") } } } diff --git a/app/src/main/java/net/helcel/fidelity/tools/BarcodeGenerator.kt b/app/src/main/java/net/helcel/fidelity/tools/BarcodeGenerator.kt index fa46e5f..34df18d 100644 --- a/app/src/main/java/net/helcel/fidelity/tools/BarcodeGenerator.kt +++ b/app/src/main/java/net/helcel/fidelity/tools/BarcodeGenerator.kt @@ -6,6 +6,8 @@ import com.google.zxing.MultiFormatWriter import com.google.zxing.WriterException import com.google.zxing.common.BitMatrix import net.helcel.fidelity.tools.BarcodeFormatConverter.stringToFormat +import androidx.core.graphics.set +import androidx.core.graphics.createBitmap object BarcodeGenerator { @@ -31,13 +33,11 @@ object BarcodeGenerator { val bitMatrix: BitMatrix = writer.encode(content, format, width, height) - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(width, height) for (x in 0 until width) { for (y in 0 until height) { - bitmap.setPixel( - x, y, getPixelColor(bitMatrix, x, y) - ) + bitmap[x, y] = getPixelColor(bitMatrix, x, y) } } return bitmap diff --git a/app/src/main/java/net/helcel/fidelity/tools/BarcodeScanner.kt b/app/src/main/java/net/helcel/fidelity/tools/BarcodeScanner.kt index a981899..c186dec 100644 --- a/app/src/main/java/net/helcel/fidelity/tools/BarcodeScanner.kt +++ b/app/src/main/java/net/helcel/fidelity/tools/BarcodeScanner.kt @@ -26,9 +26,9 @@ object BarcodeScanner { try { val result = reader.decode(binaryBitmap) cb(result.text, formatToString(result.barcodeFormat)) - } catch (e: NotFoundException) { + } catch (_: NotFoundException) { cb(null, null) - } catch (e: ReaderException) { + } catch (_: ReaderException) { cb(null, null) } } diff --git a/app/src/main/java/net/helcel/fidelity/tools/BiometricStore.kt b/app/src/main/java/net/helcel/fidelity/tools/BiometricStore.kt new file mode 100644 index 0000000..2f59d91 --- /dev/null +++ b/app/src/main/java/net/helcel/fidelity/tools/BiometricStore.kt @@ -0,0 +1,142 @@ +package net.helcel.fidelity.tools + +import android.content.Context +import android.net.Uri +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import javax.crypto.Cipher +import androidx.datastore.preferences.core.* +import androidx.datastore.preferences.preferencesDataStore +import androidx.fragment.app.FragmentActivity +import com.kunzisoft.keepass.hardware.HardwareKey +import com.kunzisoft.keepass.utils.parseUri +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.suspendCancellableCoroutine +import java.security.KeyStore +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +val Context.securePrefs by preferencesDataStore("keepass_prefs") +object KeePassKeys { + val DB_FILE_PATH = stringPreferencesKey("db_file_path") + val PASSWORD = stringPreferencesKey("password_enc") + val KEY_FILE_PATH = stringPreferencesKey("key_file_path") + val IV = stringPreferencesKey("iv") +} + +sealed class CredentialResult { + data class Success(val db: Uri?, val password: String, val key: Uri?) : CredentialResult() + object NoData : CredentialResult() + object AuthFailed : CredentialResult() +} + +private const val KEY_ALIAS = "keepass_bio_key" + +fun getOrCreateBiometricKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey } + val keyGenerator = + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + val spec = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ).apply { + setBlockModes(KeyProperties.BLOCK_MODE_GCM) + setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + setUserAuthenticationRequired(true) + setInvalidatedByBiometricEnrollment(true) + }.build() + + keyGenerator.init(spec) + return keyGenerator.generateKey() +} + +fun getCipherForDecryption(key: SecretKey, iv: ByteArray?): Cipher { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + if(iv==null) cipher.init(Cipher.ENCRYPT_MODE, key) + else cipher.init(Cipher.DECRYPT_MODE, key, javax.crypto.spec.GCMParameterSpec(128, iv)) + return cipher +} +object KeePassStore { + suspend fun saveCredentials( + context: Context, cred: CredentialResult.Success + ): CredentialResult { + val cipher = showBiometricPrompt(context as FragmentActivity, true) + ?: return CredentialResult.AuthFailed + val encPasswordB = cipher.doFinal(cred.password.toByteArray(Charsets.UTF_8)) + context.securePrefs.edit { prefs -> + prefs[KeePassKeys.DB_FILE_PATH] = cred.db.toString() + prefs[KeePassKeys.PASSWORD] = Base64.encodeToString(encPasswordB, Base64.DEFAULT) + prefs[KeePassKeys.IV] = Base64.encodeToString(cipher.iv, Base64.DEFAULT) + cred.key?.let { prefs[KeePassKeys.KEY_FILE_PATH] = it.toString() } + } + return cred + } + + suspend fun hasCredentials(context: Context): Boolean { + val prefs = context.securePrefs.data.first() + return prefs[KeePassKeys.DB_FILE_PATH] != null && + prefs[KeePassKeys.PASSWORD] != null + } + + fun packCredentials(dbFilePath:Uri?, password: String, keyFilePath: Uri?): CredentialResult.Success { + return CredentialResult.Success(dbFilePath, password, keyFilePath) + } + + suspend fun loadCredentials(context: Context): CredentialResult { + val prefs = context.securePrefs.data.first { true } + val dbFilePath = prefs[KeePassKeys.DB_FILE_PATH] ?: return CredentialResult.NoData + val encryptedBase64 = prefs[KeePassKeys.PASSWORD] ?: return CredentialResult.NoData + val keyFilePath = prefs[KeePassKeys.KEY_FILE_PATH] + val cipher = showBiometricPrompt(context as FragmentActivity, false) + ?: return CredentialResult.AuthFailed + val decrypted = cipher.doFinal(Base64.decode(encryptedBase64, Base64.DEFAULT)) + return packCredentials( + dbFilePath.parseUri(), + String(decrypted, Charsets.UTF_8), + keyFilePath?.parseUri() + ) + } +} + + +@OptIn(ExperimentalCoroutinesApi::class) +suspend fun showBiometricPrompt(activity: FragmentActivity, enc: Boolean): Cipher? { + val prefs = activity.securePrefs.data.first() + return suspendCancellableCoroutine { cont -> + val executor = ContextCompat.getMainExecutor(activity) + val biometricPrompt = BiometricPrompt( + activity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { cont.resume(result.cryptoObject?.cipher) {} } + override fun onAuthenticationError(code: Int, msg: CharSequence) { cont.resume(null) {} } + override fun onAuthenticationFailed() { cont.resume(null) {} } + } + ) + val iv = if(enc) null else prefs[KeePassKeys.IV]?.let { Base64.decode(it, Base64.DEFAULT) } + if (!enc && iv == null) { cont.resume(null) {} } + val cipher = getCipherForDecryption(getOrCreateBiometricKey(), iv) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle("Unlock KeePass") + .setSubtitle("Authenticate to access your KeePass database") + .setNegativeButtonText("Cancel") + .build() + + biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) + } + +} + + +fun retrieveResponseFromChallenge( + hardwareKey: HardwareKey, + seed: ByteArray?, +): ByteArray { + val response: ByteArray = "".toByteArray() + return response +} diff --git a/app/src/main/java/net/helcel/fidelity/tools/CacheManager.kt b/app/src/main/java/net/helcel/fidelity/tools/CacheManager.kt deleted file mode 100644 index 790084c..0000000 --- a/app/src/main/java/net/helcel/fidelity/tools/CacheManager.kt +++ /dev/null @@ -1,50 +0,0 @@ -package net.helcel.fidelity.tools - -import android.content.SharedPreferences -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken - - -object CacheManager { - - const val PREF_NAME = "FIDELITY" - private const val ENTRY_KEY = "FIDELITY" - private var data: ArrayList> = ArrayList() - private var pref: SharedPreferences? = null - - fun addFidelity(item: Triple) { - val exists = data.find { it.first == item.first } - if (exists != null) - data.remove(exists) - - data.add(0, item) - saveFidelity() - } - - fun rmFidelity(idx: Int) { - data.removeAt(idx) - saveFidelity() - } - - private fun saveFidelity() { - val editor = pref?.edit() - val gson = Gson() - val json = gson.toJson(data) - editor?.putString(ENTRY_KEY, json) - editor?.apply() - } - - fun loadFidelity(pref: SharedPreferences) { - this.pref = pref - val gson = Gson() - val json = pref.getString(ENTRY_KEY, null) - val type = object : TypeToken>>() {}.type - data = gson.fromJson(json, type) ?: ArrayList() - - } - - fun getFidelity(): ArrayList> { - return data - } - -} \ No newline at end of file diff --git a/app/src/main/java/net/helcel/fidelity/tools/ErrorToaster.kt b/app/src/main/java/net/helcel/fidelity/tools/ErrorToaster.kt deleted file mode 100644 index 4973cc7..0000000 --- a/app/src/main/java/net/helcel/fidelity/tools/ErrorToaster.kt +++ /dev/null @@ -1,31 +0,0 @@ -package net.helcel.fidelity.tools - -import android.content.Context -import android.widget.Toast - -object ErrorToaster { - private fun helper(activity: Context?, message: String, length: Int) { - if (activity != null) - Toast.makeText(activity, message, length).show() - } - - fun noKP2AFound(activity: Context?) { - helper(activity, "KeePass2Android Not Installed", Toast.LENGTH_LONG) - } - - fun formIncomplete(activity: Context?) { - helper(activity, "Form Incomplete", Toast.LENGTH_SHORT) - } - - fun invalidFormat(activity: Context?) { - helper(activity, "Invalid Format", Toast.LENGTH_SHORT) - } - - fun nothingFound(activity: Context?) { - helper(activity, "Nothing Found", Toast.LENGTH_SHORT) - } - - fun noPermission(activity: Context?) { - helper(activity, "Missing Permission", Toast.LENGTH_LONG) - } -} diff --git a/app/src/main/java/net/helcel/fidelity/tools/Keepass.kt b/app/src/main/java/net/helcel/fidelity/tools/Keepass.kt new file mode 100644 index 0000000..d2ecd90 --- /dev/null +++ b/app/src/main/java/net/helcel/fidelity/tools/Keepass.kt @@ -0,0 +1,185 @@ +package net.helcel.fidelity.tools + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import kotlinx.serialization.Serializable +import java.io.ByteArrayInputStream +import kotlinx.serialization.json.Json +import androidx.core.content.edit +import com.kunzisoft.keepass.database.element.Database +import com.kunzisoft.keepass.database.element.Field +import com.kunzisoft.keepass.database.element.Group +import com.kunzisoft.keepass.database.element.MasterCredential +import com.kunzisoft.keepass.database.element.binary.BinaryData +import com.kunzisoft.keepass.database.element.node.NodeIdUUID +import com.kunzisoft.keepass.database.element.security.ProtectedString +import com.kunzisoft.keepass.hardware.HardwareKey +import com.kunzisoft.keepass.utils.getBinaryDir +import kotlinx.serialization.builtins.ListSerializer +import java.io.File +import java.util.UUID + +object FidelityKeepassFields { + const val FIDELITYFORMAT = "FidelityFormat" + const val FIDELITYCODE = "FidelityCode" +} + +@Serializable +data class FidelityEntry( + val uid: String? = null, + val title: String = "", + val code: String = "", + val format: String = "", + val protected: Boolean = false, + + val hidden: Boolean = false, + val pinned: Boolean = false, + val lastUse: Int = 0, +) + +object FidelityRepository { + private var db: Database = Database() + private var binaryDir: File? = null + val entries = mutableStateListOf() + val activeEntry = mutableStateOf(FidelityEntry()) + + + fun getRoot(): Group? { + return db.rootGroup + } + + fun start(ctx: Context, uri: Uri?, c: MasterCredential): Boolean { + if (binaryDir == null) binaryDir = ctx.getBinaryDir() + if (uri == null) return false + try { + val bitStream = + ByteArrayInputStream(ctx.contentResolver.openInputStream(uri)?.readBytes()) + db.loadData( + bitStream, c, + { hardwareKey, seed -> retrieveResponseFromChallenge(hardwareKey, seed) }, + false, binaryDir!!, + { BinaryData.canMemoryBeAllocatedInRAM(ctx, it) }, + false, null + ) + return true + } catch (e: Exception) { + println(e) + return false + } + } + + fun end(ctx: Context, uri: Uri?, c: MasterCredential): Boolean { + if (uri == null) return false + db.saveData( + File(binaryDir, db.binaryCache.hashCode().toString()),{ ctx.contentResolver.openOutputStream(uri) }, + false, c, { hardwareKey, seed -> retrieveResponseFromChallenge(hardwareKey, seed) }) + return true + } + + fun genCredentials( + ctx: Context, + cred: CredentialResult.Success, + hardwareKey: HardwareKey? = null + ): MasterCredential { + return MasterCredential( + cred.password, + cred.key?.let { ctx.contentResolver.openInputStream(cred.key)?.readBytes() }, + hardwareKey + ) + } + + fun importDB(context: Context) { + val seenID= arrayListOf() + fun importDBRec(group: Group) { + group.getChildEntries().forEach { + val fields = it.getExtraFields() + val code = fields.firstOrNull { e -> e.name == FidelityKeepassFields.FIDELITYCODE } + val format = + fields.firstOrNull { e -> e.name == FidelityKeepassFields.FIDELITYFORMAT } + if (code == null || format == null) return@forEach + + val newEntry = FidelityEntry( + uid=it.nodeId.id.toString(), + title=it.title, + code=code.protectedValue.stringValue, + format=format.protectedValue.stringValue, + protected=code.protectedValue.isProtected, + ) + val idx = entries.indexOfFirst { e -> e.uid == newEntry.uid } + seenID.add(newEntry.uid!!) + if (idx >= 0) { + val oldEntry = entries[idx] + entries[idx] = newEntry.copy( + pinned = oldEntry.pinned, + hidden = oldEntry.hidden, + lastUse = oldEntry.lastUse + ) + } else { + entries.add(newEntry) + } + + + } + group.getChildGroups().forEach { importDBRec(it) } + } + if (db.rootGroup != null) + importDBRec(db.rootGroup!!) + entries.removeAll { !seenID.contains(it.uid)} + val distinct = entries.distinctBy { it.uid } + entries.clear() + entries.addAll(distinct) + saveEntries(context) + } + + fun saveEntries(context: Context) { + val prefs = context.getSharedPreferences("fidelity_prefs", Context.MODE_PRIVATE) + prefs.edit { putString("entries", Json.encodeToString( + ListSerializer(FidelityEntry.serializer()), + entries + )) } + } + + fun loadEntries(context: Context) { + val prefs = context.getSharedPreferences("fidelity_prefs", Context.MODE_PRIVATE) + try { + val json = prefs.getString("entries", null) ?: return + val list = Json.decodeFromString( + ListSerializer(FidelityEntry.serializer()), + json + ) + + entries.clear() + entries.addAll(list) + }catch(_: Exception){ + prefs.edit{ putString("entries",Json.encodeToString( + ListSerializer(FidelityEntry.serializer()),emptyList())) + } + } + } + + fun addEntry(ctx: Context, entry: FidelityEntry) { + val dbEntry = db.getEntryById(NodeIdUUID(UUID.fromString(entry.uid))) ?: db.createEntry() + val dbParent = db.getGroupById(NodeIdUUID(UUID.fromString(entry.uid))) + dbEntry?.apply { + putExtraField( + Field( + FidelityKeepassFields.FIDELITYCODE, + ProtectedString(entry.protected, entry.code) + ) + ) + putExtraField( + Field( + FidelityKeepassFields.FIDELITYFORMAT, + ProtectedString(string= entry.format) + ) + ) + if(dbParent!=null) title = entry.title + dbParent?.addChildEntry(dbEntry) + } + entries.removeIf {it.uid == entry.uid} + entries.add(entry.copy(uid=dbEntry?.nodeId?.id.toString())) + saveEntries(ctx) + } +} diff --git a/app/src/main/java/net/helcel/fidelity/tools/KeepassWrapper.kt b/app/src/main/java/net/helcel/fidelity/tools/KeepassWrapper.kt deleted file mode 100644 index a3ba82c..0000000 --- a/app/src/main/java/net/helcel/fidelity/tools/KeepassWrapper.kt +++ /dev/null @@ -1,85 +0,0 @@ -package net.helcel.fidelity.tools - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.Fragment -import net.helcel.fidelity.pluginSDK.KeepassDef -import net.helcel.fidelity.pluginSDK.Kp2aControl - -object KeepassWrapper { - - private const val CODE_FIELD: String = "FidelityCode" - private const val FORMAT_FIELD: String = "FidelityFormat" - private const val PROTECT_CODE_FIELD: String = "FidelityProtectedCode" - - fun entryCreate( - fragment: Fragment, - title: String, - code: String, - format: String, - protectCode: Boolean, - ): Pair, ArrayList> { - - val fields = HashMap() - val protected = ArrayList() - fields[KeepassDef.TitleField] = title - fields[KeepassDef.UrlField] = - "androidapp://" + fragment.requireActivity().packageName - fields[CODE_FIELD] = code - fields[FORMAT_FIELD] = format - fields[PROTECT_CODE_FIELD] = protectCode.toString() - protected.add(CODE_FIELD) - - return Pair(fields, protected) - } - - - fun resultLauncher( - fragment: Fragment, - callback: (HashMap) -> Unit - ): ActivityResultLauncher { - return fragment.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - val credentials = Kp2aControl.getEntryFieldsFromIntent(result.data) - callback(credentials) - } - } - } - - fun entryExtract(map: HashMap): Triple { - return Triple( - map[KeepassDef.TitleField], - map[CODE_FIELD], - map[FORMAT_FIELD] - ) - } - - fun bundleCreate(title: String?, code: String?, fmt: String?): Bundle { - val data = Bundle() - data.putString("title", title) - data.putString("code", code) - data.putString("fmt", fmt) - return data - } - - fun bundleCreate(triple: Triple): Bundle { - return bundleCreate(triple.first, triple.second, triple.third) - } - - fun bundleExtract(data: Bundle?): Triple { - return Triple( - data?.getString("title"), - data?.getString("code"), - data?.getString("fmt") - ) - } - - fun isProtected(map: HashMap): Boolean { - return map[PROTECT_CODE_FIELD].toBoolean() - } - - -} \ No newline at end of file diff --git a/app/src/main/res/layout/act_main.xml b/app/src/main/res/layout/act_main.xml deleted file mode 100644 index b090865..0000000 --- a/app/src/main/res/layout/act_main.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/frag_create_entry.xml b/app/src/main/res/layout/frag_create_entry.xml deleted file mode 100644 index 449abb4..0000000 --- a/app/src/main/res/layout/frag_create_entry.xml +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/frag_launcher.xml b/app/src/main/res/layout/frag_launcher.xml deleted file mode 100644 index a1a8c25..0000000 --- a/app/src/main/res/layout/frag_launcher.xml +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/frag_scanner.xml b/app/src/main/res/layout/frag_scanner.xml deleted file mode 100644 index b5719f8..0000000 --- a/app/src/main/res/layout/frag_scanner.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/frag_view_entry.xml b/app/src/main/res/layout/frag_view_entry.xml deleted file mode 100644 index b12a9dd..0000000 --- a/app/src/main/res/layout/frag_view_entry.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/list_item_dropdown.xml b/app/src/main/res/layout/list_item_dropdown.xml deleted file mode 100644 index 56c46c3..0000000 --- a/app/src/main/res/layout/list_item_dropdown.xml +++ /dev/null @@ -1,8 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/list_item_fidelity.xml b/app/src/main/res/layout/list_item_fidelity.xml deleted file mode 100644 index c744e4b..0000000 --- a/app/src/main/res/layout/list_item_fidelity.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml deleted file mode 100644 index 94fd7e0..0000000 --- a/app/src/main/res/values/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae39ef4..1df1f4c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,10 +1,10 @@ - - Fidelity - Fidelity adds an interface to manage fidelity cards and other barcodes to Keepass2Android - Soraefir - - Keepass Fidelity + + App theme + System + Light + Dark + Statistics barcode preview Expand diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml deleted file mode 100644 index 00a79a2..0000000 --- a/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - \ No newline at end of file diff --git a/build.gradle b/build.gradle index d363256..f7a8e62 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,13 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { +// ext.kotlin_version = '1.8.20' +// ext.android_core_version = '1.10.1' +// ext.android_appcompat_version = '1.6.1' +// ext.android_material_version = '1.9.0' + ext.android_test_version = '1.5.2' +} + plugins { id 'com.android.application' version '8.13.0' apply false id 'com.android.library' version '8.13.0' apply false diff --git a/external/KeePassDX b/external/KeePassDX new file mode 160000 index 0000000..a7d0467 --- /dev/null +++ b/external/KeePassDX @@ -0,0 +1 @@ +Subproject commit a7d0467127ce1e6a1b0dfa1de6963b9e3a01ef1e diff --git a/settings.gradle b/settings.gradle index 8979d4a..e3711bc 100644 --- a/settings.gradle +++ b/settings.gradle @@ -14,6 +14,11 @@ dependencyResolutionManagement { maven { url 'https://jitpack.io' } } } +include(":database") +project(":database").projectDir = file("external/KeePassDX/database") + +include(":crypto") +project(":crypto").projectDir = file("external/KeePassDX/crypto") rootProject.name = "Fidelity" include ':app'