feat: An option to not require biometric confirmation tap

This commit is contained in:
Artem Chepurnoy 2024-03-29 12:08:26 +02:00
parent 75d7eb96a0
commit 7670a040e3
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
22 changed files with 338 additions and 59 deletions

View File

@ -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 ->

View File

@ -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,

View File

@ -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.

View File

@ -17,6 +17,7 @@ sealed interface VaultState {
class WithBiometric(
val getCipher: () -> Either<Throwable, LeCipher>,
val getCreateIo: (String) -> IO<Unit>,
val requireConfirmation: Boolean,
)
}
@ -32,6 +33,7 @@ sealed interface VaultState {
class WithBiometric(
val getCipher: () -> Either<Throwable, LeCipher>,
val getCreateIo: () -> IO<Unit>,
val requireConfirmation: Boolean,
)
}

View File

@ -35,6 +35,8 @@ interface SettingsReadRepository {
fun getBiometricTimeout(): Flow<Duration?>
fun getBiometricRequireConfirmation(): Flow<Boolean>
fun getClipboardClearDelay(): Flow<Duration?>
fun getClipboardUpdateDuration(): Flow<Duration?>

View File

@ -57,6 +57,10 @@ interface SettingsReadWriteRepository : SettingsReadRepository {
duration: Duration?,
): IO<Unit>
fun setBiometricRequireConfirmation(
requireConfirmation: Boolean,
): IO<Unit>
fun setClipboardClearDelay(
duration: Duration?,
): IO<Unit>

View File

@ -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)

View File

@ -0,0 +1,6 @@
package com.artemchep.keyguard.common.usecase
import kotlinx.coroutines.flow.Flow
import kotlin.time.Duration
interface GetBiometricRequireConfirmation : () -> Flow<Boolean>

View File

@ -0,0 +1,5 @@
package com.artemchep.keyguard.common.usecase
import com.artemchep.keyguard.common.io.IO
interface PutBiometricRequireConfirmation : (Boolean) -> IO<Unit>

View File

@ -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<Boolean> = sharedFlow
}

View File

@ -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<Unit> = settingsReadWriteRepository
.setBiometricRequireConfirmation(requireConfirmation)
}

View File

@ -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

View File

@ -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<ChangePasswordState> = with(localDI().direct) {
changePasswordState(
unlockUseCase = instance(),
getBiometricRequireConfirmation = instance(),
windowCoroutineScope = instance(),
)
}
@ -44,6 +47,7 @@ fun changePasswordState(): Loadable<ChangePasswordState> = with(localDI().direct
@Composable
fun changePasswordState(
unlockUseCase: UnlockUseCase,
getBiometricRequireConfirmation: GetBiometricRequireConfirmation,
windowCoroutineScope: WindowCoroutineScope,
): Loadable<ChangePasswordState> = 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<BiometricAuthPrompt>,
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 ->

View File

@ -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<ElevatedAccessResult>,
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 ->

View File

@ -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<String, (DirectDI) -> 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,

View File

@ -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<BiometricAuthPrompt>()
}
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<BiometricAuthPrompt>()
}
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,

View File

@ -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),
)
}

View File

@ -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),
),
),

View File

@ -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<Throwable, LeCipher>,
getCreateIo: (String) -> IO<Unit>,
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,
)
}

View File

@ -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<Throwable, LeCipher>,
private val getCreateIo: () -> IO<Unit>,
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() {

View File

@ -991,6 +991,8 @@
<string name="pref_item_features_overview_title">Features overview</string>
<string name="pref_item_url_override_title">URL overrides</string>
<string name="pref_item_biometric_unlock_title">Biometric unlock</string>
<string name="pref_item_biometric_unlock_require_confirmation_title">Require confirmation</string>
<string name="pref_item_biometric_unlock_require_confirmation_text">Require an additional confirmation button tap after a successful biometric authentication</string>
<!--
A title of the system popup that asks a user to use his biometric to later
be able to use it to unlock the vault. -->

View File

@ -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<GetBiometricRequireConfirmation> {
GetBiometricRequireConfirmationImpl(
directDI = this,
)
}
bindSingleton<PutBiometricRequireConfirmation> {
PutBiometricRequireConfirmationImpl(
directDI = this,
)
}
bindSingleton<GetCheckPwnedPasswords> {
GetCheckPwnedPasswordsImpl(
directDI = this,