diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/android/PasskeyGetActivity.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/PasskeyGetActivity.kt index 6cf7b75..8cf4eea 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/android/PasskeyGetActivity.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/android/PasskeyGetActivity.kt @@ -50,6 +50,7 @@ import com.artemchep.keyguard.common.model.getOrNull import com.artemchep.keyguard.common.usecase.AddCipherUsedPasskeyHistory import com.artemchep.keyguard.common.usecase.BiometricStatusUseCase import com.artemchep.keyguard.common.usecase.ConfirmAccessByPasswordUseCase +import com.artemchep.keyguard.common.usecase.GetBiometricRequireConfirmation import com.artemchep.keyguard.common.usecase.GetCiphers import com.artemchep.keyguard.common.usecase.GetVaultSession import com.artemchep.keyguard.common.usecase.WindowCoroutineScope @@ -536,6 +537,7 @@ fun produceUserVerificationState( produceUserVerificationState( onAuthenticated = onAuthenticated, biometricStatusUseCase = instance(), + getBiometricRequireConfirmation = instance(), confirmAccessByPasswordUseCase = instance(), windowCoroutineScope = instance(), ) @@ -545,6 +547,7 @@ fun produceUserVerificationState( fun produceUserVerificationState( onAuthenticated: () -> Unit, biometricStatusUseCase: BiometricStatusUseCase, + getBiometricRequireConfirmation: GetBiometricRequireConfirmation, confirmAccessByPasswordUseCase: ConfirmAccessByPasswordUseCase, windowCoroutineScope: WindowCoroutineScope, ): UserVerificationState = produceScreenState( @@ -567,8 +570,11 @@ fun produceUserVerificationState( .getOrNull() when (biometricStatus) { is BiometricStatus.Available -> { + val requireConfirmation = getBiometricRequireConfirmation() + .first() createPromptOrNull( executor = executor, + requireConfirmation = requireConfirmation, fn = { onAuthenticated() }, @@ -652,11 +658,13 @@ fun produceUserVerificationState( private fun createPromptOrNull( executor: LoadingTask, + requireConfirmation: Boolean, fn: () -> Unit, ): PureBiometricAuthPrompt = run { BiometricAuthPromptSimple( title = TextHolder.Res(Res.strings.elevatedaccess_biometric_auth_confirm_title), text = TextHolder.Res(Res.strings.elevatedaccess_biometric_auth_confirm_text), + requireConfirmation = requireConfirmation, onComplete = { result -> result.fold( ifLeft = { exception -> diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/biometric/BiometricPromptEffect.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/biometric/BiometricPromptEffect.kt index 92ee54b..24f8d3a 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/biometric/BiometricPromptEffect.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/feature/biometric/BiometricPromptEffect.kt @@ -51,6 +51,7 @@ private fun FragmentActivity.launchPrompt( ?.also(::setDescription) } .setNegativeButtonText(getString(android.R.string.cancel)) + .setConfirmationRequired(event.requireConfirmation) .build() val prompt = BiometricPrompt( this, @@ -89,6 +90,7 @@ private fun FragmentActivity.launchPrompt( ?.also(::setDescription) } .setNegativeButtonText(getString(android.R.string.cancel)) + .setConfirmationRequired(event.requireConfirmation) .build() val prompt = BiometricPrompt( this, diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/BiometricAuthPrompt.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/BiometricAuthPrompt.kt index 5222225..d0fad83 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/BiometricAuthPrompt.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/BiometricAuthPrompt.kt @@ -10,6 +10,7 @@ class BiometricAuthPrompt( val title: TextHolder, val text: TextHolder? = null, val cipher: LeCipher, + val requireConfirmation: Boolean, /** * Called when the user either failed the authentication or * successfully passed it. @@ -22,6 +23,7 @@ class BiometricAuthPrompt( class BiometricAuthPromptSimple( val title: TextHolder, val text: TextHolder? = null, + val requireConfirmation: Boolean, /** * Called when the user either failed the authentication or * successfully passed it. diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/VaultState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/VaultState.kt index 83c3d93..db6f307 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/VaultState.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/VaultState.kt @@ -17,6 +17,7 @@ sealed interface VaultState { class WithBiometric( val getCipher: () -> Either, val getCreateIo: (String) -> IO, + val requireConfirmation: Boolean, ) } @@ -32,6 +33,7 @@ sealed interface VaultState { class WithBiometric( val getCipher: () -> Either, val getCreateIo: () -> IO, + val requireConfirmation: Boolean, ) } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/settings/SettingsReadRepository.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/settings/SettingsReadRepository.kt index 85266c5..59d5c2d 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/settings/SettingsReadRepository.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/settings/SettingsReadRepository.kt @@ -35,6 +35,8 @@ interface SettingsReadRepository { fun getBiometricTimeout(): Flow + fun getBiometricRequireConfirmation(): Flow + fun getClipboardClearDelay(): Flow fun getClipboardUpdateDuration(): Flow diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/settings/SettingsReadWriteRepository.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/settings/SettingsReadWriteRepository.kt index 5a18d98..4d2f7c9 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/settings/SettingsReadWriteRepository.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/settings/SettingsReadWriteRepository.kt @@ -57,6 +57,10 @@ interface SettingsReadWriteRepository : SettingsReadRepository { duration: Duration?, ): IO + fun setBiometricRequireConfirmation( + requireConfirmation: Boolean, + ): IO + fun setClipboardClearDelay( duration: Duration?, ): IO diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/settings/impl/SettingsRepositoryImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/settings/impl/SettingsRepositoryImpl.kt index 521db66..367ed75 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/settings/impl/SettingsRepositoryImpl.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/settings/impl/SettingsRepositoryImpl.kt @@ -47,6 +47,7 @@ class SettingsRepositoryImpl( private const val KEY_VAULT_TIMEOUT = "vault_timeout" private const val KEY_VAULT_SCREEN_LOCK = "vault_screen_lock" private const val KEY_BIOMETRIC_TIMEOUT = "biometric_timeout" + private const val KEY_BIOMETRIC_REQUIRE_CONFIRMATION = "biometric_require_confirmation" private const val KEY_CLIPBOARD_CLEAR_DELAY = "clipboard_clear_delay" private const val KEY_CLIPBOARD_UPDATE_DURATION = "clipboard_update_duration" private const val KEY_CONCEAL_FIELDS = "conceal_fields" @@ -107,6 +108,8 @@ class SettingsRepositoryImpl( private val biometricTimeoutPref = store.getLong(KEY_BIOMETRIC_TIMEOUT, NONE_DURATION) + private val biometricRequireConfirmationPref = store.getBoolean(KEY_BIOMETRIC_REQUIRE_CONFIRMATION, true) + private val clipboardClearDelayPref = store.getLong(KEY_CLIPBOARD_CLEAR_DELAY, NONE_DURATION) @@ -277,6 +280,13 @@ class SettingsRepositoryImpl( override fun getBiometricTimeout() = biometricTimeoutPref .asDuration() + override fun setBiometricRequireConfirmation( + requireConfirmation: Boolean, + ) = biometricRequireConfirmationPref + .setAndCommit(requireConfirmation) + + override fun getBiometricRequireConfirmation() = biometricRequireConfirmationPref + override fun setClipboardClearDelay(duration: Duration?) = clipboardClearDelayPref .setAndCommit(duration) diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/GetBiometricRequireConfirmation.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/GetBiometricRequireConfirmation.kt new file mode 100644 index 0000000..126484c --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/GetBiometricRequireConfirmation.kt @@ -0,0 +1,6 @@ +package com.artemchep.keyguard.common.usecase + +import kotlinx.coroutines.flow.Flow +import kotlin.time.Duration + +interface GetBiometricRequireConfirmation : () -> Flow diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/PutBiometricRequireConfirmation.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/PutBiometricRequireConfirmation.kt new file mode 100644 index 0000000..4199dd0 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/PutBiometricRequireConfirmation.kt @@ -0,0 +1,5 @@ +package com.artemchep.keyguard.common.usecase + +import com.artemchep.keyguard.common.io.IO + +interface PutBiometricRequireConfirmation : (Boolean) -> IO diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/GetBiometricRequireConfirmationImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/GetBiometricRequireConfirmationImpl.kt new file mode 100644 index 0000000..a117448 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/GetBiometricRequireConfirmationImpl.kt @@ -0,0 +1,19 @@ +package com.artemchep.keyguard.common.usecase.impl + +import com.artemchep.keyguard.common.service.settings.SettingsReadRepository +import com.artemchep.keyguard.common.usecase.GetBiometricRequireConfirmation +import kotlinx.coroutines.flow.Flow +import org.kodein.di.DirectDI +import org.kodein.di.instance + +class GetBiometricRequireConfirmationImpl( + settingsReadRepository: SettingsReadRepository, +) : GetBiometricRequireConfirmation { + private val sharedFlow = settingsReadRepository.getBiometricRequireConfirmation() + + constructor(directDI: DirectDI) : this( + settingsReadRepository = directDI.instance(), + ) + + override fun invoke(): Flow = sharedFlow +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/PutBiometricRequireConfirmationImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/PutBiometricRequireConfirmationImpl.kt new file mode 100644 index 0000000..0747e51 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/PutBiometricRequireConfirmationImpl.kt @@ -0,0 +1,18 @@ +package com.artemchep.keyguard.common.usecase.impl + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.service.settings.SettingsReadWriteRepository +import com.artemchep.keyguard.common.usecase.PutBiometricRequireConfirmation +import org.kodein.di.DirectDI +import org.kodein.di.instance + +class PutBiometricRequireConfirmationImpl( + private val settingsReadWriteRepository: SettingsReadWriteRepository, +) : PutBiometricRequireConfirmation { + constructor(directDI: DirectDI) : this( + settingsReadWriteRepository = directDI.instance(), + ) + + override fun invoke(requireConfirmation: Boolean): IO = settingsReadWriteRepository + .setBiometricRequireConfirmation(requireConfirmation) +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/UnlockUseCaseImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/UnlockUseCaseImpl.kt index bffdf3e..b7631be 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/UnlockUseCaseImpl.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/UnlockUseCaseImpl.kt @@ -35,6 +35,7 @@ import com.artemchep.keyguard.common.usecase.BiometricKeyEncryptUseCase import com.artemchep.keyguard.common.usecase.BiometricStatusUseCase import com.artemchep.keyguard.common.usecase.DisableBiometric import com.artemchep.keyguard.common.usecase.GetBiometricRemainingDuration +import com.artemchep.keyguard.common.usecase.GetBiometricRequireConfirmation import com.artemchep.keyguard.common.usecase.GetVaultSession import com.artemchep.keyguard.common.usecase.PutVaultSession import com.artemchep.keyguard.common.usecase.UnlockUseCase @@ -48,6 +49,7 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -69,6 +71,7 @@ class UnlockUseCaseImpl( private val disableBiometric: DisableBiometric, private val keyReadWriteRepository: FingerprintReadWriteRepository, private val sessionMetadataReadWriteRepository: SessionMetadataReadWriteRepository, + private val getBiometricRequireConfirmation: GetBiometricRequireConfirmation, private val getBiometricRemainingDuration: GetBiometricRemainingDuration, private val biometricKeyEncryptUseCase: BiometricKeyEncryptUseCase, private val decryptBiometricKeyUseCase: BiometricKeyDecryptUseCase, @@ -127,6 +130,7 @@ class UnlockUseCaseImpl( disableBiometric = directDI.instance(), keyReadWriteRepository = directDI.instance(), sessionMetadataReadWriteRepository = directDI.instance(), + getBiometricRequireConfirmation = directDI.instance(), getBiometricRemainingDuration = directDI.instance(), biometricKeyEncryptUseCase = directDI.instance(), decryptBiometricKeyUseCase = directDI.instance(), @@ -163,7 +167,7 @@ class UnlockUseCaseImpl( } } - private fun createCreateVaultState( + private suspend fun createCreateVaultState( biometric: BiometricStatus, ): VaultState { return VaultState.Create( @@ -194,6 +198,8 @@ class UnlockUseCaseImpl( // the code in the block. This allows us to not // return the cipher from the action. .memoize() + val requireConfirmation = getBiometricRequireConfirmation() + .first() VaultState.Create.WithBiometric( getCipher = getCipherForEncryption, getCreateIo = { password -> @@ -231,6 +237,7 @@ class UnlockUseCaseImpl( } .dispatchOn(Dispatchers.Default) }, + requireConfirmation = requireConfirmation, ) } else { null @@ -238,7 +245,7 @@ class UnlockUseCaseImpl( ) } - private fun createUnlockVaultState( + private suspend fun createUnlockVaultState( tokens: Fingerprint, biometric: BiometricStatus, lockReason: String?, @@ -275,6 +282,8 @@ class UnlockUseCaseImpl( // the code in the block. This allows us to not // return the cipher from the action. .memoize() + val requireConfirmation = getBiometricRequireConfirmation() + .first() VaultState.Unlock.WithBiometric( getCipher = getCipherForDecryption, getCreateIo = { @@ -296,6 +305,7 @@ class UnlockUseCaseImpl( .flatMap(::unlock) .dispatchOn(Dispatchers.Default) }, + requireConfirmation = requireConfirmation, ) } else { null diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/changepassword/ChangePasswordStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/changepassword/ChangePasswordStateProducer.kt index cc57ccc..204e91c 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/changepassword/ChangePasswordStateProducer.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/changepassword/ChangePasswordStateProducer.kt @@ -9,6 +9,7 @@ import com.artemchep.keyguard.common.model.BiometricAuthPrompt import com.artemchep.keyguard.common.model.Loadable import com.artemchep.keyguard.common.model.ToastMessage import com.artemchep.keyguard.common.model.VaultState +import com.artemchep.keyguard.common.usecase.GetBiometricRequireConfirmation import com.artemchep.keyguard.common.usecase.UnlockUseCase import com.artemchep.keyguard.common.usecase.WindowCoroutineScope import com.artemchep.keyguard.common.util.flow.EventFlow @@ -21,6 +22,7 @@ import com.artemchep.keyguard.feature.navigation.state.produceScreenState import com.artemchep.keyguard.res.Res import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -37,6 +39,7 @@ private const val KEY_BIOMETRIC_ENABLED = "biometric.enabled" fun changePasswordState(): Loadable = with(localDI().direct) { changePasswordState( unlockUseCase = instance(), + getBiometricRequireConfirmation = instance(), windowCoroutineScope = instance(), ) } @@ -44,6 +47,7 @@ fun changePasswordState(): Loadable = with(localDI().direct @Composable fun changePasswordState( unlockUseCase: UnlockUseCase, + getBiometricRequireConfirmation: GetBiometricRequireConfirmation, windowCoroutineScope: WindowCoroutineScope, ): Loadable = produceScreenState( key = "change_password", @@ -63,11 +67,16 @@ fun changePasswordState( val fn = unlockUseCase() .map { vaultState -> when (vaultState) { - is VaultState.Main -> ah( - state = vaultState, - biometricPromptSink = biometricPromptSink, - windowCoroutineScope = windowCoroutineScope, - ) + is VaultState.Main -> { + val requireConfirmation = getBiometricRequireConfirmation() + .first() + ah( + state = vaultState, + requireConfirmation = requireConfirmation, + biometricPromptSink = biometricPromptSink, + windowCoroutineScope = windowCoroutineScope, + ) + } else -> null } @@ -154,6 +163,7 @@ fun changePasswordState( private fun RememberStateFlowScope.ah( state: VaultState.Main, + requireConfirmation: Boolean, biometricPromptSink: EventFlow, windowCoroutineScope: WindowCoroutineScope, ) = Fn( @@ -184,6 +194,7 @@ private fun RememberStateFlowScope.ah( val prompt = BiometricAuthPrompt( title = TextHolder.Res(Res.strings.changepassword_biometric_auth_confirm_title), cipher = cipher, + requireConfirmation = requireConfirmation, onComplete = { result -> result.fold( ifLeft = { e -> diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/elevatedaccess/ElevatedAccessStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/elevatedaccess/ElevatedAccessStateProducer.kt index ed36d01..99974bc 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/elevatedaccess/ElevatedAccessStateProducer.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/elevatedaccess/ElevatedAccessStateProducer.kt @@ -14,6 +14,7 @@ import com.artemchep.keyguard.common.model.PureBiometricAuthPrompt import com.artemchep.keyguard.common.model.ToastMessage import com.artemchep.keyguard.common.usecase.BiometricStatusUseCase import com.artemchep.keyguard.common.usecase.ConfirmAccessByPasswordUseCase +import com.artemchep.keyguard.common.usecase.GetBiometricRequireConfirmation import com.artemchep.keyguard.common.usecase.WindowCoroutineScope import com.artemchep.keyguard.common.util.flow.EventFlow import com.artemchep.keyguard.feature.auth.common.TextFieldModel2 @@ -28,6 +29,7 @@ import com.artemchep.keyguard.feature.navigation.state.produceScreenState import com.artemchep.keyguard.res.Res import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.shareIn import org.kodein.di.compose.localDI @@ -43,6 +45,7 @@ fun produceElevatedAccessState( produceElevatedAccessState( transmitter = transmitter, biometricStatusUseCase = instance(), + getBiometricRequireConfirmation = instance(), confirmAccessByPasswordUseCase = instance(), windowCoroutineScope = instance(), ) @@ -52,6 +55,7 @@ fun produceElevatedAccessState( fun produceElevatedAccessState( transmitter: RouteResultTransmitter, biometricStatusUseCase: BiometricStatusUseCase, + getBiometricRequireConfirmation: GetBiometricRequireConfirmation, confirmAccessByPasswordUseCase: ConfirmAccessByPasswordUseCase, windowCoroutineScope: WindowCoroutineScope, ): ElevatedAccessState = produceScreenState( @@ -74,8 +78,11 @@ fun produceElevatedAccessState( .getOrNull() when (biometricStatus) { is BiometricStatus.Available -> { + val requireConfirmation = getBiometricRequireConfirmation() + .first() createPromptOrNull( executor = executor, + requireConfirmation = requireConfirmation, fn = { navigatePopSelf() transmitter(ElevatedAccessResult.Allow) @@ -165,11 +172,13 @@ fun produceElevatedAccessState( private fun createPromptOrNull( executor: LoadingTask, + requireConfirmation: Boolean, fn: () -> Unit, ): PureBiometricAuthPrompt = run { BiometricAuthPromptSimple( title = TextHolder.Res(Res.strings.elevatedaccess_biometric_auth_confirm_title), text = TextHolder.Res(Res.strings.elevatedaccess_biometric_auth_confirm_text), + requireConfirmation = requireConfirmation, onComplete = { result -> result.fold( ifLeft = { exception -> diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/SettingPaneContent.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/SettingPaneContent.kt index b539f74..bc4b174 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/SettingPaneContent.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/SettingPaneContent.kt @@ -34,6 +34,7 @@ import com.artemchep.keyguard.feature.home.settings.component.settingAutofillRes import com.artemchep.keyguard.feature.home.settings.component.settingAutofillSaveRequestProvider import com.artemchep.keyguard.feature.home.settings.component.settingAutofillSaveUriProvider import com.artemchep.keyguard.feature.home.settings.component.settingBiometricsProvider +import com.artemchep.keyguard.feature.home.settings.component.settingBiometricsRequireConfirmationProvider import com.artemchep.keyguard.feature.home.settings.component.settingCheckPwnedPasswordsProvider import com.artemchep.keyguard.feature.home.settings.component.settingCheckPwnedServicesProvider import com.artemchep.keyguard.feature.home.settings.component.settingCheckTwoFAProvider @@ -130,6 +131,7 @@ object Setting { const val PERMISSION_WRITE_EXTERNAL_STORAGE = "permission_write_external_storage" const val PERMISSION_POST_NOTIFICATION = "permission_post_notification" const val BIOMETRIC = "biometric" + const val BIOMETRIC_REQUIRE_CONFIRMATION = "biometric_require_confirmation" const val VAULT_PERSIST = "vault_persist" const val VAULT_LOCK = "vault_lock" const val VAULT_LOCK_AFTER_REBOOT = "vault_lock_after_reboot" @@ -211,6 +213,7 @@ val hub = mapOf SettingComponent>( Setting.PERMISSION_POST_NOTIFICATION to ::settingPermissionPostNotificationsProvider, Setting.PERMISSION_WRITE_EXTERNAL_STORAGE to ::settingPermissionWriteExternalStorageProvider, Setting.BIOMETRIC to ::settingBiometricsProvider, + Setting.BIOMETRIC_REQUIRE_CONFIRMATION to ::settingBiometricsRequireConfirmationProvider, Setting.VAULT_PERSIST to ::settingVaultPersistProvider, Setting.VAULT_LOCK to ::settingVaultLockProvider, Setting.VAULT_LOCK_AFTER_REBOOT to ::settingVaultLockAfterRebootProvider, diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingBiometrics.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingBiometrics.kt index f59f2e1..4dfbf28 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingBiometrics.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingBiometrics.kt @@ -18,6 +18,7 @@ import com.artemchep.keyguard.common.service.vault.FingerprintReadRepository import com.artemchep.keyguard.common.usecase.BiometricStatusUseCase import com.artemchep.keyguard.common.usecase.DisableBiometric import com.artemchep.keyguard.common.usecase.EnableBiometric +import com.artemchep.keyguard.common.usecase.GetBiometricRequireConfirmation import com.artemchep.keyguard.common.usecase.WindowCoroutineScope import com.artemchep.keyguard.common.util.flow.EventFlow import com.artemchep.keyguard.feature.biometric.BiometricPromptEffect @@ -26,6 +27,7 @@ import com.artemchep.keyguard.res.Res import com.artemchep.keyguard.ui.FlatItem import com.artemchep.keyguard.ui.icons.icon import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -38,6 +40,7 @@ fun settingBiometricsProvider( ) = settingBiometricsProvider( fingerprintReadRepository = directDI.instance(), biometricStatusUseCase = directDI.instance(), + getBiometricRequireConfirmation = directDI.instance(), enableBiometric = directDI.instance(), disableBiometric = directDI.instance(), windowCoroutineScope = directDI.instance(), @@ -46,6 +49,7 @@ fun settingBiometricsProvider( fun settingBiometricsProvider( fingerprintReadRepository: FingerprintReadRepository, biometricStatusUseCase: BiometricStatusUseCase, + getBiometricRequireConfirmation: GetBiometricRequireConfirmation, enableBiometric: EnableBiometric, disableBiometric: DisableBiometric, windowCoroutineScope: WindowCoroutineScope, @@ -54,64 +58,82 @@ fun settingBiometricsProvider( .distinctUntilChanged() .flatMapLatest { hasBiometrics -> if (hasBiometrics) { - fingerprintReadRepository.get() - .map { tokens -> - val hasBiometricEnabled = tokens?.biometric != null - - SettingIi( - search = SettingIi.Search( - group = "biometric", - tokens = listOf( - "biometric", - ), - ), - ) { - val promptSink = remember { - EventFlow() - } - - SettingBiometrics( - checked = hasBiometricEnabled, - onCheckedChange = { shouldBeChecked -> - if (shouldBeChecked) { - enableBiometric(null) // use global session - .map { d -> - val cipher = d.getCipher() - val prompt = BiometricAuthPrompt( - title = TextHolder.Res(Res.strings.pref_item_biometric_unlock_confirm_title), - cipher = cipher, - onComplete = { result -> - result.fold( - ifLeft = { exception -> - val message = exception.message - // biometricPromptErrorSink.emit(message) - }, - ifRight = { - d.getCreateIo() - .launchIn(windowCoroutineScope) - }, - ) - }, - ) - promptSink.emit(prompt) - } - .launchIn(windowCoroutineScope) - } else { - disableBiometric() - .launchIn(windowCoroutineScope) - } - }, - ) - - BiometricPromptEffect(promptSink) - } - } + createSettingComponentFlow( + fingerprintReadRepository = fingerprintReadRepository, + getBiometricRequireConfirmation = getBiometricRequireConfirmation, + enableBiometric = enableBiometric, + disableBiometric = disableBiometric, + windowCoroutineScope = windowCoroutineScope, + ) } else { // hide option if the device does not have biometrics flowOf(null) } } +private fun createSettingComponentFlow( + fingerprintReadRepository: FingerprintReadRepository, + getBiometricRequireConfirmation: GetBiometricRequireConfirmation, + enableBiometric: EnableBiometric, + disableBiometric: DisableBiometric, + windowCoroutineScope: WindowCoroutineScope, +) = combine( + getBiometricRequireConfirmation(), + fingerprintReadRepository.get() + .map { tokens -> + tokens?.biometric != null + }, +) { requireConfirmation, biometrics -> + SettingIi( + search = SettingIi.Search( + group = "biometric", + tokens = listOf( + "biometric", + ), + ), + ) { + val promptSink = remember { + EventFlow() + } + + SettingBiometrics( + checked = biometrics, + onCheckedChange = { shouldBeChecked -> + if (shouldBeChecked) { + enableBiometric(null) // use global session + .map { d -> + val cipher = d.getCipher() + val prompt = BiometricAuthPrompt( + title = TextHolder.Res(Res.strings.pref_item_biometric_unlock_confirm_title), + cipher = cipher, + requireConfirmation = requireConfirmation, + onComplete = { result -> + result.fold( + ifLeft = { exception -> + val message = exception.message + // biometricPromptErrorSink.emit(message) + }, + ifRight = { + d.getCreateIo() + .launchIn(windowCoroutineScope) + }, + ) + }, + ) + promptSink.emit(prompt) + } + .launchIn(windowCoroutineScope) + } else { + disableBiometric() + .launchIn(windowCoroutineScope) + } + }, + ) + + BiometricPromptEffect(promptSink) + } +} + @Composable private fun SettingBiometrics( checked: Boolean, diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingBiometricsRequireConfirmation.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingBiometricsRequireConfirmation.kt new file mode 100644 index 0000000..2409b8f --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingBiometricsRequireConfirmation.kt @@ -0,0 +1,123 @@ +package com.artemchep.keyguard.feature.home.settings.component + +import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import arrow.core.partially1 +import com.artemchep.keyguard.common.io.launchIn +import com.artemchep.keyguard.common.model.BiometricStatus +import com.artemchep.keyguard.common.service.vault.FingerprintReadRepository +import com.artemchep.keyguard.common.usecase.BiometricStatusUseCase +import com.artemchep.keyguard.common.usecase.GetBiometricRequireConfirmation +import com.artemchep.keyguard.common.usecase.PutBiometricRequireConfirmation +import com.artemchep.keyguard.common.usecase.WindowCoroutineScope +import com.artemchep.keyguard.res.Res +import com.artemchep.keyguard.ui.FlatItem +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.kodein.di.DirectDI +import org.kodein.di.instance + +fun settingBiometricsRequireConfirmationProvider( + directDI: DirectDI, +) = settingBiometricsRequireConfirmationProvider( + fingerprintReadRepository = directDI.instance(), + biometricStatusUseCase = directDI.instance(), + getBiometricRequireConfirmation = directDI.instance(), + putBiometricRequireConfirmation = directDI.instance(), + windowCoroutineScope = directDI.instance(), +) + +fun settingBiometricsRequireConfirmationProvider( + fingerprintReadRepository: FingerprintReadRepository, + biometricStatusUseCase: BiometricStatusUseCase, + getBiometricRequireConfirmation: GetBiometricRequireConfirmation, + putBiometricRequireConfirmation: PutBiometricRequireConfirmation, + windowCoroutineScope: WindowCoroutineScope, +): SettingComponent = biometricStatusUseCase() + .map { it is BiometricStatus.Available } + .distinctUntilChanged() + .flatMapLatest { hasBiometrics -> + if (hasBiometrics) { + createSettingComponentFlow( + fingerprintReadRepository = fingerprintReadRepository, + getBiometricRequireConfirmation = getBiometricRequireConfirmation, + putBiometricRequireConfirmation = putBiometricRequireConfirmation, + windowCoroutineScope = windowCoroutineScope, + ) + } else { + // hide option if the device does not have biometrics + flowOf(null) + } + } + +private fun createSettingComponentFlow( + fingerprintReadRepository: FingerprintReadRepository, + getBiometricRequireConfirmation: GetBiometricRequireConfirmation, + putBiometricRequireConfirmation: PutBiometricRequireConfirmation, + windowCoroutineScope: WindowCoroutineScope, +) = combine( + getBiometricRequireConfirmation(), + fingerprintReadRepository.get() + .map { tokens -> + tokens?.biometric != null + }, +) { requireConfirmation, biometrics -> + val onCheckedChange = { shouldRequireConfirmation: Boolean -> + putBiometricRequireConfirmation(shouldRequireConfirmation) + .launchIn(windowCoroutineScope) + Unit + } + + SettingIi( + search = SettingIi.Search( + group = "biometric", + tokens = listOf( + "biometric", + "confirmation", + ), + ), + ) { + SettingBiometricsRequireConfirmation( + checked = requireConfirmation, + onCheckedChange = onCheckedChange.takeIf { biometrics }, + ) + } +} + +@Composable +private fun SettingBiometricsRequireConfirmation( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, +) { + FlatItem( + trailing = { + CompositionLocalProvider( + LocalMinimumInteractiveComponentEnforcement provides false, + ) { + Switch( + checked = checked, + enabled = onCheckedChange != null, + onCheckedChange = onCheckedChange, + ) + } + }, + title = { + Text( + text = stringResource(Res.strings.pref_item_biometric_unlock_require_confirmation_title), + ) + }, + text = { + Text( + text = stringResource(Res.strings.pref_item_biometric_unlock_require_confirmation_text), + ) + }, + onClick = onCheckedChange?.partially1(!checked), + ) +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/security/SecuritySettingsScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/security/SecuritySettingsScreen.kt index c179aad..d146dae 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/security/SecuritySettingsScreen.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/security/SecuritySettingsScreen.kt @@ -36,6 +36,7 @@ fun SecuritySettingsScreen() { key = "biometric", list = listOf( SettingPaneItem.Item(Setting.BIOMETRIC), + SettingPaneItem.Item(Setting.BIOMETRIC_REQUIRE_CONFIRMATION), SettingPaneItem.Item(Setting.REQUIRE_MASTER_PASSWORD), ), ), diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/keyguard/setup/SetupStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/keyguard/setup/SetupStateProducer.kt index f03ab4e..4b33a99 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/keyguard/setup/SetupStateProducer.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/keyguard/setup/SetupStateProducer.kt @@ -144,6 +144,7 @@ private fun createPromptOrNull( BiometricAuthPrompt( title = TextHolder.Res(Res.strings.setup_biometric_auth_confirm_title), cipher = cipher, + requireConfirmation = createVaultWithMasterPasswordAndBiometricFn.requireConfirmation, onComplete = { result -> result.fold( ifLeft = { exception -> @@ -185,6 +186,7 @@ private class CreateVaultWithBiometric( */ val getCipher: () -> Either, getCreateIo: (String) -> IO, + val requireConfirmation: Boolean, ) : CreateVaultWithPassword(executor, getCreateIo) { // Create from vault state options constructor( @@ -194,6 +196,7 @@ private class CreateVaultWithBiometric( executor = executor, getCipher = options.getCipher, getCreateIo = options.getCreateIo, + requireConfirmation = options.requireConfirmation, ) } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/keyguard/unlock/UnlockStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/keyguard/unlock/UnlockStateProducer.kt index 1734ba4..dab0566 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/keyguard/unlock/UnlockStateProducer.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/keyguard/unlock/UnlockStateProducer.kt @@ -201,6 +201,7 @@ private fun createPromptOrNull( title = TextHolder.Res(Res.strings.unlock_biometric_auth_confirm_title), text = TextHolder.Res(Res.strings.unlock_biometric_auth_confirm_text), cipher = cipher, + requireConfirmation = fn.requireConfirmation, onComplete = { result -> result.fold( ifLeft = { exception -> @@ -249,6 +250,7 @@ private class UnlockVaultWithBiometric( */ val getCipher: () -> Either, private val getCreateIo: () -> IO, + val requireConfirmation: Boolean, ) : () -> Unit { // Create from vault state options constructor( @@ -258,6 +260,7 @@ private class UnlockVaultWithBiometric( executor = executor, getCipher = options.getCipher, getCreateIo = options.getCreateIo, + requireConfirmation = options.requireConfirmation, ) override fun invoke() { diff --git a/common/src/commonMain/resources/MR/base/strings.xml b/common/src/commonMain/resources/MR/base/strings.xml index ebb882a..3082e0e 100644 --- a/common/src/commonMain/resources/MR/base/strings.xml +++ b/common/src/commonMain/resources/MR/base/strings.xml @@ -991,6 +991,8 @@ Features overview URL overrides Biometric unlock + Require confirmation + Require an additional confirmation button tap after a successful biometric authentication diff --git a/common/src/jvmMain/kotlin/com/artemchep/keyguard/di/GlobalModuleJvm.kt b/common/src/jvmMain/kotlin/com/artemchep/keyguard/di/GlobalModuleJvm.kt index 28641bb..94d4341 100644 --- a/common/src/jvmMain/kotlin/com/artemchep/keyguard/di/GlobalModuleJvm.kt +++ b/common/src/jvmMain/kotlin/com/artemchep/keyguard/di/GlobalModuleJvm.kt @@ -100,6 +100,7 @@ import com.artemchep.keyguard.common.usecase.GetAutofillManualSelection import com.artemchep.keyguard.common.usecase.GetAutofillRespectAutofillOff import com.artemchep.keyguard.common.usecase.GetAutofillSaveRequest import com.artemchep.keyguard.common.usecase.GetAutofillSaveUri +import com.artemchep.keyguard.common.usecase.GetBiometricRequireConfirmation import com.artemchep.keyguard.common.usecase.GetBiometricTimeout import com.artemchep.keyguard.common.usecase.GetBiometricTimeoutVariants import com.artemchep.keyguard.common.usecase.GetCachePremium @@ -162,6 +163,7 @@ import com.artemchep.keyguard.common.usecase.PutAutofillManualSelection import com.artemchep.keyguard.common.usecase.PutAutofillRespectAutofillOff import com.artemchep.keyguard.common.usecase.PutAutofillSaveRequest import com.artemchep.keyguard.common.usecase.PutAutofillSaveUri +import com.artemchep.keyguard.common.usecase.PutBiometricRequireConfirmation import com.artemchep.keyguard.common.usecase.PutBiometricTimeout import com.artemchep.keyguard.common.usecase.PutCachePremium import com.artemchep.keyguard.common.usecase.PutCheckPwnedPasswords @@ -222,6 +224,7 @@ import com.artemchep.keyguard.common.usecase.impl.GetAutofillManualSelectionImpl import com.artemchep.keyguard.common.usecase.impl.GetAutofillRespectAutofillOffImpl import com.artemchep.keyguard.common.usecase.impl.GetAutofillSaveRequestImpl import com.artemchep.keyguard.common.usecase.impl.GetAutofillSaveUriImpl +import com.artemchep.keyguard.common.usecase.impl.GetBiometricRequireConfirmationImpl import com.artemchep.keyguard.common.usecase.impl.GetBiometricTimeoutImpl import com.artemchep.keyguard.common.usecase.impl.GetBiometricTimeoutVariantsImpl import com.artemchep.keyguard.common.usecase.impl.GetCachePremiumImpl @@ -281,6 +284,7 @@ import com.artemchep.keyguard.common.usecase.impl.PutAutofillManualSelectionImpl import com.artemchep.keyguard.common.usecase.impl.PutAutofillRespectAutofillOffImpl import com.artemchep.keyguard.common.usecase.impl.PutAutofillSaveRequestImpl import com.artemchep.keyguard.common.usecase.impl.PutAutofillSaveUriImpl +import com.artemchep.keyguard.common.usecase.impl.PutBiometricRequireConfirmationImpl import com.artemchep.keyguard.common.usecase.impl.PutBiometricTimeoutImpl import com.artemchep.keyguard.common.usecase.impl.PutCachePremiumImpl import com.artemchep.keyguard.common.usecase.impl.PutCheckPwnedPasswordsImpl @@ -477,6 +481,16 @@ fun globalModuleJvm() = DI.Module( directDI = this, ) } + bindSingleton { + GetBiometricRequireConfirmationImpl( + directDI = this, + ) + } + bindSingleton { + PutBiometricRequireConfirmationImpl( + directDI = this, + ) + } bindSingleton { GetCheckPwnedPasswordsImpl( directDI = this,