change : confirm biometric before allowing biometric unlock.

This commit is contained in:
ganfra
2024-11-22 17:13:08 +01:00
parent 2895d0263c
commit 5fcf8a6cb4
19 changed files with 321 additions and 117 deletions

View File

@@ -10,7 +10,7 @@ package io.element.android.features.lockscreen.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
@@ -45,7 +45,7 @@ class DefaultLockScreenService @Inject constructor(
private val coroutineScope: CoroutineScope,
private val sessionObserver: SessionObserver,
private val appForegroundStateService: AppForegroundStateService,
biometricUnlockManager: BiometricUnlockManager,
biometricAuthenticatorManager: BiometricAuthenticatorManager,
) : LockScreenService {
private val _lockState = MutableStateFlow<LockScreenLockState>(LockScreenLockState.Unlocked)
override val lockState: StateFlow<LockScreenLockState> = _lockState
@@ -62,8 +62,8 @@ class DefaultLockScreenService @Inject constructor(
_lockState.value = LockScreenLockState.Unlocked
}
})
biometricUnlockManager.addCallback(object : DefaultBiometricUnlockCallback() {
override fun onBiometricUnlockSuccess() {
biometricAuthenticatorManager.addCallback(object : DefaultBiometricUnlockCallback() {
override fun onBiometricAuthenticationSuccess() {
_lockState.value = LockScreenLockState.Unlocked
coroutineScope.launch {
lockScreenStore.resetCounter()

View File

@@ -21,11 +21,11 @@ import timber.log.Timber
import java.security.InvalidKeyException
import javax.crypto.Cipher
interface BiometricUnlock {
interface BiometricAuthenticator {
interface Callback {
fun onBiometricSetupError()
fun onBiometricUnlockSuccess()
fun onBiometricUnlockFailed(error: Exception?)
fun onBiometricAuthenticationSuccess()
fun onBiometricAuthenticationFailed(error: Exception?)
}
sealed interface AuthenticationResult {
@@ -38,23 +38,23 @@ interface BiometricUnlock {
suspend fun authenticate(): AuthenticationResult
}
class NoopBiometricUnlock : BiometricUnlock {
class NoopBiometricAuthentication : BiometricAuthenticator {
override val isActive: Boolean = false
override fun setup() = Unit
override suspend fun authenticate() = BiometricUnlock.AuthenticationResult.Failure()
override suspend fun authenticate() = BiometricAuthenticator.AuthenticationResult.Failure()
}
class DefaultBiometricUnlock(
class DefaultBiometricAuthentication(
private val activity: FragmentActivity,
private val promptInfo: PromptInfo,
private val secretKeyRepository: SecretKeyRepository,
private val encryptionDecryptionService: EncryptionDecryptionService,
private val keyAlias: String,
private val callbacks: List<BiometricUnlock.Callback>
) : BiometricUnlock {
private val callbacks: List<BiometricAuthenticator.Callback>
) : BiometricAuthenticator {
override val isActive: Boolean = true
private lateinit var cryptoObject: CryptoObject
private var cryptoObject: CryptoObject? = null
override fun setup() {
try {
@@ -67,11 +67,10 @@ class DefaultBiometricUnlock(
}
}
override suspend fun authenticate(): BiometricUnlock.AuthenticationResult {
if (!this::cryptoObject.isInitialized) {
return BiometricUnlock.AuthenticationResult.Failure()
}
val deferredAuthenticationResult = CompletableDeferred<BiometricUnlock.AuthenticationResult>()
override suspend fun authenticate(): BiometricAuthenticator.AuthenticationResult {
val cryptoObject = cryptoObject ?: return BiometricAuthenticator.AuthenticationResult.Failure()
val deferredAuthenticationResult = CompletableDeferred<BiometricAuthenticator.AuthenticationResult>()
val executor = ContextCompat.getMainExecutor(activity.baseContext)
val callback = AuthenticationCallback(callbacks, deferredAuthenticationResult)
val prompt = BiometricPrompt(activity, executor, callback)
@@ -80,7 +79,7 @@ class DefaultBiometricUnlock(
deferredAuthenticationResult.await()
} catch (cancellation: CancellationException) {
prompt.cancelAuthentication()
BiometricUnlock.AuthenticationResult.Failure(cancellation)
BiometricAuthenticator.AuthenticationResult.Failure(cancellation)
}
}
@@ -91,30 +90,30 @@ class DefaultBiometricUnlock(
}
private class AuthenticationCallback(
private val callbacks: List<BiometricUnlock.Callback>,
private val deferredAuthenticationResult: CompletableDeferred<BiometricUnlock.AuthenticationResult>,
private val callbacks: List<BiometricAuthenticator.Callback>,
private val deferredAuthenticationResult: CompletableDeferred<BiometricAuthenticator.AuthenticationResult>,
) : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
val biometricUnlockError = BiometricUnlockError(errorCode, errString.toString())
callbacks.forEach { it.onBiometricUnlockFailed(biometricUnlockError) }
deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Failure(biometricUnlockError))
callbacks.forEach { it.onBiometricAuthenticationFailed(biometricUnlockError) }
deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Failure(biometricUnlockError))
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
callbacks.forEach { it.onBiometricUnlockFailed(null) }
callbacks.forEach { it.onBiometricAuthenticationFailed(null) }
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
if (result.cryptoObject?.cipher.isValid()) {
callbacks.forEach { it.onBiometricUnlockSuccess() }
deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Success)
callbacks.forEach { it.onBiometricAuthenticationSuccess() }
deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Success)
} else {
val error = IllegalStateException("Invalid cipher")
callbacks.forEach { it.onBiometricUnlockFailed(error) }
deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Failure())
callbacks.forEach { it.onBiometricAuthenticationFailed(error) }
deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Failure())
}
}

View File

@@ -9,7 +9,7 @@ package io.element.android.features.lockscreen.impl.biometric
import androidx.compose.runtime.Composable
interface BiometricUnlockManager {
interface BiometricAuthenticatorManager {
/**
* If the device is secured for example with a pin, pattern or password.
*/
@@ -20,9 +20,18 @@ interface BiometricUnlockManager {
*/
val hasAvailableAuthenticator: Boolean
fun addCallback(callback: BiometricUnlock.Callback)
fun removeCallback(callback: BiometricUnlock.Callback)
fun addCallback(callback: BiometricAuthenticator.Callback)
fun removeCallback(callback: BiometricAuthenticator.Callback)
/**
* Remember a biometric authenticator ready for unlocking the app.
*/
@Composable
fun rememberBiometricUnlock(): BiometricUnlock
fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator
/**
* Remember a biometric authenticator ready for confirmation.
*/
@Composable
fun rememberConfirmBiometricAuthenticator(): BiometricAuthenticator
}

View File

@@ -31,6 +31,7 @@ import io.element.android.libraries.cryptography.api.SecretKeyRepository
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.concurrent.CopyOnWriteArrayList
@@ -40,15 +41,15 @@ private const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_BIOMETRIC"
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultBiometricUnlockManager @Inject constructor(
class DefaultBiometricAuthenticatorManager @Inject constructor(
@ApplicationContext private val context: Context,
private val lockScreenStore: LockScreenStore,
private val lockScreenConfig: LockScreenConfig,
private val encryptionDecryptionService: EncryptionDecryptionService,
private val secretKeyRepository: SecretKeyRepository,
private val coroutineScope: CoroutineScope,
) : BiometricUnlockManager {
private val callbacks = CopyOnWriteArrayList<BiometricUnlock.Callback>()
) : BiometricAuthenticatorManager {
private val callbacks = CopyOnWriteArrayList<BiometricAuthenticator.Callback>()
private val biometricManager = BiometricManager.from(context)
private val keyguardManager: KeyguardManager = context.getSystemService()!!
@@ -85,16 +86,42 @@ class DefaultBiometricUnlockManager @Inject constructor(
}
@Composable
override fun rememberBiometricUnlock(): BiometricUnlock {
override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
val isBiometricAllowed by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
val isAvailable by remember(lifecycleState) {
derivedStateOf {
isBiometricAllowed && hasAvailableAuthenticator
}
derivedStateOf { isBiometricAllowed && hasAvailableAuthenticator }
}
val promptTitle = stringResource(id = R.string.screen_app_lock_biometric_unlock_title_android)
val promptNegative = stringResource(id = R.string.screen_app_lock_use_pin_android)
return rememberBiometricAuthenticator(
isAvailable = isAvailable,
promptTitle = promptTitle,
promptNegative = promptNegative,
)
}
@Composable
override fun rememberConfirmBiometricAuthenticator(): BiometricAuthenticator {
val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
val isAvailable by remember(lifecycleState) {
derivedStateOf { hasAvailableAuthenticator }
}
val promptTitle = stringResource(id = R.string.screen_app_lock_confirm_biometric_authentication_android)
val promptNegative = stringResource(id = CommonStrings.action_cancel)
return rememberBiometricAuthenticator(
isAvailable = isAvailable,
promptTitle = promptTitle,
promptNegative = promptNegative,
)
}
@Composable
private fun rememberBiometricAuthenticator(
isAvailable: Boolean,
promptTitle: String,
promptNegative: String,
): BiometricAuthenticator {
val activity = LocalContext.current.findFragmentActivity()
return remember(isAvailable) {
if (isAvailable && activity != null) {
@@ -108,7 +135,7 @@ class DefaultBiometricUnlockManager @Inject constructor(
setNegativeButtonText(promptNegative)
setAllowedAuthenticators(authenticators)
}.build()
DefaultBiometricUnlock(
DefaultBiometricAuthentication(
activity = activity,
promptInfo = promptInfo,
secretKeyRepository = secretKeyRepository,
@@ -117,16 +144,16 @@ class DefaultBiometricUnlockManager @Inject constructor(
callbacks = callbacks + internalCallback
)
} else {
NoopBiometricUnlock()
NoopBiometricAuthentication()
}
}
}
override fun addCallback(callback: BiometricUnlock.Callback) {
override fun addCallback(callback: BiometricAuthenticator.Callback) {
callbacks.add(callback)
}
override fun removeCallback(callback: BiometricUnlock.Callback) {
override fun removeCallback(callback: BiometricAuthenticator.Callback) {
callbacks.remove(callback)
}

View File

@@ -7,8 +7,8 @@
package io.element.android.features.lockscreen.impl.biometric
open class DefaultBiometricUnlockCallback : BiometricUnlock.Callback {
open class DefaultBiometricUnlockCallback : BiometricAuthenticator.Callback {
override fun onBiometricSetupError() = Unit
override fun onBiometricUnlockSuccess() = Unit
override fun onBiometricUnlockFailed(error: Exception?) = Unit
override fun onBiometricAuthenticationSuccess() = Unit
override fun onBiometricAuthenticationFailed(error: Exception?) = Unit
}

View File

@@ -15,7 +15,8 @@ import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.lockscreen.impl.LockScreenConfig
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.libraries.architecture.Presenter
@@ -27,7 +28,7 @@ class LockScreenSettingsPresenter @Inject constructor(
private val lockScreenConfig: LockScreenConfig,
private val pinCodeManager: PinCodeManager,
private val lockScreenStore: LockScreenStore,
private val biometricUnlockManager: BiometricUnlockManager,
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
private val coroutineScope: CoroutineScope,
) : Presenter<LockScreenSettingsState> {
@Composable
@@ -42,6 +43,8 @@ class LockScreenSettingsPresenter @Inject constructor(
mutableStateOf(false)
}
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
fun handleEvents(event: LockScreenSettingsEvents) {
when (event) {
LockScreenSettingsEvents.CancelRemovePin -> showRemovePinConfirmation = false
@@ -56,7 +59,14 @@ class LockScreenSettingsPresenter @Inject constructor(
LockScreenSettingsEvents.OnRemovePin -> showRemovePinConfirmation = true
LockScreenSettingsEvents.ToggleBiometricAllowed -> {
coroutineScope.launch {
lockScreenStore.setIsBiometricUnlockAllowed(!isBiometricEnabled)
if (!isBiometricEnabled) {
biometricUnlock.setup()
if (biometricUnlock.authenticate() == BiometricAuthenticator.AuthenticationResult.Success) {
lockScreenStore.setIsBiometricUnlockAllowed(true)
}
} else {
lockScreenStore.setIsBiometricUnlockAllowed(false)
}
}
}
}
@@ -66,7 +76,7 @@ class LockScreenSettingsPresenter @Inject constructor(
showRemovePinOption = showRemovePinOption,
isBiometricEnabled = isBiometricEnabled,
showRemovePinConfirmation = showRemovePinConfirmation,
showToggleBiometric = biometricUnlockManager.isDeviceSecured,
showToggleBiometric = biometricAuthenticatorManager.isDeviceSecured,
eventSink = ::handleEvents
)
}

View File

@@ -20,6 +20,7 @@ import com.bumble.appyx.navmodel.backstack.operation.newRoot
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.setup.biometric.SetupBiometricNode
@@ -35,6 +36,7 @@ class LockScreenSetupFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val pinCodeManager: PinCodeManager,
val biometricAuthenticatorManager: BiometricAuthenticatorManager,
) : BaseFlowNode<LockScreenSetupFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Pin,
@@ -61,7 +63,11 @@ class LockScreenSetupFlowNode @AssistedInject constructor(
private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
backstack.newRoot(NavTarget.Biometric)
if (biometricAuthenticatorManager.hasAvailableAuthenticator) {
backstack.newRoot(NavTarget.Biometric)
} else {
onSetupDone()
}
}
}

View File

@@ -13,6 +13,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.launch
@@ -20,6 +22,7 @@ import javax.inject.Inject
class SetupBiometricPresenter @Inject constructor(
private val lockScreenStore: LockScreenStore,
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
) : Presenter<SetupBiometricState> {
@Composable
override fun present(): SetupBiometricState {
@@ -28,12 +31,16 @@ class SetupBiometricPresenter @Inject constructor(
}
val coroutineScope = rememberCoroutineScope()
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
fun handleEvents(event: SetupBiometricEvents) {
when (event) {
SetupBiometricEvents.AllowBiometric -> coroutineScope.launch {
lockScreenStore.setIsBiometricUnlockAllowed(true)
isBiometricSetupDone = true
biometricUnlock.setup()
if (biometricUnlock.authenticate() == BiometricAuthenticator.AuthenticationResult.Success) {
lockScreenStore.setIsBiometricUnlockAllowed(true)
isBiometricSetupDone = true
}
}
SetupBiometricEvents.UsePin -> coroutineScope.launch {
lockScreenStore.setIsBiometricUnlockAllowed(false)

View File

@@ -11,14 +11,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import javax.inject.Inject
class PinUnlockHelper @Inject constructor(
private val biometricUnlockManager: BiometricUnlockManager,
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
private val pinCodeManager: PinCodeManager
) {
@Composable
@@ -26,7 +26,7 @@ class PinUnlockHelper @Inject constructor(
val latestOnUnlock by rememberUpdatedState(onUnlock)
DisposableEffect(Unit) {
val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricUnlockSuccess() {
override fun onBiometricAuthenticationSuccess() {
latestOnUnlock()
}
}
@@ -35,10 +35,10 @@ class PinUnlockHelper @Inject constructor(
latestOnUnlock()
}
}
biometricUnlockManager.addCallback(biometricUnlockCallback)
biometricAuthenticatorManager.addCallback(biometricUnlockCallback)
pinCodeManager.addCallback(pinCodeVerifiedCallback)
onDispose {
biometricUnlockManager.removeCallback(biometricUnlockCallback)
biometricAuthenticatorManager.removeCallback(biometricUnlockCallback)
pinCodeManager.removeCallback(pinCodeVerifiedCallback)
}
}

View File

@@ -15,8 +15,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
@@ -32,7 +32,7 @@ import javax.inject.Inject
class PinUnlockPresenter @Inject constructor(
private val pinCodeManager: PinCodeManager,
private val biometricUnlockManager: BiometricUnlockManager,
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
private val logoutUseCase: LogoutUseCase,
private val coroutineScope: CoroutineScope,
private val pinUnlockHelper: PinUnlockHelper,
@@ -56,12 +56,12 @@ class PinUnlockPresenter @Inject constructor(
mutableStateOf<AsyncAction<String?>>(AsyncAction.Uninitialized)
}
var biometricUnlockResult by remember {
mutableStateOf<BiometricUnlock.AuthenticationResult?>(null)
mutableStateOf<BiometricAuthenticator.AuthenticationResult?>(null)
}
val isUnlocked = remember {
mutableStateOf(false)
}
val biometricUnlock = biometricUnlockManager.rememberBiometricUnlock()
val biometricUnlock = biometricAuthenticatorManager.rememberUnlockBiometricAuthenticator()
LaunchedEffect(Unit) {
suspend {
val pinCodeSize = pinCodeManager.getPinCodeSize()

View File

@@ -7,7 +7,7 @@
package io.element.android.features.lockscreen.impl.unlock
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockError
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.architecture.AsyncAction
@@ -21,7 +21,7 @@ data class PinUnlockState(
val signOutAction: AsyncAction<String?>,
val showBiometricUnlock: Boolean,
val isUnlocked: Boolean,
val biometricUnlockResult: BiometricUnlock.AuthenticationResult?,
val biometricUnlockResult: BiometricAuthenticator.AuthenticationResult?,
val eventSink: (PinUnlockEvents) -> Unit
) {
val isSignOutPromptCancellable = when (remainingAttempts) {
@@ -30,7 +30,7 @@ data class PinUnlockState(
}
val biometricUnlockErrorMessage = when {
biometricUnlockResult is BiometricUnlock.AuthenticationResult.Failure &&
biometricUnlockResult is BiometricAuthenticator.AuthenticationResult.Failure &&
biometricUnlockResult.error is BiometricUnlockError &&
biometricUnlockResult.error.isAuthDisabledError -> {
biometricUnlockResult.error.message

View File

@@ -9,7 +9,7 @@ package io.element.android.features.lockscreen.impl.unlock
import androidx.biometric.BiometricPrompt
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockError
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.architecture.AsyncAction
@@ -25,7 +25,7 @@ open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
aPinUnlockState(showBiometricUnlock = false),
aPinUnlockState(showSignOutPrompt = true, remainingAttempts = 0),
aPinUnlockState(signOutAction = AsyncAction.Loading),
aPinUnlockState(biometricUnlockResult = BiometricUnlock.AuthenticationResult.Failure(
aPinUnlockState(biometricUnlockResult = BiometricAuthenticator.AuthenticationResult.Failure(
BiometricUnlockError(BiometricPrompt.ERROR_LOCKOUT, "Biometric auth disabled")
)),
)
@@ -37,7 +37,7 @@ fun aPinUnlockState(
showWrongPinTitle: Boolean = false,
showSignOutPrompt: Boolean = false,
showBiometricUnlock: Boolean = true,
biometricUnlockResult: BiometricUnlock.AuthenticationResult? = null,
biometricUnlockResult: BiometricAuthenticator.AuthenticationResult? = null,
isUnlocked: Boolean = false,
signOutAction: AsyncAction<String?> = AsyncAction.Uninitialized,
) = PinUnlockState(

View File

@@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"biometric authentication"</string>
<string name="screen_app_lock_biometric_unlock">"biometric unlock"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Unlock with biometric"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Confirm biometric"</string>
<string name="screen_app_lock_forgot_pin">"Forgot PIN?"</string>
<string name="screen_app_lock_settings_change_pin">"Change PIN code"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Allow biometric unlock"</string>

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.lockscreen.impl.biometric
class FakeBiometricAuthenticator(
override val isActive: Boolean = false,
private val authenticateLambda: suspend () -> BiometricAuthenticator.AuthenticationResult = { BiometricAuthenticator.AuthenticationResult.Success },
) : BiometricAuthenticator {
override fun setup() = Unit
override suspend fun authenticate() = authenticateLambda()
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.lockscreen.impl.biometric
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
class FakeBiometricAuthenticatorManager(
override var isDeviceSecured: Boolean = true,
override var hasAvailableAuthenticator: Boolean = false,
private val createBiometricAuthenticator: () -> BiometricAuthenticator = { FakeBiometricAuthenticator() },
) : BiometricAuthenticatorManager {
override fun addCallback(callback: BiometricAuthenticator.Callback) {
// no-op
}
override fun removeCallback(callback: BiometricAuthenticator.Callback) {
// no-op
}
@Composable
override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
return remember {
createBiometricAuthenticator()
}
}
@Composable
override fun rememberConfirmBiometricAuthenticator(): BiometricAuthenticator {
return remember {
createBiometricAuthenticator()
}
}
}

View File

@@ -1,31 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.lockscreen.impl.biometric
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
class FakeBiometricUnlockManager : BiometricUnlockManager {
override var isDeviceSecured: Boolean = true
override var hasAvailableAuthenticator: Boolean = false
override fun addCallback(callback: BiometricUnlock.Callback) {
// no-op
}
override fun removeCallback(callback: BiometricUnlock.Callback) {
// no-op
}
@Composable
override fun rememberBiometricUnlock(): BiometricUnlock {
return remember {
NoopBiometricUnlock()
}
}
}

View File

@@ -7,28 +7,38 @@
package io.element.android.features.lockscreen.impl.settings
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.LockScreenConfig
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricUnlockManager
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.test
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LockScreenSettingsPresenterTest {
@Test
fun `present - remove pin option is hidden when mandatory`() = runTest {
val presenter = createLockScreenSettingsPresenter(this, lockScreenConfig = aLockScreenConfig(isPinMandatory = true))
presenter.test {
awaitItem().also { state ->
assertThat(state.showRemovePinOption).isFalse()
}
}
}
@Test
fun `present - remove pin flow`() = runTest {
val presenter = createLockScreenSettingsPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
consumeItemsUntilPredicate { state ->
state.showRemovePinOption
}.last().also { state ->
@@ -55,11 +65,95 @@ class LockScreenSettingsPresenterTest {
}
}
@Test
fun `present - show toggle biometric if device is secured`() = runTest {
val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(
isDeviceSecured = true,
)
val presenter = createLockScreenSettingsPresenter(
coroutineScope = this,
biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
)
presenter.test {
skipItems(1)
assertThat(awaitItem().showToggleBiometric).isTrue()
}
}
@Test
fun `present - enable biometric unlock success`() = runTest {
val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(
createBiometricAuthenticator = {
FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Success })
}
)
val presenter = createLockScreenSettingsPresenter(
coroutineScope = this,
biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
)
presenter.test {
skipItems(1)
awaitItem().also { state ->
state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
}
awaitItem().also { state ->
assertThat(state.isBiometricEnabled).isTrue()
}
}
}
@Test
fun `present - enable biometric unlock failure`() = runTest {
val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(
createBiometricAuthenticator = {
FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Failure() })
}
)
val presenter = createLockScreenSettingsPresenter(
coroutineScope = this,
biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
)
presenter.test {
skipItems(1)
awaitItem().also { state ->
state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
}
}
}
@Test
fun `present - disable biometric unlock`() = runTest {
val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(
createBiometricAuthenticator = {
FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Failure() })
}
)
val lockScreenStore = InMemoryLockScreenStore()
val presenter = createLockScreenSettingsPresenter(
coroutineScope = this,
lockScreenStore = lockScreenStore,
biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
)
lockScreenStore.setIsBiometricUnlockAllowed(true)
presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.isBiometricEnabled).isTrue()
state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
}
awaitItem().also { state ->
assertThat(state.isBiometricEnabled).isFalse()
}
}
}
private suspend fun createLockScreenSettingsPresenter(
coroutineScope: CoroutineScope,
lockScreenConfig: LockScreenConfig = aLockScreenConfig(),
biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
lockScreenStore: LockScreenStore = InMemoryLockScreenStore(),
): LockScreenSettingsPresenter {
val lockScreenStore = InMemoryLockScreenStore()
val pinCodeManager = aPinCodeManager(lockScreenStore = lockScreenStore).apply {
createPinCode("1234")
}
@@ -68,7 +162,7 @@ class LockScreenSettingsPresenterTest {
pinCodeManager = pinCodeManager,
coroutineScope = coroutineScope,
lockScreenConfig = lockScreenConfig,
biometricUnlockManager = FakeBiometricUnlockManager(),
biometricAuthenticatorManager = biometricAuthenticatorManager,
)
}
}

View File

@@ -11,6 +11,10 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import kotlinx.coroutines.flow.first
@@ -19,9 +23,12 @@ import org.junit.Test
class SetupBiometricPresenterTest {
@Test
fun `present - allow flow`() = runTest {
fun `present - allow flow with biometric authentication success`() = runTest {
val lockScreenStore = InMemoryLockScreenStore()
val presenter = createSetupBiometricPresenter(lockScreenStore)
val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(createBiometricAuthenticator = {
FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Success })
})
val presenter = createSetupBiometricPresenter(lockScreenStore, fakeBiometricAuthenticatorManager)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -36,6 +43,24 @@ class SetupBiometricPresenterTest {
assertThat(lockScreenStore.isBiometricUnlockAllowed().first()).isTrue()
}
@Test
fun `present - allow flow with biometric authentication failure`() = runTest {
val lockScreenStore = InMemoryLockScreenStore()
val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(createBiometricAuthenticator = {
FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Failure() })
})
val presenter = createSetupBiometricPresenter(lockScreenStore, fakeBiometricAuthenticatorManager)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().also { state ->
assertThat(state.isBiometricSetupDone).isFalse()
state.eventSink(SetupBiometricEvents.AllowBiometric)
}
}
assertThat(lockScreenStore.isBiometricUnlockAllowed().first()).isFalse()
}
@Test
fun `present - skip flow`() = runTest {
val lockScreenStore = InMemoryLockScreenStore()
@@ -55,10 +80,12 @@ class SetupBiometricPresenterTest {
}
private fun createSetupBiometricPresenter(
lockScreenStore: LockScreenStore = InMemoryLockScreenStore()
lockScreenStore: LockScreenStore = InMemoryLockScreenStore(),
biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
): SetupBiometricPresenter {
return SetupBiometricPresenter(
lockScreenStore = lockScreenStore,
biometricAuthenticatorManager = biometricAuthenticatorManager
)
}
}

View File

@@ -11,8 +11,8 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricUnlockManager
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
@@ -137,7 +137,7 @@ class PinUnlockPresenterTest {
private suspend fun createPinUnlockPresenter(
scope: CoroutineScope,
biometricUnlockManager: BiometricUnlockManager = FakeBiometricUnlockManager(),
biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = { "" }),
): PinUnlockPresenter {
@@ -147,10 +147,10 @@ class PinUnlockPresenterTest {
}
return PinUnlockPresenter(
pinCodeManager = pinCodeManager,
biometricUnlockManager = biometricUnlockManager,
biometricAuthenticatorManager = biometricAuthenticatorManager,
logoutUseCase = logoutUseCase,
coroutineScope = scope,
pinUnlockHelper = PinUnlockHelper(biometricUnlockManager, pinCodeManager),
pinUnlockHelper = PinUnlockHelper(biometricAuthenticatorManager, pinCodeManager),
)
}
}