mirror of
https://github.com/Kunzisoft/KeePassDX.git
synced 2025-12-04 15:49:33 +01:00
@@ -1,4 +1,5 @@
|
||||
KeePassDX(4.1.4)
|
||||
* Fix UnlockManager #2098 #2101
|
||||
|
||||
KeePassDX(4.1.3)
|
||||
* Fix Autofill Registration #2089
|
||||
|
||||
@@ -55,8 +55,8 @@ import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity
|
||||
import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity
|
||||
import com.kunzisoft.keepass.autofill.AutofillComponent
|
||||
import com.kunzisoft.keepass.autofill.AutofillHelper
|
||||
import com.kunzisoft.keepass.biometric.AdvancedUnlockFragment
|
||||
import com.kunzisoft.keepass.biometric.AdvancedUnlockManager
|
||||
import com.kunzisoft.keepass.biometric.DeviceUnlockFragment
|
||||
import com.kunzisoft.keepass.biometric.DeviceUnlockManager
|
||||
import com.kunzisoft.keepass.database.ContextualDatabase
|
||||
import com.kunzisoft.keepass.database.MainCredential
|
||||
import com.kunzisoft.keepass.database.exception.DuplicateUuidDatabaseException
|
||||
@@ -81,7 +81,7 @@ import com.kunzisoft.keepass.utils.getParcelableExtraCompat
|
||||
import com.kunzisoft.keepass.view.MainCredentialView
|
||||
import com.kunzisoft.keepass.view.asError
|
||||
import com.kunzisoft.keepass.view.showActionErrorIfNeeded
|
||||
import com.kunzisoft.keepass.viewmodels.AdvancedUnlockViewModel
|
||||
import com.kunzisoft.keepass.viewmodels.DeviceUnlockViewModel
|
||||
import com.kunzisoft.keepass.viewmodels.DatabaseFileViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.FileNotFoundException
|
||||
@@ -98,10 +98,10 @@ class MainCredentialActivity : DatabaseModeActivity() {
|
||||
private var confirmButtonView: Button? = null
|
||||
private var infoContainerView: ViewGroup? = null
|
||||
private lateinit var coordinatorLayout: CoordinatorLayout
|
||||
private var advancedUnlockFragment: AdvancedUnlockFragment? = null
|
||||
private var deviceUnlockFragment: DeviceUnlockFragment? = null
|
||||
|
||||
private val mDatabaseFileViewModel: DatabaseFileViewModel by viewModels()
|
||||
private val mAdvancedUnlockViewModel: AdvancedUnlockViewModel by viewModels()
|
||||
private val mDeviceUnlockViewModel: DeviceUnlockViewModel by viewModels()
|
||||
|
||||
private val mPasswordActivityEducation = PasswordActivityEducation(this)
|
||||
|
||||
@@ -170,8 +170,9 @@ class MainCredentialActivity : DatabaseModeActivity() {
|
||||
|
||||
// Listen password checkbox to init advanced unlock and confirmation button
|
||||
mainCredentialView?.onConditionToStoreCredentialChanged = { _, verified ->
|
||||
mAdvancedUnlockViewModel.checkUnlockAvailability(
|
||||
conditionToStoreCredentialVerified = verified
|
||||
mDeviceUnlockViewModel.checkConditionToStoreCredential(
|
||||
condition = verified,
|
||||
databaseFileUri = mDatabaseFileUri
|
||||
)
|
||||
// TODO Async by ViewModel
|
||||
enableConfirmationButton()
|
||||
@@ -226,20 +227,22 @@ class MainCredentialActivity : DatabaseModeActivity() {
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
mAdvancedUnlockViewModel.uiState.collect { uiState ->
|
||||
mDeviceUnlockViewModel.uiState.collect { uiState ->
|
||||
// New value received
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (uiState.isCredentialRequired) {
|
||||
mAdvancedUnlockViewModel.provideCredentialForEncryption(
|
||||
mDeviceUnlockViewModel.encryptCredential(
|
||||
getCredentialForEncryption()
|
||||
)
|
||||
}
|
||||
}
|
||||
uiState.cipherEncryptDatabase?.let { cipherEncryptDatabase ->
|
||||
onCredentialEncrypted(cipherEncryptDatabase)
|
||||
mAdvancedUnlockViewModel.consumeCredentialEncrypted()
|
||||
mDeviceUnlockViewModel.consumeCredentialEncrypted()
|
||||
}
|
||||
uiState.cipherDecryptDatabase?.let { cipherDecryptDatabase ->
|
||||
onCredentialDecrypted(cipherDecryptDatabase)
|
||||
mAdvancedUnlockViewModel.consumeCredentialDecrypted()
|
||||
mDeviceUnlockViewModel.consumeCredentialDecrypted()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,11 +253,12 @@ class MainCredentialActivity : DatabaseModeActivity() {
|
||||
super.onResume()
|
||||
|
||||
// Init Biometric elements only if allowed
|
||||
if (PreferencesUtil.isAdvancedUnlockEnable(this)) {
|
||||
advancedUnlockFragment = supportFragmentManager
|
||||
.findFragmentByTag(UNLOCK_FRAGMENT_TAG) as? AdvancedUnlockFragment?
|
||||
if (advancedUnlockFragment == null) {
|
||||
advancedUnlockFragment = AdvancedUnlockFragment().also {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||
&& PreferencesUtil.isAdvancedUnlockEnable(this)) {
|
||||
deviceUnlockFragment = supportFragmentManager
|
||||
.findFragmentByTag(UNLOCK_FRAGMENT_TAG) as? DeviceUnlockFragment?
|
||||
if (deviceUnlockFragment == null) {
|
||||
deviceUnlockFragment = DeviceUnlockFragment().also {
|
||||
supportFragmentManager.commit {
|
||||
replace(
|
||||
R.id.fragment_advanced_unlock_container_view,
|
||||
@@ -276,7 +280,7 @@ class MainCredentialActivity : DatabaseModeActivity() {
|
||||
|
||||
// Don't allow auto open prompt if lock become when UI visible
|
||||
if (DatabaseLockActivity.LOCKING_ACTIVITY_UI_VISIBLE_DURING_LOCK == true) {
|
||||
mAdvancedUnlockViewModel.allowAutoOpenBiometricPrompt = false
|
||||
mDeviceUnlockViewModel.allowAutoOpenBiometricPrompt = false
|
||||
}
|
||||
|
||||
mDatabaseFileUri?.let { databaseFileUri ->
|
||||
@@ -494,7 +498,7 @@ class MainCredentialActivity : DatabaseModeActivity() {
|
||||
loadDatabase()
|
||||
} else {
|
||||
// Init Biometric elements
|
||||
mAdvancedUnlockViewModel.databaseFileLoaded(databaseFileUri)
|
||||
mDeviceUnlockViewModel.databaseFileLoaded(databaseFileUri)
|
||||
}
|
||||
|
||||
enableConfirmationButton()
|
||||
@@ -654,7 +658,7 @@ class MainCredentialActivity : DatabaseModeActivity() {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||
&& !readOnlyEducationPerformed) {
|
||||
val biometricCanAuthenticate = AdvancedUnlockManager.canAuthenticate(this)
|
||||
val biometricCanAuthenticate = DeviceUnlockManager.canAuthenticate(this)
|
||||
if ((biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_SUCCESS)
|
||||
&& advancedUnlockButton != null) {
|
||||
|
||||
@@ -177,14 +177,18 @@ class CipherDatabaseAction(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun containsCipherDatabase(databaseUri: Uri,
|
||||
fun containsCipherDatabase(databaseUri: Uri?,
|
||||
contains: (Boolean) -> Unit) {
|
||||
if (databaseUri == null) {
|
||||
contains.invoke(false)
|
||||
} else {
|
||||
getCipherDatabase(databaseUri) {
|
||||
contains.invoke(it != null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetCipherParameters(databaseUri: Uri) {
|
||||
fun resetCipherParameters(databaseUri: Uri?) {
|
||||
containsCipherDatabase(databaseUri) { contains ->
|
||||
if (contains) {
|
||||
mBinder?.resetTimer()
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package com.kunzisoft.keepass.biometric
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import javax.crypto.Cipher
|
||||
|
||||
data class AdvancedUnlockCryptoPrompt(var cipher: Cipher,
|
||||
@StringRes var promptTitleId: Int,
|
||||
@StringRes var promptDescriptionId: Int? = null,
|
||||
var isDeviceCredentialOperation: Boolean,
|
||||
var isBiometricOperation: Boolean)
|
||||
@@ -1,667 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Jeremy Jamet / Kunzisoft.
|
||||
*
|
||||
* This file is part of KeePassDX.
|
||||
*
|
||||
* KeePassDX is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* KeePassDX is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.biometric
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.biometric.BiometricManager
|
||||
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
|
||||
import com.kunzisoft.keepass.model.CipherDecryptDatabase
|
||||
import com.kunzisoft.keepass.model.CipherEncryptDatabase
|
||||
import com.kunzisoft.keepass.model.CredentialStorage
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.view.AdvancedUnlockInfoView
|
||||
import com.kunzisoft.keepass.view.hideByFading
|
||||
import com.kunzisoft.keepass.view.showByFading
|
||||
import com.kunzisoft.keepass.viewmodels.AdvancedUnlockViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AdvancedUnlockFragment: Fragment(), AdvancedUnlockManager.AdvancedUnlockCallback {
|
||||
|
||||
private var mAdvancedUnlockEnabled = false
|
||||
private var mAutoOpenPromptEnabled = false
|
||||
|
||||
private var advancedUnlockManager: AdvancedUnlockManager? = null
|
||||
private var biometricMode: Mode = Mode.BIOMETRIC_UNAVAILABLE
|
||||
private var mAdvancedUnlockInfoView: AdvancedUnlockInfoView? = null
|
||||
|
||||
var databaseFileUri: Uri? = null
|
||||
private set
|
||||
|
||||
// TODO Retrieve credential storage from app database
|
||||
var credentialDatabaseStorage: CredentialStorage = CredentialStorage.DEFAULT
|
||||
|
||||
// Variable to check if the prompt can be open (if the right activity is currently shown)
|
||||
// checkBiometricAvailability() allows open biometric prompt and onDestroy() removes the authorization
|
||||
private var allowOpenBiometricPrompt = false
|
||||
|
||||
private lateinit var cipherDatabaseAction : CipherDatabaseAction
|
||||
|
||||
private var cipherDatabaseListener: CipherDatabaseAction.CipherDatabaseListener? = null
|
||||
|
||||
private val mAdvancedUnlockViewModel: AdvancedUnlockViewModel by activityViewModels()
|
||||
|
||||
// Only to fix multiple fingerprint menu #332
|
||||
private var mAllowAdvancedUnlockMenu = false
|
||||
private var mAddBiometricMenuInProgress = false
|
||||
|
||||
// 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 ->
|
||||
mAdvancedUnlockViewModel.allowAutoOpenBiometricPrompt = false
|
||||
// To wait resume
|
||||
if (keepConnection) {
|
||||
mAdvancedUnlockViewModel.deviceCredentialAuthSucceeded =
|
||||
result.resultCode == Activity.RESULT_OK
|
||||
}
|
||||
keepConnection = false
|
||||
}
|
||||
|
||||
private val menuProvider: MenuProvider = object: MenuProvider {
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// biometric menu
|
||||
if (mAllowAdvancedUnlockMenu)
|
||||
menuInflater.inflate(R.menu.advanced_unlock, menu)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
R.id.menu_keystore_remove_key -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
deleteEncryptedDatabaseKey()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
cipherDatabaseAction = CipherDatabaseAction.getInstance(requireContext().applicationContext)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
|
||||
val rootView = inflater.inflate(R.layout.fragment_advanced_unlock, container, false)
|
||||
|
||||
mAdvancedUnlockInfoView = rootView.findViewById(R.id.advanced_unlock_view)
|
||||
|
||||
return rootView
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
activity?.addMenuProvider(menuProvider, viewLifecycleOwner)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
mAdvancedUnlockViewModel.uiState.collect { uiState ->
|
||||
// Database loaded
|
||||
uiState.databaseFileLoaded?.let { databaseLoaded ->
|
||||
onDatabaseLoaded(databaseLoaded)
|
||||
mAdvancedUnlockViewModel.consumeDatabaseFileLoaded()
|
||||
}
|
||||
// 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() {
|
||||
super.onResume()
|
||||
context?.let {
|
||||
mAdvancedUnlockEnabled = PreferencesUtil.isAdvancedUnlockEnable(it)
|
||||
mAutoOpenPromptEnabled = PreferencesUtil.isAdvancedUnlockPromptAutoOpenEnable(it)
|
||||
}
|
||||
keepConnection = false
|
||||
}
|
||||
|
||||
private fun onDatabaseLoaded(databaseUri: Uri?) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// To get device credential unlock result, only if same database uri
|
||||
if (databaseUri != null
|
||||
&& mAdvancedUnlockEnabled) {
|
||||
mAdvancedUnlockViewModel.deviceCredentialAuthSucceeded?.let { authSucceeded ->
|
||||
if (databaseUri == databaseFileUri) {
|
||||
if (authSucceeded) {
|
||||
advancedUnlockManager?.advancedUnlockCallback?.onAuthenticationSucceeded()
|
||||
} else {
|
||||
advancedUnlockManager?.advancedUnlockCallback?.onAuthenticationFailed()
|
||||
}
|
||||
} else {
|
||||
disconnect()
|
||||
}
|
||||
} ?: run {
|
||||
if (databaseUri != databaseFileUri) {
|
||||
connect(databaseUri)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
disconnect()
|
||||
}
|
||||
mAdvancedUnlockViewModel.deviceCredentialAuthSucceeded = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check unlock availability and change the current mode depending of device's state
|
||||
*/
|
||||
private fun checkUnlockAvailability() {
|
||||
context?.let { context ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
allowOpenBiometricPrompt = true
|
||||
if (PreferencesUtil.isBiometricUnlockEnable(context)) {
|
||||
// biometric not supported (by API level or hardware) so keep option hidden
|
||||
// or manually disable
|
||||
val biometricCanAuthenticate = AdvancedUnlockManager.canAuthenticate(context)
|
||||
if (!PreferencesUtil.isAdvancedUnlockEnable(context)
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE) {
|
||||
toggleMode(Mode.BIOMETRIC_UNAVAILABLE)
|
||||
} else if (biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED) {
|
||||
toggleMode(Mode.BIOMETRIC_SECURITY_UPDATE_REQUIRED)
|
||||
} else {
|
||||
// biometric is available but not configured, show icon but in disabled state with some information
|
||||
if (biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED) {
|
||||
toggleMode(Mode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED)
|
||||
} else {
|
||||
selectMode()
|
||||
}
|
||||
}
|
||||
} else if (PreferencesUtil.isDeviceCredentialUnlockEnable(context)) {
|
||||
if (AdvancedUnlockManager.isDeviceSecure(context)) {
|
||||
selectMode()
|
||||
} else {
|
||||
toggleMode(Mode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun selectMode() {
|
||||
// Check if fingerprint well init (be called the first time the fingerprint is configured
|
||||
// and the activity still active)
|
||||
if (advancedUnlockManager?.isKeyManagerInitialized != true) {
|
||||
advancedUnlockManager = AdvancedUnlockManager { requireActivity() }
|
||||
// callback for fingerprint findings
|
||||
advancedUnlockManager?.advancedUnlockCallback = this
|
||||
}
|
||||
// Recheck to change the mode
|
||||
if (advancedUnlockManager?.isKeyManagerInitialized != true) {
|
||||
toggleMode(Mode.KEY_MANAGER_UNAVAILABLE)
|
||||
} else {
|
||||
if (isConditionToStoreCredentialVerified) {
|
||||
// listen for encryption
|
||||
toggleMode(Mode.STORE_CREDENTIAL)
|
||||
} else {
|
||||
databaseFileUri?.let { databaseUri ->
|
||||
cipherDatabaseAction.containsCipherDatabase(databaseUri) { containsCipher ->
|
||||
// biometric available but no stored password found yet for this DB so show info don't listen
|
||||
toggleMode(if (containsCipher) {
|
||||
// listen for decryption
|
||||
Mode.EXTRACT_CREDENTIAL
|
||||
} else {
|
||||
if (isConditionToStoreCredentialVerified) {
|
||||
// if condition OK, key manager in error
|
||||
Mode.KEY_MANAGER_UNAVAILABLE
|
||||
} else {
|
||||
// wait for typing
|
||||
Mode.WAIT_CREDENTIAL
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun toggleMode(newBiometricMode: Mode) {
|
||||
if (newBiometricMode != biometricMode) {
|
||||
biometricMode = newBiometricMode
|
||||
initAdvancedUnlockMode()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initNotAvailable() {
|
||||
showViews(false)
|
||||
|
||||
mAdvancedUnlockInfoView?.setIconViewClickListener(null)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun openBiometricSetting() {
|
||||
mAdvancedUnlockInfoView?.setIconViewClickListener {
|
||||
try {
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
|
||||
context?.startActivity(Intent(Settings.ACTION_BIOMETRIC_ENROLL))
|
||||
}
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> {
|
||||
@Suppress("DEPRECATION") context
|
||||
?.startActivity(Intent(Settings.ACTION_FINGERPRINT_ENROLL))
|
||||
}
|
||||
else -> {
|
||||
context?.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// ACTION_SECURITY_SETTINGS does not contain fingerprint enrollment on some devices...
|
||||
context?.startActivity(Intent(Settings.ACTION_SETTINGS))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initSecurityUpdateRequired() {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.biometric_security_update_required)
|
||||
|
||||
openBiometricSetting()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initNotConfigured() {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.configure_biometric)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
|
||||
openBiometricSetting()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initKeyManagerNotAvailable() {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.keystore_not_accessible)
|
||||
|
||||
openBiometricSetting()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initWaitData() {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.unavailable)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
|
||||
context?.let { context ->
|
||||
mAdvancedUnlockInfoView?.setIconViewClickListener {
|
||||
onAuthenticationError(BiometricPrompt.ERROR_UNABLE_TO_PROCESS,
|
||||
context.getString(R.string.credential_before_click_advanced_unlock_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun openAdvancedUnlockPrompt(cryptoPrompt: AdvancedUnlockCryptoPrompt) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
if (allowOpenBiometricPrompt) {
|
||||
if (cryptoPrompt.isDeviceCredentialOperation)
|
||||
keepConnection = true
|
||||
try {
|
||||
advancedUnlockManager?.openAdvancedUnlockPrompt(cryptoPrompt,
|
||||
mDeviceCredentialResultLauncher)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to open advanced unlock prompt", e)
|
||||
setAdvancedUnlockedTitleView(R.string.advanced_unlock_prompt_not_initialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initEncryptData() {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.unlock_and_link_biometric)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
|
||||
advancedUnlockManager?.initEncryptData { cryptoPrompt ->
|
||||
// Set listener to open the biometric dialog and save credential
|
||||
mAdvancedUnlockInfoView?.setIconViewClickListener { _ ->
|
||||
openAdvancedUnlockPrompt(cryptoPrompt)
|
||||
}
|
||||
} ?: throw Exception("AdvancedUnlockManager not initialized")
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initDecryptData() {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.unlock)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
|
||||
advancedUnlockManager?.let { unlockHelper ->
|
||||
databaseFileUri?.let { databaseUri ->
|
||||
cipherDatabaseAction.getCipherDatabase(databaseUri) { cipherDatabase ->
|
||||
cipherDatabase?.let {
|
||||
unlockHelper.initDecryptData(it.specParameters) { cryptoPrompt ->
|
||||
|
||||
// Set listener to open the biometric dialog and check credential
|
||||
mAdvancedUnlockInfoView?.setIconViewClickListener { _ ->
|
||||
openAdvancedUnlockPrompt(cryptoPrompt)
|
||||
}
|
||||
|
||||
// Auto open the biometric prompt
|
||||
if (mAdvancedUnlockViewModel.allowAutoOpenBiometricPrompt
|
||||
&& mAutoOpenPromptEnabled) {
|
||||
mAdvancedUnlockViewModel.allowAutoOpenBiometricPrompt = false
|
||||
openAdvancedUnlockPrompt(cryptoPrompt)
|
||||
}
|
||||
}
|
||||
} ?: deleteEncryptedDatabaseKey()
|
||||
}
|
||||
} ?: throw UnknownDatabaseLocationException()
|
||||
} ?: throw Exception("AdvancedUnlockManager not initialized")
|
||||
}
|
||||
|
||||
private fun initAdvancedUnlockMode() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
mAllowAdvancedUnlockMenu = false
|
||||
try {
|
||||
when (biometricMode) {
|
||||
Mode.BIOMETRIC_UNAVAILABLE -> initNotAvailable()
|
||||
Mode.BIOMETRIC_SECURITY_UPDATE_REQUIRED -> initSecurityUpdateRequired()
|
||||
Mode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED -> initNotConfigured()
|
||||
Mode.KEY_MANAGER_UNAVAILABLE -> initKeyManagerNotAvailable()
|
||||
Mode.WAIT_CREDENTIAL -> initWaitData()
|
||||
Mode.STORE_CREDENTIAL -> initEncryptData()
|
||||
Mode.EXTRACT_CREDENTIAL -> initDecryptData()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onGenericException(e)
|
||||
}
|
||||
invalidateBiometricMenu()
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidateBiometricMenu() {
|
||||
// Show fingerprint key deletion
|
||||
if (!mAddBiometricMenuInProgress) {
|
||||
mAddBiometricMenuInProgress = true
|
||||
databaseFileUri?.let { databaseUri ->
|
||||
cipherDatabaseAction.containsCipherDatabase(databaseUri) { containsCipher ->
|
||||
mAllowAdvancedUnlockMenu = containsCipher
|
||||
&& (biometricMode != Mode.BIOMETRIC_UNAVAILABLE
|
||||
&& biometricMode != Mode.KEY_MANAGER_UNAVAILABLE)
|
||||
mAddBiometricMenuInProgress = false
|
||||
activity?.invalidateOptionsMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun connect(databaseUri: Uri) {
|
||||
showViews(true)
|
||||
this.databaseFileUri = databaseUri
|
||||
cipherDatabaseListener = object: CipherDatabaseAction.CipherDatabaseListener {
|
||||
override fun onCipherDatabaseCleared() {
|
||||
advancedUnlockManager?.closeBiometricPrompt()
|
||||
checkUnlockAvailability()
|
||||
}
|
||||
}
|
||||
cipherDatabaseAction.apply {
|
||||
reloadPreferences()
|
||||
cipherDatabaseListener?.let {
|
||||
registerDatabaseListener(it)
|
||||
}
|
||||
}
|
||||
checkUnlockAvailability()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun disconnect(hideViews: Boolean = true,
|
||||
closePrompt: Boolean = true) {
|
||||
this.databaseFileUri = null
|
||||
// Close the biometric prompt
|
||||
allowOpenBiometricPrompt = false
|
||||
if (closePrompt)
|
||||
advancedUnlockManager?.closeBiometricPrompt()
|
||||
cipherDatabaseListener?.let {
|
||||
cipherDatabaseAction.unregisterDatabaseListener(it)
|
||||
}
|
||||
biometricMode = Mode.BIOMETRIC_UNAVAILABLE
|
||||
if (hideViews) {
|
||||
showViews(false)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun deleteEncryptedDatabaseKey() {
|
||||
mAllowAdvancedUnlockMenu = false
|
||||
advancedUnlockManager?.closeBiometricPrompt()
|
||||
databaseFileUri?.let { databaseUri ->
|
||||
cipherDatabaseAction.deleteByDatabaseUri(databaseUri) {
|
||||
checkUnlockAvailability()
|
||||
}
|
||||
} ?: checkUnlockAvailability()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
Log.e(TAG, "Biometric authentication error. Code : $errorCode Error : $errString")
|
||||
setAdvancedUnlockedMessageView(errString.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
override fun onAuthenticationFailed() {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
Log.e(TAG, "Biometric authentication failed, biometric not recognized")
|
||||
setAdvancedUnlockedMessageView(R.string.advanced_unlock_not_recognized)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
override fun onAuthenticationSucceeded() {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
when (biometricMode) {
|
||||
Mode.BIOMETRIC_UNAVAILABLE -> {
|
||||
}
|
||||
Mode.BIOMETRIC_SECURITY_UPDATE_REQUIRED -> {
|
||||
}
|
||||
Mode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED -> {
|
||||
}
|
||||
Mode.KEY_MANAGER_UNAVAILABLE -> {
|
||||
}
|
||||
Mode.WAIT_CREDENTIAL -> {
|
||||
}
|
||||
Mode.STORE_CREDENTIAL -> {
|
||||
// newly store the entered password in encrypted way
|
||||
mAdvancedUnlockViewModel.retrieveCredentialForEncryption()
|
||||
}
|
||||
Mode.EXTRACT_CREDENTIAL -> {
|
||||
// retrieve the encrypted value from preferences
|
||||
databaseFileUri?.let { databaseUri ->
|
||||
cipherDatabaseAction.getCipherDatabase(databaseUri) { cipherDatabase ->
|
||||
cipherDatabase?.encryptedValue?.let { value ->
|
||||
advancedUnlockManager?.decryptData(value)
|
||||
} ?: deleteEncryptedDatabaseKey()
|
||||
}
|
||||
} ?: run {
|
||||
onAuthenticationError(-1, getString(R.string.error_database_uri_null))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleEncryptedResult(encryptedValue: ByteArray, ivSpec: ByteArray) {
|
||||
databaseFileUri?.let { databaseUri ->
|
||||
mAdvancedUnlockViewModel.onCredentialEncrypted(
|
||||
CipherEncryptDatabase().apply {
|
||||
this.databaseUri = databaseUri
|
||||
this.credentialStorage = credentialDatabaseStorage
|
||||
this.encryptedValue = encryptedValue
|
||||
this.specParameters = ivSpec
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleDecryptedResult(decryptedValue: ByteArray) {
|
||||
// Load database directly with password retrieve
|
||||
databaseFileUri?.let { databaseUri ->
|
||||
mAdvancedUnlockViewModel.onCredentialDecrypted(
|
||||
CipherDecryptDatabase().apply {
|
||||
this.databaseUri = databaseUri
|
||||
this.credentialStorage = credentialDatabaseStorage
|
||||
this.decryptedValue = decryptedValue
|
||||
}
|
||||
)
|
||||
cipherDatabaseAction.resetCipherParameters(databaseUri)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
override fun onUnrecoverableKeyException(e: Exception) {
|
||||
setAdvancedUnlockedMessageView(R.string.advanced_unlock_invalid_key)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
override fun onInvalidKeyException(e: Exception) {
|
||||
setAdvancedUnlockedMessageView(R.string.advanced_unlock_invalid_key)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
override fun onGenericException(e: Exception) {
|
||||
val errorMessage = e.cause?.localizedMessage ?: e.localizedMessage ?: ""
|
||||
setAdvancedUnlockedMessageView(errorMessage)
|
||||
}
|
||||
|
||||
private fun showViews(show: Boolean) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
if (show) {
|
||||
if (mAdvancedUnlockInfoView?.visibility != View.VISIBLE)
|
||||
mAdvancedUnlockInfoView?.showByFading()
|
||||
}
|
||||
else {
|
||||
if (mAdvancedUnlockInfoView?.visibility == View.VISIBLE)
|
||||
mAdvancedUnlockInfoView?.hideByFading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun setAdvancedUnlockedTitleView(textId: Int) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
mAdvancedUnlockInfoView?.setTitle(textId)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun setAdvancedUnlockedMessageView(textId: Int) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
mAdvancedUnlockInfoView?.setMessage(textId)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun setAdvancedUnlockedMessageView(text: CharSequence) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
mAdvancedUnlockInfoView?.setMessage(text)
|
||||
}
|
||||
}
|
||||
|
||||
enum class Mode {
|
||||
BIOMETRIC_UNAVAILABLE,
|
||||
BIOMETRIC_SECURITY_UPDATE_REQUIRED,
|
||||
DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED,
|
||||
KEY_MANAGER_UNAVAILABLE,
|
||||
WAIT_CREDENTIAL,
|
||||
STORE_CREDENTIAL,
|
||||
EXTRACT_CREDENTIAL
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (!keepConnection) {
|
||||
// If close prompt, bug "user not authenticated in Android R"
|
||||
disconnect(false)
|
||||
advancedUnlockManager = null
|
||||
}
|
||||
}
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
mAdvancedUnlockInfoView = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
disconnect()
|
||||
advancedUnlockManager = null
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = AdvancedUnlockFragment::class.java.name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.kunzisoft.keepass.biometric
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import javax.crypto.Cipher
|
||||
|
||||
data class DeviceUnlockCryptoPrompt(
|
||||
var type: DeviceUnlockCryptoPromptType,
|
||||
var cipher: Cipher,
|
||||
@StringRes var titleId: Int,
|
||||
@StringRes var descriptionId: Int? = null,
|
||||
var isDeviceCredentialOperation: Boolean,
|
||||
var isBiometricOperation: Boolean
|
||||
)
|
||||
|
||||
enum class DeviceUnlockCryptoPromptType {
|
||||
CREDENTIAL_ENCRYPTION, CREDENTIAL_DECRYPTION
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
/*
|
||||
* Copyright 2020 Jeremy Jamet / Kunzisoft.
|
||||
*
|
||||
* This file is part of KeePassDX.
|
||||
*
|
||||
* KeePassDX is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* KeePassDX is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.biometric
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
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.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.view.DeviceUnlockView
|
||||
import com.kunzisoft.keepass.view.hideByFading
|
||||
import com.kunzisoft.keepass.view.showByFading
|
||||
import com.kunzisoft.keepass.viewmodels.DeviceUnlockViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.UnrecoverableKeyException
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
class DeviceUnlockFragment: Fragment() {
|
||||
|
||||
private var mDeviceUnlockView: DeviceUnlockView? = null
|
||||
|
||||
private val mDeviceUnlockViewModel: DeviceUnlockViewModel by activityViewModels()
|
||||
|
||||
private var mBiometricPrompt: BiometricPrompt? = null
|
||||
|
||||
// Only to fix multiple fingerprint menu #332
|
||||
private var mAllowAdvancedUnlockMenu = false
|
||||
|
||||
// Only keep connection when we request a device credential activity
|
||||
private var keepConnection = false
|
||||
|
||||
private var storeCredentialButtonClickListener: View.OnClickListener? = null
|
||||
private var extractCredentialButtonClickListener: View.OnClickListener? = null
|
||||
|
||||
private var mDeviceCredentialResultLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
mDeviceUnlockViewModel.allowAutoOpenBiometricPrompt = false
|
||||
// To wait resume
|
||||
if (keepConnection) {
|
||||
mDeviceUnlockViewModel.deviceCredentialAuthSucceeded =
|
||||
result.resultCode == Activity.RESULT_OK
|
||||
}
|
||||
keepConnection = false
|
||||
}
|
||||
|
||||
private var storeAuthenticationCallback = object: BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
// newly store the entered password in encrypted way
|
||||
mDeviceUnlockViewModel.retrieveCredentialForEncryption()
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
setAuthenticationFailed()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
setAuthenticationError(errorCode, errString)
|
||||
}
|
||||
}
|
||||
|
||||
private var extractAuthenticationCallback = object: BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
mDeviceUnlockViewModel.decryptCredential()
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
setAuthenticationFailed()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
setAuthenticationError(errorCode, errString)
|
||||
}
|
||||
}
|
||||
|
||||
private val menuProvider: MenuProvider = object: MenuProvider {
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
// biometric menu
|
||||
if (mAllowAdvancedUnlockMenu)
|
||||
menuInflater.inflate(R.menu.advanced_unlock, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
R.id.menu_keystore_remove_key ->
|
||||
deleteEncryptedDatabaseKey()
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
|
||||
val rootView = inflater.inflate(R.layout.fragment_advanced_unlock, container, false)
|
||||
|
||||
mDeviceUnlockView = rootView.findViewById(R.id.advanced_unlock_view)
|
||||
|
||||
return rootView
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
activity?.addMenuProvider(menuProvider, viewLifecycleOwner)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
mDeviceUnlockViewModel.uiState.collect { uiState ->
|
||||
// Change mode
|
||||
toggleDeviceCredentialMode(uiState.deviceUnlockMode)
|
||||
// Prompt
|
||||
uiState.cryptoPrompt?.let { prompt ->
|
||||
mDeviceUnlockViewModel.promptShown()
|
||||
when (prompt.type) {
|
||||
DeviceUnlockCryptoPromptType.CREDENTIAL_ENCRYPTION ->
|
||||
manageEncryptionPrompt(prompt)
|
||||
DeviceUnlockCryptoPromptType.CREDENTIAL_DECRYPTION ->
|
||||
manageDecryptionPrompt(prompt)
|
||||
}
|
||||
}
|
||||
if (uiState.closePromptRequested) {
|
||||
closeBiometricPrompt()
|
||||
mDeviceUnlockViewModel.biometricPromptClosed()
|
||||
}
|
||||
// Errors
|
||||
setAdvancedUnlockedError(uiState.error)
|
||||
// Advanced menu
|
||||
mAllowAdvancedUnlockMenu = uiState.allowAdvancedUnlockMenu
|
||||
activity?.invalidateOptionsMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
keepConnection = false
|
||||
}
|
||||
|
||||
fun openAdvancedUnlockPrompt(
|
||||
cryptoPrompt: DeviceUnlockCryptoPrompt,
|
||||
authenticationCallback: BiometricPrompt.AuthenticationCallback
|
||||
) {
|
||||
// Init advanced unlock prompt
|
||||
mBiometricPrompt = BiometricPrompt(
|
||||
this@DeviceUnlockFragment,
|
||||
Executors.newSingleThreadExecutor(),
|
||||
authenticationCallback
|
||||
)
|
||||
|
||||
val promptTitle = getString(cryptoPrompt.titleId)
|
||||
val promptDescription = cryptoPrompt.descriptionId?.let { descriptionId ->
|
||||
getString(descriptionId)
|
||||
} ?: ""
|
||||
|
||||
if (cryptoPrompt.isBiometricOperation) {
|
||||
val promptInfoExtractCredential = BiometricPrompt.PromptInfo.Builder().apply {
|
||||
setTitle(promptTitle)
|
||||
if (promptDescription.isNotEmpty())
|
||||
setDescription(promptDescription)
|
||||
setConfirmationRequired(false)
|
||||
if (isDeviceCredentialBiometricOperation(context)) {
|
||||
setAllowedAuthenticators(DEVICE_CREDENTIAL)
|
||||
} else {
|
||||
setNegativeButtonText(getString(android.R.string.cancel))
|
||||
}
|
||||
}.build()
|
||||
mBiometricPrompt?.authenticate(
|
||||
promptInfoExtractCredential,
|
||||
BiometricPrompt.CryptoObject(cryptoPrompt.cipher))
|
||||
}
|
||||
else if (cryptoPrompt.isDeviceCredentialOperation) {
|
||||
context?.let { context ->
|
||||
val keyGuardManager = ContextCompat.getSystemService(
|
||||
context,
|
||||
KeyguardManager::class.java
|
||||
)
|
||||
@Suppress("DEPRECATION")
|
||||
mDeviceCredentialResultLauncher.launch(
|
||||
keyGuardManager?.createConfirmDeviceCredentialIntent(
|
||||
promptTitle,
|
||||
promptDescription
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeBiometricPrompt() {
|
||||
mBiometricPrompt?.cancelAuthentication()
|
||||
}
|
||||
|
||||
private var currentCredentialMode = DeviceUnlockMode.BIOMETRIC_UNAVAILABLE
|
||||
private fun toggleDeviceCredentialMode(deviceUnlockMode: DeviceUnlockMode) {
|
||||
if (currentCredentialMode == deviceUnlockMode) {
|
||||
return
|
||||
}
|
||||
currentCredentialMode = deviceUnlockMode
|
||||
try {
|
||||
when (deviceUnlockMode) {
|
||||
DeviceUnlockMode.BIOMETRIC_UNAVAILABLE -> setNotAvailableMode()
|
||||
DeviceUnlockMode.BIOMETRIC_SECURITY_UPDATE_REQUIRED -> setSecurityUpdateRequiredMode()
|
||||
DeviceUnlockMode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED -> setNotConfiguredMode()
|
||||
DeviceUnlockMode.KEY_MANAGER_UNAVAILABLE -> setKeyManagerNotAvailableMode()
|
||||
DeviceUnlockMode.WAIT_CREDENTIAL -> setWaitCredentialMode()
|
||||
DeviceUnlockMode.STORE_CREDENTIAL -> setStoreCredentialMode()
|
||||
DeviceUnlockMode.EXTRACT_CREDENTIAL -> setExtractCredentialMode()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showGenericException(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun manageEncryptionPrompt(cryptoPrompt: DeviceUnlockCryptoPrompt) {
|
||||
if (cryptoPrompt.isDeviceCredentialOperation) {
|
||||
keepConnection = true
|
||||
}
|
||||
storeCredentialButtonClickListener = View.OnClickListener { _ ->
|
||||
try {
|
||||
openAdvancedUnlockPrompt(
|
||||
cryptoPrompt,
|
||||
storeAuthenticationCallback
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to open encryption prompt", e)
|
||||
storeCredentialButtonClickListener = null
|
||||
setAdvancedUnlockedTitleView(R.string.advanced_unlock_prompt_not_initialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openExtractPrompt(cryptoPrompt: DeviceUnlockCryptoPrompt) {
|
||||
try {
|
||||
openAdvancedUnlockPrompt(
|
||||
cryptoPrompt,
|
||||
extractAuthenticationCallback
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to open decryption prompt", e)
|
||||
extractCredentialButtonClickListener = null
|
||||
setAdvancedUnlockedTitleView(R.string.advanced_unlock_prompt_not_initialized)
|
||||
}
|
||||
}
|
||||
|
||||
private fun manageDecryptionPrompt(cryptoPrompt: DeviceUnlockCryptoPrompt) {
|
||||
// Set listener to open the biometric dialog and check credential
|
||||
extractCredentialButtonClickListener = View.OnClickListener { _ ->
|
||||
openExtractPrompt(cryptoPrompt)
|
||||
}
|
||||
// Auto open the biometric prompt
|
||||
if (mDeviceUnlockViewModel.allowAutoOpenBiometricPrompt
|
||||
&& PreferencesUtil.isAdvancedUnlockPromptAutoOpenEnable(requireContext())) {
|
||||
mDeviceUnlockViewModel.allowAutoOpenBiometricPrompt = false
|
||||
openExtractPrompt(cryptoPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun showGenericException(e: Exception) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
val errorMessage = e.cause?.localizedMessage ?: e.localizedMessage ?: ""
|
||||
setAdvancedUnlockedMessageView(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setNotAvailableMode() {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
showViews(false)
|
||||
mDeviceUnlockView?.setDeviceUnlockButtonViewClickListener(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openBiometricSetting() {
|
||||
mDeviceUnlockView?.setDeviceUnlockButtonViewClickListener {
|
||||
try {
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
|
||||
context?.startActivity(Intent(Settings.ACTION_BIOMETRIC_ENROLL))
|
||||
}
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> {
|
||||
@Suppress("DEPRECATION") context
|
||||
?.startActivity(Intent(Settings.ACTION_FINGERPRINT_ENROLL))
|
||||
}
|
||||
else -> {
|
||||
context?.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// ACTION_SECURITY_SETTINGS does not contain fingerprint enrollment on some devices...
|
||||
context?.startActivity(Intent(Settings.ACTION_SETTINGS))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setSecurityUpdateRequiredMode() {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.biometric_security_update_required)
|
||||
openBiometricSetting()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setNotConfiguredMode() {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.configure_biometric)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
openBiometricSetting()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setKeyManagerNotAvailableMode() {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.keystore_not_accessible)
|
||||
openBiometricSetting()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setWaitCredentialMode() {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.unavailable)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
context?.let { context ->
|
||||
mDeviceUnlockView?.setDeviceUnlockButtonViewClickListener {
|
||||
showError(
|
||||
BiometricPrompt.ERROR_UNABLE_TO_PROCESS,
|
||||
context.getString(R.string.credential_before_click_advanced_unlock_button)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(errorCode: Int, errString: CharSequence) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
Log.e(TAG, "Biometric authentication error. Code : $errorCode Error : $errString")
|
||||
setAdvancedUnlockedMessageView(errString.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun setStoreCredentialMode() {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.unlock_and_link_biometric)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
context?.let { context ->
|
||||
mDeviceUnlockView?.setDeviceUnlockButtonViewClickListener { view ->
|
||||
storeCredentialButtonClickListener?.onClick(view) ?: run {
|
||||
showError(
|
||||
BiometricPrompt.ERROR_HW_UNAVAILABLE,
|
||||
context.getString(R.string.keystore_not_accessible)
|
||||
)
|
||||
}
|
||||
storeCredentialButtonClickListener = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setExtractCredentialMode() {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.unlock)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
context?.let { context ->
|
||||
mDeviceUnlockView?.setDeviceUnlockButtonViewClickListener { view ->
|
||||
extractCredentialButtonClickListener?.onClick(view) ?: run {
|
||||
showError(
|
||||
BiometricPrompt.ERROR_HW_UNAVAILABLE,
|
||||
context.getString(R.string.keystore_not_accessible)
|
||||
)
|
||||
}
|
||||
extractCredentialButtonClickListener = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteEncryptedDatabaseKey() {
|
||||
mDeviceUnlockViewModel.deleteEncryptedDatabaseKey()
|
||||
}
|
||||
|
||||
private fun showViews(show: Boolean) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
if (show) {
|
||||
if (mDeviceUnlockView?.visibility != View.VISIBLE)
|
||||
mDeviceUnlockView?.showByFading()
|
||||
}
|
||||
else {
|
||||
if (mDeviceUnlockView?.visibility == View.VISIBLE)
|
||||
mDeviceUnlockView?.hideByFading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAdvancedUnlockedTitleView(textId: Int) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
mDeviceUnlockView?.setTitle(textId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAdvancedUnlockedMessageView(textId: Int) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
mDeviceUnlockView?.setMessage(textId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAdvancedUnlockedMessageView(text: CharSequence?) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
mDeviceUnlockView?.setMessage(text)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAdvancedUnlockedError(exception: Exception?) {
|
||||
when (exception) {
|
||||
is UnrecoverableKeyException -> {
|
||||
setAdvancedUnlockedMessageView(R.string.advanced_unlock_invalid_key)
|
||||
}
|
||||
is KeyPermanentlyInvalidatedException -> {
|
||||
setAdvancedUnlockedMessageView(R.string.advanced_unlock_invalid_key)
|
||||
}
|
||||
else -> {
|
||||
setAdvancedUnlockedMessageView(
|
||||
exception?.cause?.localizedMessage
|
||||
?: exception?.localizedMessage
|
||||
?: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
Log.e(TAG, "Biometric authentication error. Code : $errorCode Error : $errString")
|
||||
setAdvancedUnlockedMessageView(errString.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAuthenticationFailed() {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
Log.e(TAG, "Biometric authentication failed, biometric not recognized")
|
||||
setAdvancedUnlockedMessageView(R.string.advanced_unlock_not_recognized)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
if (!keepConnection) {
|
||||
// If close prompt, bug "user not authenticated in Android R"
|
||||
mDeviceUnlockViewModel.disconnect()
|
||||
}
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
mDeviceUnlockView = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
mDeviceUnlockViewModel.disconnect()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = DeviceUnlockFragment::class.java.name
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ package com.kunzisoft.keepass.biometric
|
||||
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
@@ -29,110 +28,70 @@ import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.*
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.app.database.CipherDatabaseAction
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import java.security.KeyStore
|
||||
import java.security.UnrecoverableKeyException
|
||||
import java.util.concurrent.Executors
|
||||
import javax.crypto.BadPaddingException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity) {
|
||||
class DeviceUnlockManager(private var appContext: Context) {
|
||||
|
||||
private var keyStore: KeyStore? = null
|
||||
private var keyGenerator: KeyGenerator? = null
|
||||
private var cipher: Cipher? = null
|
||||
|
||||
private var biometricPrompt: BiometricPrompt? = null
|
||||
private var authenticationCallback = object: BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
advancedUnlockCallback?.onAuthenticationSucceeded()
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
advancedUnlockCallback?.onAuthenticationFailed()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
advancedUnlockCallback?.onAuthenticationError(errorCode, errString)
|
||||
}
|
||||
}
|
||||
|
||||
var advancedUnlockCallback: AdvancedUnlockCallback? = null
|
||||
|
||||
private var isKeyManagerInit = false
|
||||
|
||||
private val biometricUnlockEnable = PreferencesUtil.isBiometricUnlockEnable(retrieveContext())
|
||||
private val deviceCredentialUnlockEnable = PreferencesUtil.isDeviceCredentialUnlockEnable(retrieveContext())
|
||||
|
||||
val isKeyManagerInitialized: Boolean
|
||||
get() {
|
||||
if (!isKeyManagerInit) {
|
||||
advancedUnlockCallback?.onGenericException(Exception("Biometric not initialized"))
|
||||
}
|
||||
return isKeyManagerInit
|
||||
}
|
||||
|
||||
private fun isBiometricOperation(): Boolean {
|
||||
return biometricUnlockEnable || isDeviceCredentialBiometricOperation()
|
||||
}
|
||||
|
||||
// Since Android 30, device credential is also a biometric operation
|
||||
private fun isDeviceCredentialOperation(): Boolean {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.R
|
||||
&& deviceCredentialUnlockEnable
|
||||
}
|
||||
|
||||
private fun isDeviceCredentialBiometricOperation(): Boolean {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
&& deviceCredentialUnlockEnable
|
||||
}
|
||||
private var biometricUnlockEnable = isBiometricUnlockEnable(appContext)
|
||||
private var deviceCredentialUnlockEnable = isDeviceCredentialUnlockEnable(appContext)
|
||||
|
||||
init {
|
||||
if (isDeviceSecure(retrieveContext())
|
||||
&& (biometricUnlockEnable || deviceCredentialUnlockEnable)) {
|
||||
if (biometricUnlockEnable || deviceCredentialUnlockEnable) {
|
||||
if (isDeviceSecure(appContext)) {
|
||||
try {
|
||||
this.keyStore = KeyStore.getInstance(ADVANCED_UNLOCK_KEYSTORE)
|
||||
this.keyGenerator = KeyGenerator.getInstance(ADVANCED_UNLOCK_KEY_ALGORITHM, ADVANCED_UNLOCK_KEYSTORE)
|
||||
this.keyGenerator = KeyGenerator.getInstance(
|
||||
ADVANCED_UNLOCK_KEY_ALGORITHM,
|
||||
ADVANCED_UNLOCK_KEYSTORE
|
||||
)
|
||||
this.cipher = Cipher.getInstance(
|
||||
ADVANCED_UNLOCK_KEY_ALGORITHM + "/"
|
||||
+ ADVANCED_UNLOCK_BLOCKS_MODES + "/"
|
||||
+ ADVANCED_UNLOCK_ENCRYPTION_PADDING)
|
||||
isKeyManagerInit = (keyStore != null
|
||||
&& keyGenerator != null
|
||||
&& cipher != null)
|
||||
+ ADVANCED_UNLOCK_ENCRYPTION_PADDING
|
||||
)
|
||||
if (keyStore == null) {
|
||||
throw SecurityException("Unable to initialize the keystore")
|
||||
}
|
||||
if (keyGenerator == null) {
|
||||
throw SecurityException("Unable to initialize the key generator")
|
||||
}
|
||||
if (cipher == null) {
|
||||
throw SecurityException("Unable to initialize the cipher")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to initialize the keystore", e)
|
||||
isKeyManagerInit = false
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
Log.e(TAG, "Unable to initialize the device unlock manager", e)
|
||||
throw e
|
||||
}
|
||||
} else {
|
||||
// really not much to do when no fingerprint support found
|
||||
isKeyManagerInit = false
|
||||
throw SecurityException("Device not secure enough")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized private fun getSecretKey(): SecretKey? {
|
||||
if (!isKeyManagerInitialized) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
// Create new key if needed
|
||||
keyStore?.let { keyStore ->
|
||||
keyStore.load(null)
|
||||
|
||||
try {
|
||||
if (!keyStore.containsAlias(ADVANCED_UNLOCK_KEYSTORE_KEY)) {
|
||||
// Set the alias of the entry in Android KeyStore where the key will appear
|
||||
@@ -151,7 +110,7 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity)
|
||||
}
|
||||
// To store in the security chip
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||
&& retrieveContext().packageManager.hasSystemFeature(
|
||||
&& appContext.packageManager.hasSystemFeature(
|
||||
PackageManager.FEATURE_STRONGBOX_KEYSTORE)) {
|
||||
setIsStrongBoxBacked(true)
|
||||
}
|
||||
@@ -161,98 +120,111 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to create a key in keystore", e)
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
throw e
|
||||
}
|
||||
|
||||
return keyStore.getKey(ADVANCED_UNLOCK_KEYSTORE_KEY, null) as SecretKey?
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to retrieve the key in keystore", e)
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
throw e
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Synchronized fun initEncryptData(actionIfCypherInit: (cryptoPrompt: AdvancedUnlockCryptoPrompt) -> Unit,) {
|
||||
@Synchronized fun initEncryptData(
|
||||
actionIfCypherInit: (cryptoPrompt: DeviceUnlockCryptoPrompt) -> Unit
|
||||
) {
|
||||
initEncryptData(actionIfCypherInit, true)
|
||||
}
|
||||
|
||||
@Synchronized private fun initEncryptData(actionIfCypherInit: (cryptoPrompt: AdvancedUnlockCryptoPrompt) -> Unit,
|
||||
firstLaunch: Boolean) {
|
||||
if (!isKeyManagerInitialized) {
|
||||
return
|
||||
}
|
||||
@Synchronized private fun initEncryptData(
|
||||
actionIfCypherInit: (cryptoPrompt: DeviceUnlockCryptoPrompt) -> Unit,
|
||||
firstLaunch: Boolean
|
||||
) {
|
||||
try {
|
||||
getSecretKey()?.let { secretKey ->
|
||||
cipher?.let { cipher ->
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
|
||||
|
||||
actionIfCypherInit.invoke(
|
||||
AdvancedUnlockCryptoPrompt(
|
||||
cipher,
|
||||
R.string.advanced_unlock_prompt_store_credential_title,
|
||||
R.string.advanced_unlock_prompt_store_credential_message,
|
||||
isDeviceCredentialOperation(), isBiometricOperation())
|
||||
DeviceUnlockCryptoPrompt(
|
||||
type = DeviceUnlockCryptoPromptType.CREDENTIAL_ENCRYPTION,
|
||||
cipher = cipher,
|
||||
titleId = R.string.advanced_unlock_prompt_store_credential_title,
|
||||
descriptionId = R.string.advanced_unlock_prompt_store_credential_message,
|
||||
isDeviceCredentialOperation = isDeviceCredentialOperation(
|
||||
deviceCredentialUnlockEnable
|
||||
),
|
||||
isBiometricOperation = isBiometricOperation(
|
||||
biometricUnlockEnable, deviceCredentialUnlockEnable
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (unrecoverableKeyException: UnrecoverableKeyException) {
|
||||
Log.e(TAG, "Unable to initialize encrypt data", unrecoverableKeyException)
|
||||
advancedUnlockCallback?.onUnrecoverableKeyException(unrecoverableKeyException)
|
||||
throw unrecoverableKeyException
|
||||
} catch (invalidKeyException: KeyPermanentlyInvalidatedException) {
|
||||
Log.e(TAG, "Unable to initialize encrypt data", invalidKeyException)
|
||||
if (firstLaunch) {
|
||||
deleteAllEntryKeysInKeystoreForBiometric(retrieveContext())
|
||||
deleteAllEntryKeysInKeystoreForBiometric(appContext)
|
||||
initEncryptData(actionIfCypherInit, false)
|
||||
} else {
|
||||
advancedUnlockCallback?.onInvalidKeyException(invalidKeyException)
|
||||
throw invalidKeyException
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to initialize encrypt data", e)
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized fun encryptData(value: ByteArray) {
|
||||
if (!isKeyManagerInitialized) {
|
||||
return
|
||||
}
|
||||
@Synchronized fun encryptData(
|
||||
value: ByteArray,
|
||||
handleEncryptedResult: (encryptedValue: ByteArray, ivSpec: ByteArray) -> Unit
|
||||
) {
|
||||
try {
|
||||
val encrypted = cipher?.doFinal(value) ?: byteArrayOf()
|
||||
// passes updated iv spec on to callback so this can be stored for decryption
|
||||
cipher?.parameters?.getParameterSpec(IvParameterSpec::class.java)?.let{ spec ->
|
||||
advancedUnlockCallback?.handleEncryptedResult(encrypted, spec.iv)
|
||||
handleEncryptedResult.invoke(encrypted, spec.iv)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to encrypt data", e)
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized fun initDecryptData(ivSpecValue: ByteArray,
|
||||
actionIfCypherInit: (cryptoPrompt: AdvancedUnlockCryptoPrompt) -> Unit) {
|
||||
@Synchronized fun initDecryptData(
|
||||
ivSpecValue: ByteArray,
|
||||
actionIfCypherInit: (cryptoPrompt: DeviceUnlockCryptoPrompt) -> Unit
|
||||
) {
|
||||
initDecryptData(ivSpecValue, actionIfCypherInit, true)
|
||||
}
|
||||
|
||||
@Synchronized private fun initDecryptData(ivSpecValue: ByteArray,
|
||||
actionIfCypherInit: (cryptoPrompt: AdvancedUnlockCryptoPrompt) -> Unit,
|
||||
firstLaunch: Boolean = true) {
|
||||
if (!isKeyManagerInitialized) {
|
||||
return
|
||||
}
|
||||
@Synchronized private fun initDecryptData(
|
||||
ivSpecValue: ByteArray,
|
||||
actionIfCypherInit: (cryptoPrompt: DeviceUnlockCryptoPrompt) -> Unit,
|
||||
firstLaunch: Boolean
|
||||
) {
|
||||
try {
|
||||
// important to restore spec here that was used for decryption
|
||||
val spec = IvParameterSpec(ivSpecValue)
|
||||
getSecretKey()?.let { secretKey ->
|
||||
cipher?.let { cipher ->
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
|
||||
|
||||
actionIfCypherInit.invoke(
|
||||
AdvancedUnlockCryptoPrompt(
|
||||
cipher,
|
||||
R.string.advanced_unlock_prompt_extract_credential_title,
|
||||
null,
|
||||
isDeviceCredentialOperation(), isBiometricOperation())
|
||||
DeviceUnlockCryptoPrompt(
|
||||
type = DeviceUnlockCryptoPromptType.CREDENTIAL_DECRYPTION,
|
||||
cipher = cipher,
|
||||
titleId = R.string.advanced_unlock_prompt_extract_credential_title,
|
||||
descriptionId = null,
|
||||
isDeviceCredentialOperation = isDeviceCredentialOperation(
|
||||
deviceCredentialUnlockEnable
|
||||
),
|
||||
isBiometricOperation = isBiometricOperation(
|
||||
biometricUnlockEnable, deviceCredentialUnlockEnable
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -262,37 +234,34 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity)
|
||||
deleteKeystoreKey()
|
||||
initDecryptData(ivSpecValue, actionIfCypherInit, firstLaunch)
|
||||
} else {
|
||||
advancedUnlockCallback?.onUnrecoverableKeyException(unrecoverableKeyException)
|
||||
throw unrecoverableKeyException
|
||||
}
|
||||
} catch (invalidKeyException: KeyPermanentlyInvalidatedException) {
|
||||
Log.e(TAG, "Unable to initialize decrypt data", invalidKeyException)
|
||||
if (firstLaunch) {
|
||||
deleteAllEntryKeysInKeystoreForBiometric(retrieveContext())
|
||||
deleteAllEntryKeysInKeystoreForBiometric(appContext)
|
||||
initDecryptData(ivSpecValue, actionIfCypherInit, firstLaunch)
|
||||
} else {
|
||||
advancedUnlockCallback?.onInvalidKeyException(invalidKeyException)
|
||||
throw invalidKeyException
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to initialize decrypt data", e)
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized fun decryptData(encryptedValue: ByteArray) {
|
||||
if (!isKeyManagerInitialized) {
|
||||
return
|
||||
}
|
||||
@Synchronized fun decryptData(
|
||||
encryptedValue: ByteArray,
|
||||
handleDecryptedResult: (decryptedValue: ByteArray) -> Unit
|
||||
) {
|
||||
try {
|
||||
// actual decryption here
|
||||
cipher?.doFinal(encryptedValue)?.let { decrypted ->
|
||||
advancedUnlockCallback?.handleDecryptedResult(decrypted)
|
||||
handleDecryptedResult.invoke(decrypted)
|
||||
}
|
||||
} catch (badPaddingException: BadPaddingException) {
|
||||
Log.e(TAG, "Unable to decrypt data", badPaddingException)
|
||||
advancedUnlockCallback?.onInvalidKeyException(badPaddingException)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to decrypt data", e)
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,71 +271,13 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity)
|
||||
keyStore?.deleteEntry(ADVANCED_UNLOCK_KEYSTORE_KEY)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to delete entry key in keystore", e)
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized fun openAdvancedUnlockPrompt(cryptoPrompt: AdvancedUnlockCryptoPrompt,
|
||||
deviceCredentialResultLauncher: ActivityResultLauncher<Intent>
|
||||
) {
|
||||
// Init advanced unlock prompt
|
||||
if (biometricPrompt == null) {
|
||||
biometricPrompt = BiometricPrompt(retrieveContext(),
|
||||
Executors.newSingleThreadExecutor(),
|
||||
authenticationCallback)
|
||||
}
|
||||
|
||||
val promptTitle = retrieveContext().getString(cryptoPrompt.promptTitleId)
|
||||
val promptDescription = cryptoPrompt.promptDescriptionId?.let { descriptionId ->
|
||||
retrieveContext().getString(descriptionId)
|
||||
} ?: ""
|
||||
|
||||
if (cryptoPrompt.isBiometricOperation) {
|
||||
val promptInfoExtractCredential = BiometricPrompt.PromptInfo.Builder().apply {
|
||||
setTitle(promptTitle)
|
||||
if (promptDescription.isNotEmpty())
|
||||
setDescription(promptDescription)
|
||||
setConfirmationRequired(false)
|
||||
if (isDeviceCredentialBiometricOperation()) {
|
||||
setAllowedAuthenticators(DEVICE_CREDENTIAL)
|
||||
} else {
|
||||
setNegativeButtonText(retrieveContext().getString(android.R.string.cancel))
|
||||
}
|
||||
}.build()
|
||||
biometricPrompt?.authenticate(
|
||||
promptInfoExtractCredential,
|
||||
BiometricPrompt.CryptoObject(cryptoPrompt.cipher))
|
||||
}
|
||||
else if (cryptoPrompt.isDeviceCredentialOperation) {
|
||||
val keyGuardManager = ContextCompat.getSystemService(retrieveContext(), KeyguardManager::class.java)
|
||||
@Suppress("DEPRECATION")
|
||||
deviceCredentialResultLauncher.launch(
|
||||
keyGuardManager?.createConfirmDeviceCredentialIntent(promptTitle, promptDescription)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized fun closeBiometricPrompt() {
|
||||
biometricPrompt?.cancelAuthentication()
|
||||
}
|
||||
|
||||
interface AdvancedUnlockErrorCallback {
|
||||
fun onUnrecoverableKeyException(e: Exception)
|
||||
fun onInvalidKeyException(e: Exception)
|
||||
fun onGenericException(e: Exception)
|
||||
}
|
||||
|
||||
interface AdvancedUnlockCallback : AdvancedUnlockErrorCallback {
|
||||
fun onAuthenticationSucceeded()
|
||||
fun onAuthenticationFailed()
|
||||
fun onAuthenticationError(errorCode: Int, errString: CharSequence)
|
||||
fun handleEncryptedResult(encryptedValue: ByteArray, ivSpec: ByteArray)
|
||||
fun handleDecryptedResult(decryptedValue: ByteArray)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = AdvancedUnlockManager::class.java.name
|
||||
private val TAG = DeviceUnlockManager::class.java.name
|
||||
|
||||
private const val ADVANCED_UNLOCK_KEYSTORE = "AndroidKeyStore"
|
||||
private const val ADVANCED_UNLOCK_KEYSTORE_KEY = "com.kunzisoft.keepass.biometric.key"
|
||||
@@ -445,62 +356,65 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity)
|
||||
/**
|
||||
* Remove entry key in keystore
|
||||
*/
|
||||
fun deleteEntryKeyInKeystoreForBiometric(fragmentActivity: FragmentActivity,
|
||||
advancedCallback: AdvancedUnlockErrorCallback) {
|
||||
AdvancedUnlockManager{ fragmentActivity }.apply {
|
||||
advancedUnlockCallback = object : AdvancedUnlockCallback {
|
||||
override fun onAuthenticationSucceeded() {}
|
||||
|
||||
override fun onAuthenticationFailed() {}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {}
|
||||
|
||||
override fun handleEncryptedResult(encryptedValue: ByteArray, ivSpec: ByteArray) {}
|
||||
|
||||
override fun handleDecryptedResult(decryptedValue: ByteArray) {}
|
||||
|
||||
override fun onUnrecoverableKeyException(e: Exception) {
|
||||
advancedCallback.onUnrecoverableKeyException(e)
|
||||
}
|
||||
|
||||
override fun onInvalidKeyException(e: Exception) {
|
||||
advancedCallback.onInvalidKeyException(e)
|
||||
}
|
||||
|
||||
override fun onGenericException(e: Exception) {
|
||||
advancedCallback.onGenericException(e)
|
||||
}
|
||||
}
|
||||
fun deleteEntryKeyInKeystoreForBiometric(
|
||||
appContext: Context
|
||||
) {
|
||||
DeviceUnlockManager(appContext).apply {
|
||||
deleteKeystoreKey()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAllEntryKeysInKeystoreForBiometric(activity: FragmentActivity) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
deleteEntryKeyInKeystoreForBiometric(
|
||||
activity,
|
||||
object : AdvancedUnlockErrorCallback {
|
||||
fun showException(e: Exception) {
|
||||
Toast.makeText(activity,
|
||||
activity.getString(R.string.advanced_unlock_scanning_error, e.localizedMessage),
|
||||
fun deleteAllEntryKeysInKeystoreForBiometric(appContext: Context) {
|
||||
try {
|
||||
deleteEntryKeyInKeystoreForBiometric(appContext)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(appContext,
|
||||
appContext.getString(
|
||||
R.string.advanced_unlock_scanning_error,
|
||||
e.localizedMessage
|
||||
),
|
||||
Toast.LENGTH_SHORT).show()
|
||||
} finally {
|
||||
CipherDatabaseAction.getInstance(appContext).deleteAll()
|
||||
}
|
||||
|
||||
override fun onUnrecoverableKeyException(e: Exception) {
|
||||
showException(e)
|
||||
}
|
||||
|
||||
override fun onInvalidKeyException(e: Exception) {
|
||||
showException(e)
|
||||
}
|
||||
|
||||
override fun onGenericException(e: Exception) {
|
||||
showException(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
CipherDatabaseAction.getInstance(activity.applicationContext).deleteAll()
|
||||
}
|
||||
}
|
||||
|
||||
fun isBiometricUnlockEnable(appContext: Context) =
|
||||
PreferencesUtil.isBiometricUnlockEnable(appContext)
|
||||
|
||||
fun isDeviceCredentialUnlockEnable(appContext: Context) =
|
||||
PreferencesUtil.isDeviceCredentialUnlockEnable(appContext)
|
||||
|
||||
private fun isBiometricOperation(
|
||||
biometricUnlockEnable: Boolean,
|
||||
deviceCredentialUnlockEnable: Boolean
|
||||
): Boolean {
|
||||
return biometricUnlockEnable
|
||||
|| isDeviceCredentialBiometricOperation(deviceCredentialUnlockEnable)
|
||||
}
|
||||
|
||||
// Since Android 30, device credential is also a biometric operation
|
||||
private fun isDeviceCredentialOperation(
|
||||
deviceCredentialUnlockEnable: Boolean
|
||||
): Boolean {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.R
|
||||
&& deviceCredentialUnlockEnable
|
||||
}
|
||||
|
||||
private fun isDeviceCredentialBiometricOperation(
|
||||
deviceCredentialUnlockEnable: Boolean
|
||||
): Boolean {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
&& deviceCredentialUnlockEnable
|
||||
}
|
||||
|
||||
fun isDeviceCredentialBiometricOperation(context: Context?): Boolean {
|
||||
if (context == null) {
|
||||
return false
|
||||
}
|
||||
return isDeviceCredentialBiometricOperation(
|
||||
isDeviceCredentialUnlockEnable(context)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.kunzisoft.keepass.biometric
|
||||
|
||||
enum class DeviceUnlockMode {
|
||||
BIOMETRIC_UNAVAILABLE,
|
||||
BIOMETRIC_SECURITY_UPDATE_REQUIRED,
|
||||
DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED,
|
||||
KEY_MANAGER_UNAVAILABLE,
|
||||
WAIT_CREDENTIAL,
|
||||
STORE_CREDENTIAL,
|
||||
EXTRACT_CREDENTIAL
|
||||
}
|
||||
@@ -41,7 +41,7 @@ import com.kunzisoft.keepass.activities.dialogs.ProFeatureDialogFragment
|
||||
import com.kunzisoft.keepass.activities.dialogs.UnavailableFeatureDialogFragment
|
||||
import com.kunzisoft.keepass.activities.stylish.Stylish
|
||||
import com.kunzisoft.keepass.app.database.FileDatabaseHistoryAction
|
||||
import com.kunzisoft.keepass.biometric.AdvancedUnlockManager
|
||||
import com.kunzisoft.keepass.biometric.DeviceUnlockManager
|
||||
import com.kunzisoft.keepass.education.Education
|
||||
import com.kunzisoft.keepass.icons.IconPackChooser
|
||||
import com.kunzisoft.keepass.services.ClipboardEntryNotificationService
|
||||
@@ -251,7 +251,7 @@ class NestedAppSettingsFragment : NestedSettingsFragment() {
|
||||
val tempAdvancedUnlockPreference: TwoStatePreference? = findPreference(getString(R.string.temp_advanced_unlock_enable_key))
|
||||
|
||||
val biometricUnlockSupported = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
AdvancedUnlockManager.biometricUnlockSupported(activity)
|
||||
DeviceUnlockManager.biometricUnlockSupported(activity)
|
||||
} else false
|
||||
biometricUnlockEnablePreference?.apply {
|
||||
// False if under Marshmallow
|
||||
@@ -296,7 +296,7 @@ class NestedAppSettingsFragment : NestedSettingsFragment() {
|
||||
}
|
||||
|
||||
val deviceCredentialUnlockSupported = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
AdvancedUnlockManager.deviceCredentialUnlockSupported(activity)
|
||||
DeviceUnlockManager.deviceCredentialUnlockSupported(activity)
|
||||
} else false
|
||||
deviceCredentialUnlockEnablePreference?.apply {
|
||||
// Biometric unlock already checked
|
||||
@@ -395,7 +395,7 @@ class NestedAppSettingsFragment : NestedSettingsFragment() {
|
||||
validate?.invoke()
|
||||
warningAlertDialog?.setOnDismissListener(null)
|
||||
if (deleteKeys && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
AdvancedUnlockManager.deleteAllEntryKeysInKeystoreForBiometric(activity)
|
||||
DeviceUnlockManager.deleteAllEntryKeysInKeystoreForBiometric(activity)
|
||||
}
|
||||
}
|
||||
.setNegativeButton(resources.getString(android.R.string.cancel)
|
||||
|
||||
@@ -29,7 +29,7 @@ import androidx.preference.PreferenceManager
|
||||
import com.kunzisoft.keepass.BuildConfig
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.stylish.Stylish
|
||||
import com.kunzisoft.keepass.biometric.AdvancedUnlockManager
|
||||
import com.kunzisoft.keepass.biometric.DeviceUnlockManager
|
||||
import com.kunzisoft.keepass.database.element.SortNodeEnum
|
||||
import com.kunzisoft.keepass.database.search.SearchParameters
|
||||
import com.kunzisoft.keepass.education.Education
|
||||
@@ -512,7 +512,7 @@ object PreferencesUtil {
|
||||
return prefs.getBoolean(context.getString(R.string.biometric_unlock_enable_key),
|
||||
context.resources.getBoolean(R.bool.biometric_unlock_enable_default))
|
||||
&& (if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
|
||||
AdvancedUnlockManager.biometricUnlockSupported(context)
|
||||
DeviceUnlockManager.biometricUnlockSupported(context)
|
||||
} else {
|
||||
false
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ import androidx.annotation.StringRes
|
||||
import com.kunzisoft.keepass.R
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
class AdvancedUnlockInfoView @JvmOverloads constructor(context: Context,
|
||||
class DeviceUnlockView @JvmOverloads constructor(context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyle: Int = 0)
|
||||
: LinearLayout(context, attrs, defStyle) {
|
||||
@@ -45,7 +45,7 @@ class AdvancedUnlockInfoView @JvmOverloads constructor(context: Context,
|
||||
biometricButtonView = findViewById(R.id.biometric_button)
|
||||
}
|
||||
|
||||
fun setIconViewClickListener(listener: OnClickListener?) {
|
||||
fun setDeviceUnlockButtonViewClickListener(listener: OnClickListener?) {
|
||||
biometricButtonView?.setOnClickListener(listener)
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@ class AdvancedUnlockInfoView @JvmOverloads constructor(context: Context,
|
||||
title = context.getString(textId)
|
||||
}
|
||||
|
||||
fun setMessage(text: CharSequence) {
|
||||
if (text.isNotEmpty())
|
||||
fun setMessage(text: CharSequence?) {
|
||||
if (!text.isNullOrEmpty())
|
||||
Toast.makeText(context, text, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
package com.kunzisoft.keepass.viewmodels
|
||||
|
||||
import android.net.Uri
|
||||
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(DeviceUnlockState())
|
||||
val uiState: StateFlow<DeviceUnlockState> = _uiState
|
||||
|
||||
fun checkUnlockAvailability(conditionToStoreCredentialVerified: Boolean? = null) {
|
||||
_uiState.update { currentState ->
|
||||
currentState.copy(
|
||||
onUnlockAvailabilityCheckRequested = true,
|
||||
isConditionToStoreCredentialVerified = conditionToStoreCredentialVerified
|
||||
?: _uiState.value.isConditionToStoreCredentialVerified
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeCheckUnlockAvailability() {
|
||||
_uiState.update { currentState ->
|
||||
currentState.copy(
|
||||
onUnlockAvailabilityCheckRequested = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun databaseFileLoaded(databaseUri: Uri?) {
|
||||
_uiState.update { currentState ->
|
||||
currentState.copy(
|
||||
databaseFileLoaded = databaseUri
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeDatabaseFileLoaded() {
|
||||
_uiState.update { currentState ->
|
||||
currentState.copy(
|
||||
databaseFileLoaded = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class DeviceUnlockState(
|
||||
val initAdvancedUnlockMode: Boolean = false,
|
||||
val databaseFileLoaded: 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 DeviceUnlockState
|
||||
|
||||
if (initAdvancedUnlockMode != other.initAdvancedUnlockMode) return false
|
||||
if (isCredentialRequired != other.isCredentialRequired) return false
|
||||
if (isConditionToStoreCredentialVerified != other.isConditionToStoreCredentialVerified) return false
|
||||
if (onUnlockAvailabilityCheckRequested != other.onUnlockAvailabilityCheckRequested) return false
|
||||
if (databaseFileLoaded != other.databaseFileLoaded) 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 = initAdvancedUnlockMode.hashCode()
|
||||
result = 31 * result + isCredentialRequired.hashCode()
|
||||
result = 31 * result + isConditionToStoreCredentialVerified.hashCode()
|
||||
result = 31 * result + onUnlockAvailabilityCheckRequested.hashCode()
|
||||
result = 31 * result + (databaseFileLoaded?.hashCode() ?: 0)
|
||||
result = 31 * result + (credential?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (cipherEncryptDatabase?.hashCode() ?: 0)
|
||||
result = 31 * result + (cipherDecryptDatabase?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package com.kunzisoft.keepass.viewmodels
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.kunzisoft.keepass.app.database.CipherDatabaseAction
|
||||
import com.kunzisoft.keepass.biometric.DeviceUnlockCryptoPrompt
|
||||
import com.kunzisoft.keepass.biometric.DeviceUnlockManager
|
||||
import com.kunzisoft.keepass.biometric.DeviceUnlockMode
|
||||
import com.kunzisoft.keepass.database.exception.UnknownDatabaseLocationException
|
||||
import com.kunzisoft.keepass.model.CipherDecryptDatabase
|
||||
import com.kunzisoft.keepass.model.CipherEncryptDatabase
|
||||
import com.kunzisoft.keepass.model.CredentialStorage
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class DeviceUnlockViewModel(application: Application): AndroidViewModel(application) {
|
||||
|
||||
var allowAutoOpenBiometricPrompt : Boolean = true
|
||||
var deviceCredentialAuthSucceeded: Boolean? = null
|
||||
|
||||
private var cipherDatabaseListener: CipherDatabaseAction.CipherDatabaseListener? = null
|
||||
|
||||
private var isConditionToStoreCredentialVerified: Boolean = false
|
||||
|
||||
private var deviceUnlockManager: DeviceUnlockManager? = null
|
||||
private var databaseUri: Uri? = null
|
||||
|
||||
// TODO Retrieve credential storage from app database
|
||||
var credentialDatabaseStorage: CredentialStorage = CredentialStorage.DEFAULT
|
||||
|
||||
val cipherDatabaseAction = CipherDatabaseAction.getInstance(getApplication())
|
||||
|
||||
private val _uiState = MutableStateFlow(DeviceUnlockState())
|
||||
val uiState: StateFlow<DeviceUnlockState> = _uiState
|
||||
|
||||
fun checkConditionToStoreCredential(condition: Boolean, databaseFileUri: Uri?) {
|
||||
isConditionToStoreCredentialVerified = condition
|
||||
checkUnlockAvailability(databaseFileUri)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check unlock availability and change the current mode depending of device's state
|
||||
*/
|
||||
fun checkUnlockAvailability(databaseFileUri: Uri?) {
|
||||
databaseUri = databaseFileUri
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
cipherDatabaseAction.containsCipherDatabase(databaseFileUri) { containsCipherDatabase ->
|
||||
if (PreferencesUtil.isBiometricUnlockEnable(getApplication())) {
|
||||
// biometric not supported (by API level or hardware) so keep option hidden
|
||||
// or manually disable
|
||||
val biometricCanAuthenticate = DeviceUnlockManager.canAuthenticate(getApplication())
|
||||
if (!PreferencesUtil.isAdvancedUnlockEnable(getApplication())
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE) {
|
||||
changeMode(DeviceUnlockMode.BIOMETRIC_UNAVAILABLE)
|
||||
} else if (biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED) {
|
||||
changeMode(DeviceUnlockMode.BIOMETRIC_SECURITY_UPDATE_REQUIRED)
|
||||
} else {
|
||||
// biometric is available but not configured, show icon but in disabled state with some information
|
||||
if (biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED) {
|
||||
changeMode(DeviceUnlockMode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED)
|
||||
} else {
|
||||
selectMode(containsCipherDatabase)
|
||||
}
|
||||
}
|
||||
} else if (PreferencesUtil.isDeviceCredentialUnlockEnable(getApplication())) {
|
||||
if (DeviceUnlockManager.isDeviceSecure(getApplication())) {
|
||||
selectMode(containsCipherDatabase)
|
||||
} else {
|
||||
changeMode(DeviceUnlockMode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isModeChanging(newMode: DeviceUnlockMode): Boolean {
|
||||
return _uiState.value.deviceUnlockMode != newMode
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun selectMode(containsCipherDatabase: Boolean) {
|
||||
try {
|
||||
if (isConditionToStoreCredentialVerified) {
|
||||
if (deviceUnlockManager == null
|
||||
|| isModeChanging(DeviceUnlockMode.STORE_CREDENTIAL)) {
|
||||
deviceUnlockManager = DeviceUnlockManager(getApplication())
|
||||
}
|
||||
// listen for encryption
|
||||
changeMode(DeviceUnlockMode.STORE_CREDENTIAL)
|
||||
initEncryptData()
|
||||
} else if (containsCipherDatabase) {
|
||||
if (deviceUnlockManager == null
|
||||
|| isModeChanging(DeviceUnlockMode.EXTRACT_CREDENTIAL)) {
|
||||
deviceUnlockManager = DeviceUnlockManager(getApplication())
|
||||
}
|
||||
// biometric available but no stored password found yet for this DB
|
||||
// listen for decryption
|
||||
changeMode(DeviceUnlockMode.EXTRACT_CREDENTIAL)
|
||||
initDecryptData()
|
||||
} else {
|
||||
// wait for typing
|
||||
changeMode(DeviceUnlockMode.WAIT_CREDENTIAL)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
changeMode(DeviceUnlockMode.KEY_MANAGER_UNAVAILABLE)
|
||||
setException(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun connect(databaseUri: Uri) {
|
||||
this.databaseUri = databaseUri
|
||||
cipherDatabaseListener = object: CipherDatabaseAction.CipherDatabaseListener {
|
||||
override fun onCipherDatabaseCleared() {
|
||||
closeBiometricPrompt()
|
||||
checkUnlockAvailability(databaseUri)
|
||||
}
|
||||
}
|
||||
cipherDatabaseAction.apply {
|
||||
reloadPreferences()
|
||||
cipherDatabaseListener?.let {
|
||||
registerDatabaseListener(it)
|
||||
}
|
||||
}
|
||||
checkUnlockAvailability(databaseUri)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
this.databaseUri = null
|
||||
closeBiometricPrompt()
|
||||
cipherDatabaseListener?.let {
|
||||
cipherDatabaseAction.unregisterDatabaseListener(it)
|
||||
}
|
||||
reset()
|
||||
}
|
||||
|
||||
fun databaseFileLoaded(databaseUri: Uri?) {
|
||||
// To get device credential unlock result, only if same database uri
|
||||
if (databaseUri != null
|
||||
&& PreferencesUtil.isAdvancedUnlockEnable(getApplication())) {
|
||||
deviceCredentialAuthSucceeded?.let { authSucceeded ->
|
||||
if (databaseUri == this.databaseUri) {
|
||||
if (authSucceeded) {
|
||||
retrieveCredentialForEncryption()
|
||||
}
|
||||
} else {
|
||||
disconnect()
|
||||
}
|
||||
} ?: run {
|
||||
if (databaseUri != this.databaseUri) {
|
||||
connect(databaseUri)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
disconnect()
|
||||
}
|
||||
deviceCredentialAuthSucceeded = null
|
||||
}
|
||||
|
||||
fun retrieveCredentialForEncryption() {
|
||||
_uiState.update { currentState ->
|
||||
currentState.copy(
|
||||
isCredentialRequired = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun encryptCredential(
|
||||
credential: ByteArray
|
||||
) {
|
||||
try {
|
||||
deviceUnlockManager?.encryptData(
|
||||
value = credential,
|
||||
handleEncryptedResult = { encryptedValue, ivSpec ->
|
||||
databaseUri?.let { databaseUri ->
|
||||
onCredentialEncrypted(
|
||||
CipherEncryptDatabase().apply {
|
||||
this.databaseUri = databaseUri
|
||||
this.credentialStorage = credentialDatabaseStorage
|
||||
this.encryptedValue = encryptedValue
|
||||
this.specParameters = ivSpec
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
setException(e)
|
||||
}
|
||||
_uiState.update { currentState ->
|
||||
currentState.copy(
|
||||
isCredentialRequired = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun decryptCredential() {
|
||||
// retrieve the encrypted value from preferences
|
||||
databaseUri?.let { databaseUri ->
|
||||
cipherDatabaseAction.getCipherDatabase(databaseUri) { cipherDatabase ->
|
||||
cipherDatabase?.encryptedValue?.let { encryptedCredential ->
|
||||
try {
|
||||
deviceUnlockManager?.decryptData(
|
||||
encryptedValue = encryptedCredential,
|
||||
handleDecryptedResult = { decryptedValue ->
|
||||
// Load database directly with password retrieve
|
||||
onCredentialDecrypted(
|
||||
CipherDecryptDatabase().apply {
|
||||
this.databaseUri = databaseUri
|
||||
this.credentialStorage = credentialDatabaseStorage
|
||||
this.decryptedValue = decryptedValue
|
||||
}
|
||||
)
|
||||
cipherDatabaseAction.resetCipherParameters(databaseUri)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
setException(e)
|
||||
}
|
||||
} ?: deleteEncryptedDatabaseKey()
|
||||
}
|
||||
} ?: run {
|
||||
setException(UnknownDatabaseLocationException())
|
||||
}
|
||||
}
|
||||
|
||||
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 onPromptRequested(cryptoPrompt: DeviceUnlockCryptoPrompt) {
|
||||
_uiState.update { currentState ->
|
||||
currentState.copy(
|
||||
cryptoPrompt = cryptoPrompt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun promptShown() {
|
||||
_uiState.update { currentState ->
|
||||
currentState.copy(
|
||||
cryptoPrompt = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setException(value: Exception?) {
|
||||
_uiState.update { currentState ->
|
||||
currentState.copy(
|
||||
error = value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initEncryptData() {
|
||||
try {
|
||||
deviceUnlockManager?.initEncryptData { cryptoPrompt ->
|
||||
onPromptRequested(cryptoPrompt)
|
||||
} ?: setException(Exception("AdvancedUnlockManager not initialized"))
|
||||
} catch (e: Exception) {
|
||||
setException(e)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initDecryptData() {
|
||||
databaseUri?.let { databaseUri ->
|
||||
cipherDatabaseAction.getCipherDatabase(databaseUri) { cipherDatabase ->
|
||||
cipherDatabase?.let {
|
||||
try {
|
||||
deviceUnlockManager?.initDecryptData(cipherDatabase.specParameters) { cryptoPrompt ->
|
||||
onPromptRequested(cryptoPrompt)
|
||||
} ?: setException(Exception("AdvancedUnlockManager not initialized"))
|
||||
} catch (e: Exception) {
|
||||
setException(e)
|
||||
}
|
||||
} ?: deleteEncryptedDatabaseKey()
|
||||
}
|
||||
} ?: setException(UnknownDatabaseLocationException())
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun changeMode(deviceUnlockMode: DeviceUnlockMode) {
|
||||
cipherDatabaseAction.containsCipherDatabase(databaseUri) { containsCipher ->
|
||||
_uiState.update { currentState ->
|
||||
currentState.copy(
|
||||
deviceUnlockMode = deviceUnlockMode,
|
||||
allowAdvancedUnlockMenu = containsCipher
|
||||
&& deviceUnlockMode != DeviceUnlockMode.BIOMETRIC_UNAVAILABLE
|
||||
&& deviceUnlockMode != DeviceUnlockMode.KEY_MANAGER_UNAVAILABLE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteEncryptedDatabaseKey() {
|
||||
closeBiometricPrompt()
|
||||
databaseUri?.let { databaseUri ->
|
||||
cipherDatabaseAction.deleteByDatabaseUri(databaseUri) {
|
||||
checkUnlockAvailability(databaseUri)
|
||||
}
|
||||
} ?: checkUnlockAvailability(null)
|
||||
_uiState.update { currentState ->
|
||||
currentState.copy(
|
||||
allowAdvancedUnlockMenu = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeBiometricPrompt() {
|
||||
_uiState.update { currentState ->
|
||||
currentState.copy(
|
||||
cryptoPrompt = null,
|
||||
closePromptRequested = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun biometricPromptClosed() {
|
||||
_uiState.update { currentState ->
|
||||
currentState.copy(
|
||||
closePromptRequested = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
changeMode(DeviceUnlockMode.BIOMETRIC_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
deviceUnlockManager = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class DeviceUnlockState(
|
||||
val deviceUnlockMode: DeviceUnlockMode = DeviceUnlockMode.BIOMETRIC_UNAVAILABLE,
|
||||
val allowAdvancedUnlockMenu: Boolean = false,
|
||||
val isCredentialRequired: Boolean = false,
|
||||
val cipherEncryptDatabase: CipherEncryptDatabase? = null,
|
||||
val cipherDecryptDatabase: CipherDecryptDatabase? = null,
|
||||
val cryptoPrompt: DeviceUnlockCryptoPrompt? = null,
|
||||
val closePromptRequested: Boolean = false,
|
||||
val error: Exception? = null
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.kunzisoft.keepass.view.AdvancedUnlockInfoView
|
||||
<com.kunzisoft.keepass.view.DeviceUnlockView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/advanced_unlock_view"
|
||||
android:layout_width="match_parent"
|
||||
@@ -5,8 +5,8 @@
|
||||
<string name="colorize_password_summary">Colourise password characters by type</string>
|
||||
<string name="content_description_entry_background_color">Entry background colour</string>
|
||||
<string name="invalid_db_sig">Could not recognise the database format.</string>
|
||||
<string name="advanced_unlock_not_recognized">Could not recognise advanced unlock print</string>
|
||||
<string name="advanced_unlock_prompt_not_initialized">Unable to initialise advanced unlock prompt.</string>
|
||||
<string name="advanced_unlock_not_recognized">Could not recognise device unlock print</string>
|
||||
<string name="advanced_unlock_prompt_not_initialized">Unable to initialise device unlock prompt.</string>
|
||||
<string name="download_initialization">Initialising…</string>
|
||||
<string name="download_finalization">Finalising…</string>
|
||||
<string name="download_canceled">Cancelled!</string>
|
||||
|
||||
@@ -1 +1 @@
|
||||
*
|
||||
* Fix UnlockManager #2098 #2101
|
||||
@@ -1 +1 @@
|
||||
*
|
||||
* Correction UnlockManager #2098 #2101
|
||||
Reference in New Issue
Block a user