fix: Biometric error prompts #2081

This commit is contained in:
J-Jamet
2025-07-24 16:39:42 +02:00
parent 56f8a1bf9f
commit 593b5c6338
4 changed files with 231 additions and 87 deletions

View File

@@ -32,7 +32,6 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.CompoundButton
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
@@ -43,6 +42,9 @@ import androidx.appcompat.widget.Toolbar
import androidx.biometric.BiometricManager
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.snackbar.Snackbar
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.dialogs.DuplicateUuidDialog
@@ -81,10 +83,11 @@ import com.kunzisoft.keepass.view.asError
import com.kunzisoft.keepass.view.showActionErrorIfNeeded
import com.kunzisoft.keepass.viewmodels.AdvancedUnlockViewModel
import com.kunzisoft.keepass.viewmodels.DatabaseFileViewModel
import kotlinx.coroutines.launch
import java.io.FileNotFoundException
class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.BuilderListener {
class MainCredentialActivity : DatabaseModeActivity() {
// Views
private var toolbar: Toolbar? = null
@@ -166,21 +169,13 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu
}
// Listen password checkbox to init advanced unlock and confirmation button
mainCredentialView?.onPasswordChecked =
CompoundButton.OnCheckedChangeListener { _, _ ->
mAdvancedUnlockViewModel.checkUnlockAvailability()
enableConfirmationButton()
}
mainCredentialView?.onKeyFileChecked =
CompoundButton.OnCheckedChangeListener { _, _ ->
// TODO mAdvancedUnlockViewModel.checkUnlockAvailability()
enableConfirmationButton()
}
mainCredentialView?.onHardwareKeyChecked =
CompoundButton.OnCheckedChangeListener { _, _ ->
// TODO mAdvancedUnlockViewModel.checkUnlockAvailability()
enableConfirmationButton()
}
mainCredentialView?.onConditionToStoreCredentialChanged = { credentialStorage, verified ->
mAdvancedUnlockViewModel.checkUnlockAvailability(
conditionToStoreCredentialVerified = verified
)
// TODO Async by ViewModel
enableConfirmationButton()
}
// Observe if default database
mDatabaseFileViewModel.isDefaultDatabase.observe(this) { isDefaultDatabase ->
@@ -228,6 +223,27 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu
onDatabaseFileLoaded(databaseFile?.databaseUri, keyFileUri, hardwareKey)
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
mAdvancedUnlockViewModel.uiState.collect { uiState ->
// New value received
if (uiState.isCredentialRequired) {
mAdvancedUnlockViewModel.provideCredentialForEncryption(
getCredentialForEncryption()
)
}
uiState.cipherEncryptDatabase?.let { cipherEncryptDatabase ->
onCredentialEncrypted(cipherEncryptDatabase)
mAdvancedUnlockViewModel.consumeCredentialEncrypted()
}
uiState.cipherDecryptDatabase?.let { cipherDecryptDatabase ->
onCredentialDecrypted(cipherDecryptDatabase)
mAdvancedUnlockViewModel.consumeCredentialDecrypted()
}
}
}
}
}
override fun onResume() {
@@ -400,23 +416,6 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu
}
}
override fun retrieveCredentialForEncryption(): ByteArray {
return mainCredentialView?.retrieveCredentialForStorage(credentialStorageListener)
?: byteArrayOf()
}
override fun conditionToStoreCredential(): Boolean {
return mainCredentialView?.conditionToStoreCredential() == true
}
override fun onCredentialEncrypted(cipherEncryptDatabase: CipherEncryptDatabase) {
// Load the database if password is registered with biometric
loadDatabase(mDatabaseFileUri,
mainCredentialView?.getMainCredential(),
cipherEncryptDatabase
)
}
private val credentialStorageListener = object: MainCredentialView.CredentialStorageListener {
override fun passwordToStore(password: String?): ByteArray? {
return password?.toByteArray()
@@ -433,7 +432,20 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu
}
}
override fun onCredentialDecrypted(cipherDecryptDatabase: CipherDecryptDatabase) {
private fun getCredentialForEncryption(): ByteArray {
return mainCredentialView?.retrieveCredentialForStorage(credentialStorageListener)
?: byteArrayOf()
}
private fun onCredentialEncrypted(cipherEncryptDatabase: CipherEncryptDatabase) {
// Load the database if password is registered with biometric
loadDatabase(mDatabaseFileUri,
mainCredentialView?.getMainCredential(),
cipherEncryptDatabase
)
}
private fun onCredentialDecrypted(cipherDecryptDatabase: CipherDecryptDatabase) {
// Load the database if password is retrieve from biometric
// Retrieve from biometric
val mainCredential = mainCredentialView?.getMainCredential() ?: MainCredential()

View File

@@ -35,7 +35,9 @@ import androidx.biometric.BiometricPrompt
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.app.database.CipherDatabaseAction
import com.kunzisoft.keepass.database.exception.UnknownDatabaseLocationException
@@ -52,8 +54,6 @@ import kotlinx.coroutines.launch
class AdvancedUnlockFragment: Fragment(), AdvancedUnlockManager.AdvancedUnlockCallback {
private var mBuilderListener: BuilderListener? = null
private var mAdvancedUnlockEnabled = false
private var mAutoOpenPromptEnabled = false
@@ -84,6 +84,8 @@ class AdvancedUnlockFragment: Fragment(), AdvancedUnlockManager.AdvancedUnlockCa
// Only keep connection when we request a device credential activity
private var keepConnection = false
private var isConditionToStoreCredentialVerified = false
private var mDeviceCredentialResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
@@ -120,14 +122,6 @@ class AdvancedUnlockFragment: Fragment(), AdvancedUnlockManager.AdvancedUnlockCa
mAdvancedUnlockEnabled = PreferencesUtil.isAdvancedUnlockEnable(context)
mAutoOpenPromptEnabled = PreferencesUtil.isAdvancedUnlockPromptAutoOpenEnable(context)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
mBuilderListener = context as BuilderListener
}
} catch (e: ClassCastException) {
throw ClassCastException(context.toString()
+ " must implement " + BuilderListener::class.java.name)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -138,11 +132,6 @@ class AdvancedUnlockFragment: Fragment(), AdvancedUnlockManager.AdvancedUnlockCa
mAdvancedUnlockViewModel.onInitAdvancedUnlockModeRequested.observe(this) {
initAdvancedUnlockMode()
}
mAdvancedUnlockViewModel.onUnlockAvailabilityCheckRequested.observe(this) {
checkUnlockAvailability()
}
mAdvancedUnlockViewModel.onDatabaseFileLoaded.observe(this) {
onDatabaseLoaded(it)
}
@@ -162,6 +151,27 @@ class AdvancedUnlockFragment: Fragment(), AdvancedUnlockManager.AdvancedUnlockCa
super.onViewCreated(view, savedInstanceState)
activity?.addMenuProvider(menuProvider, viewLifecycleOwner)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
mAdvancedUnlockViewModel.uiState.collect { uiState ->
// New credential value received
uiState.credential?.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
advancedUnlockManager?.encryptData(uiState.credential)
}
mAdvancedUnlockViewModel.consumeCredentialForEncryption()
}
// Condition to store credential verified
isConditionToStoreCredentialVerified = uiState.isConditionToStoreCredentialVerified
// Check unlock availability
if (uiState.onUnlockAvailabilityCheckRequested) {
checkUnlockAvailability()
mAdvancedUnlockViewModel.consumeCheckUnlockAvailability()
}
}
}
}
}
override fun onResume() {
@@ -250,7 +260,7 @@ class AdvancedUnlockFragment: Fragment(), AdvancedUnlockManager.AdvancedUnlockCa
if (advancedUnlockManager?.isKeyManagerInitialized != true) {
toggleMode(Mode.KEY_MANAGER_UNAVAILABLE)
} else {
if (mBuilderListener?.conditionToStoreCredential() == true) {
if (isConditionToStoreCredentialVerified) {
// listen for encryption
toggleMode(Mode.STORE_CREDENTIAL)
} else {
@@ -261,8 +271,13 @@ class AdvancedUnlockFragment: Fragment(), AdvancedUnlockManager.AdvancedUnlockCa
// listen for decryption
Mode.EXTRACT_CREDENTIAL
} else {
// wait for typing
Mode.WAIT_CREDENTIAL
if (isConditionToStoreCredentialVerified) {
// if condition OK, key manager in error
Mode.KEY_MANAGER_UNAVAILABLE
} else {
// wait for typing
Mode.WAIT_CREDENTIAL
}
})
}
}
@@ -523,9 +538,7 @@ class AdvancedUnlockFragment: Fragment(), AdvancedUnlockManager.AdvancedUnlockCa
}
Mode.STORE_CREDENTIAL -> {
// newly store the entered password in encrypted way
mBuilderListener?.retrieveCredentialForEncryption()?.let { credential ->
advancedUnlockManager?.encryptData(credential)
}
mAdvancedUnlockViewModel.retrieveCredentialForEncryption()
}
Mode.EXTRACT_CREDENTIAL -> {
// retrieve the encrypted value from preferences
@@ -545,7 +558,7 @@ class AdvancedUnlockFragment: Fragment(), AdvancedUnlockManager.AdvancedUnlockCa
override fun handleEncryptedResult(encryptedValue: ByteArray, ivSpec: ByteArray) {
databaseFileUri?.let { databaseUri ->
mBuilderListener?.onCredentialEncrypted(
mAdvancedUnlockViewModel.onCredentialEncrypted(
CipherEncryptDatabase().apply {
this.databaseUri = databaseUri
this.credentialStorage = credentialDatabaseStorage
@@ -559,7 +572,7 @@ class AdvancedUnlockFragment: Fragment(), AdvancedUnlockManager.AdvancedUnlockCa
override fun handleDecryptedResult(decryptedValue: ByteArray) {
// Load database directly with password retrieve
databaseFileUri?.let { databaseUri ->
mBuilderListener?.onCredentialDecrypted(
mAdvancedUnlockViewModel.onCredentialDecrypted(
CipherDecryptDatabase().apply {
this.databaseUri = databaseUri
this.credentialStorage = credentialDatabaseStorage
@@ -630,13 +643,6 @@ class AdvancedUnlockFragment: Fragment(), AdvancedUnlockManager.AdvancedUnlockCa
EXTRACT_CREDENTIAL
}
interface BuilderListener {
fun retrieveCredentialForEncryption(): ByteArray
fun conditionToStoreCredential(): Boolean
fun onCredentialEncrypted(cipherEncryptDatabase: CipherEncryptDatabase)
fun onCredentialDecrypted(cipherDecryptDatabase: CipherDecryptDatabase)
}
override fun onPause() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (!keepConnection) {
@@ -645,13 +651,11 @@ class AdvancedUnlockFragment: Fragment(), AdvancedUnlockManager.AdvancedUnlockCa
advancedUnlockManager = null
}
}
super.onPause()
}
override fun onDestroyView() {
mAdvancedUnlockInfoView = null
super.onDestroyView()
}
@@ -659,20 +663,12 @@ class AdvancedUnlockFragment: Fragment(), AdvancedUnlockManager.AdvancedUnlockCa
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
disconnect()
advancedUnlockManager = null
mBuilderListener = null
}
mAdvancedUnlockViewModel.deleteData()
super.onDestroy()
}
override fun onDetach() {
mBuilderListener = null
super.onDetach()
}
companion object {
private val TAG = AdvancedUnlockFragment::class.java.name
}
}

View File

@@ -53,9 +53,7 @@ class MainCredentialView @JvmOverloads constructor(context: Context,
private var checkboxHardwareView: CompoundButton
private var hardwareKeySelectionView: HardwareKeySelectionView
var onPasswordChecked: (CompoundButton.OnCheckedChangeListener)? = null
var onKeyFileChecked: (CompoundButton.OnCheckedChangeListener)? = null
var onHardwareKeyChecked: (CompoundButton.OnCheckedChangeListener)? = null
var onConditionToStoreCredentialChanged: ((CredentialStorage, verified: Boolean) -> Unit)? = null
var onValidateListener: (() -> Unit)? = null
private var mCredentialStorage: CredentialStorage = CredentialStorage.PASSWORD
@@ -104,7 +102,10 @@ class MainCredentialView @JvmOverloads constructor(context: Context,
}
checkboxPasswordView.setOnCheckedChangeListener { view, checked ->
onPasswordChecked?.onCheckedChanged(view, checked)
onConditionToStoreCredentialChanged?.invoke(
mCredentialStorage,
conditionToStoreCredential()
)
}
checkboxKeyFileView.setOnCheckedChangeListener { view, checked ->
if (checked) {
@@ -112,7 +113,10 @@ class MainCredentialView @JvmOverloads constructor(context: Context,
checkboxKeyFileView.isChecked = false
}
}
onKeyFileChecked?.onCheckedChanged(view, checked)
onConditionToStoreCredentialChanged?.invoke(
mCredentialStorage,
conditionToStoreCredential()
)
}
checkboxHardwareView.setOnCheckedChangeListener { view, checked ->
if (checked) {
@@ -120,7 +124,10 @@ class MainCredentialView @JvmOverloads constructor(context: Context,
checkboxHardwareView.isChecked = false
}
}
onHardwareKeyChecked?.onCheckedChanged(view, checked)
onConditionToStoreCredentialChanged?.invoke(
mCredentialStorage,
conditionToStoreCredential()
)
}
hardwareKeySelectionView.selectionListener = { _ ->

View File

@@ -3,18 +3,23 @@ package com.kunzisoft.keepass.viewmodels
import android.net.Uri
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import com.kunzisoft.keepass.model.CipherDecryptDatabase
import com.kunzisoft.keepass.model.CipherEncryptDatabase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
class AdvancedUnlockViewModel : ViewModel() {
var allowAutoOpenBiometricPrompt : Boolean = true
var deviceCredentialAuthSucceeded: Boolean? = null
private val _uiState = MutableStateFlow(DeviceUnlockUiStates())
val uiState: StateFlow<DeviceUnlockUiStates> = _uiState
val onInitAdvancedUnlockModeRequested : LiveData<Void?> get() = _onInitAdvancedUnlockModeRequested
private val _onInitAdvancedUnlockModeRequested = SingleLiveEvent<Void?>()
val onUnlockAvailabilityCheckRequested : LiveData<Void?> get() = _onUnlockAvailabilityCheckRequested
private val _onUnlockAvailabilityCheckRequested = SingleLiveEvent<Void?>()
val onDatabaseFileLoaded : LiveData<Uri?> get() = _onDatabaseFileLoaded
private val _onDatabaseFileLoaded = SingleLiveEvent<Uri?>()
@@ -22,11 +27,135 @@ class AdvancedUnlockViewModel : ViewModel() {
_onInitAdvancedUnlockModeRequested.call()
}
fun checkUnlockAvailability() {
_onUnlockAvailabilityCheckRequested.call()
fun checkUnlockAvailability(conditionToStoreCredentialVerified: Boolean) {
_uiState.update { currentState ->
currentState.copy(
onUnlockAvailabilityCheckRequested = true,
isConditionToStoreCredentialVerified = conditionToStoreCredentialVerified
)
}
}
fun consumeCheckUnlockAvailability() {
_uiState.update { currentState ->
currentState.copy(
onUnlockAvailabilityCheckRequested = false
)
}
}
fun databaseFileLoaded(databaseUri: Uri?) {
_onDatabaseFileLoaded.value = databaseUri
}
fun retrieveCredentialForEncryption() {
_uiState.update { currentState ->
currentState.copy(
isCredentialRequired = true,
credential = null
)
}
}
fun provideCredentialForEncryption(credential: ByteArray) {
_uiState.update { currentState ->
currentState.copy(
isCredentialRequired = false,
credential = credential
)
}
}
fun consumeCredentialForEncryption() {
_uiState.update { currentState ->
currentState.copy(
isCredentialRequired = false,
credential = null
)
}
}
fun onCredentialEncrypted(cipherEncryptDatabase: CipherEncryptDatabase) {
_uiState.update { currentState ->
currentState.copy(
cipherEncryptDatabase = cipherEncryptDatabase
)
}
}
fun consumeCredentialEncrypted() {
_uiState.update { currentState ->
currentState.copy(
cipherEncryptDatabase = null
)
}
}
fun onCredentialDecrypted(cipherDecryptDatabase: CipherDecryptDatabase) {
_uiState.update { currentState ->
currentState.copy(
cipherDecryptDatabase = cipherDecryptDatabase
)
}
}
fun consumeCredentialDecrypted() {
_uiState.update { currentState ->
currentState.copy(
cipherDecryptDatabase = null
)
}
}
fun deleteData() {
_uiState.update { currentState ->
currentState.copy(
initAdvancedUnlockMode = false,
databaseFileUri = null,
isCredentialRequired = false,
credential = null,
isConditionToStoreCredentialVerified = false,
onUnlockAvailabilityCheckRequested = false,
cipherEncryptDatabase = null,
cipherDecryptDatabase = null
)
}
}
}
data class DeviceUnlockUiStates(
val initAdvancedUnlockMode: Boolean = false,
val databaseFileUri: Uri? = null,
val isCredentialRequired: Boolean = false,
val credential: ByteArray? = null,
val isConditionToStoreCredentialVerified: Boolean = false,
val onUnlockAvailabilityCheckRequested: Boolean = false,
val cipherEncryptDatabase: CipherEncryptDatabase? = null,
val cipherDecryptDatabase: CipherDecryptDatabase? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DeviceUnlockUiStates
if (isCredentialRequired != other.isCredentialRequired) return false
if (isConditionToStoreCredentialVerified != other.isConditionToStoreCredentialVerified) return false
if (onUnlockAvailabilityCheckRequested != other.onUnlockAvailabilityCheckRequested) return false
if (!credential.contentEquals(other.credential)) return false
if (cipherEncryptDatabase != other.cipherEncryptDatabase) return false
if (cipherDecryptDatabase != other.cipherDecryptDatabase) return false
return true
}
override fun hashCode(): Int {
var result = isCredentialRequired.hashCode()
result = 31 * result + isConditionToStoreCredentialVerified.hashCode()
result = 31 * result + onUnlockAvailabilityCheckRequested.hashCode()
result = 31 * result + (credential?.contentHashCode() ?: 0)
result = 31 * result + (cipherEncryptDatabase?.hashCode() ?: 0)
result = 31 * result + (cipherDecryptDatabase?.hashCode() ?: 0)
return result
}
}