diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt index 2748c34ea..eed24be68 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt @@ -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() diff --git a/app/src/main/java/com/kunzisoft/keepass/biometric/AdvancedUnlockFragment.kt b/app/src/main/java/com/kunzisoft/keepass/biometric/AdvancedUnlockFragment.kt index 45b949fa1..2165ae8fa 100644 --- a/app/src/main/java/com/kunzisoft/keepass/biometric/AdvancedUnlockFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/biometric/AdvancedUnlockFragment.kt @@ -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 } } \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/view/MainCredentialView.kt b/app/src/main/java/com/kunzisoft/keepass/view/MainCredentialView.kt index 17c51b514..e4d9e2176 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/MainCredentialView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/MainCredentialView.kt @@ -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 = { _ -> diff --git a/app/src/main/java/com/kunzisoft/keepass/viewmodels/AdvancedUnlockViewModel.kt b/app/src/main/java/com/kunzisoft/keepass/viewmodels/AdvancedUnlockViewModel.kt index 1c5c0a140..af22142e6 100644 --- a/app/src/main/java/com/kunzisoft/keepass/viewmodels/AdvancedUnlockViewModel.kt +++ b/app/src/main/java/com/kunzisoft/keepass/viewmodels/AdvancedUnlockViewModel.kt @@ -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 = _uiState + val onInitAdvancedUnlockModeRequested : LiveData get() = _onInitAdvancedUnlockModeRequested private val _onInitAdvancedUnlockModeRequested = SingleLiveEvent() - val onUnlockAvailabilityCheckRequested : LiveData get() = _onUnlockAvailabilityCheckRequested - private val _onUnlockAvailabilityCheckRequested = SingleLiveEvent() - val onDatabaseFileLoaded : LiveData get() = _onDatabaseFileLoaded private val _onDatabaseFileLoaded = SingleLiveEvent() @@ -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 + } } \ No newline at end of file