[Link new device] Unlock with device credentials or application PIN code
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.lockscreen.api.DeviceUnlockEntryPoint
|
||||
import io.element.android.features.lockscreen.impl.device.DeviceUnlockCallbackHolder
|
||||
import io.element.android.features.lockscreen.impl.device.DeviceUnlockNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultDeviceUnlockEntryPoint(
|
||||
private val deviceUnlockCallbackHolder: DeviceUnlockCallbackHolder,
|
||||
) : DeviceUnlockEntryPoint {
|
||||
override fun createNode(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
): Node {
|
||||
return parentNode.createNode<DeviceUnlockNode>(
|
||||
buildContext = buildContext,
|
||||
)
|
||||
}
|
||||
|
||||
override fun requestUnlock(callback: DeviceUnlockEntryPoint.Callback) {
|
||||
deviceUnlockCallbackHolder.requestUnlock(callback)
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -109,7 +109,9 @@ private class AuthenticationCallback(
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
if (result.cryptoObject?.cipher.isValid()) {
|
||||
if (result.authenticationType == BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC &&
|
||||
result.cryptoObject?.cipher.isValid() ||
|
||||
result.authenticationType == BiometricPrompt.AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL) {
|
||||
callbacks.forEach { it.onBiometricAuthenticationSuccess() }
|
||||
deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Success)
|
||||
} else {
|
||||
|
||||
+11
@@ -21,6 +21,11 @@ interface BiometricAuthenticatorManager {
|
||||
*/
|
||||
val hasAvailableAuthenticator: Boolean
|
||||
|
||||
/**
|
||||
* If the device is secured for example with a pin, pattern or password, and the user has enrolled at least one biometric.
|
||||
*/
|
||||
val canUseDeviceUnlock: Boolean
|
||||
|
||||
fun addCallback(callback: BiometricAuthenticator.Callback)
|
||||
fun removeCallback(callback: BiometricAuthenticator.Callback)
|
||||
|
||||
@@ -35,6 +40,12 @@ interface BiometricAuthenticatorManager {
|
||||
@Composable
|
||||
fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator
|
||||
|
||||
/**
|
||||
* Remember a biometric authenticator ready for unlocking the app, using the device settings.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberUnlockDeviceBiometricAuthenticator(): BiometricAuthenticator
|
||||
|
||||
/**
|
||||
* Remember a biometric authenticator ready for confirmation.
|
||||
*/
|
||||
|
||||
+50
-6
@@ -35,6 +35,7 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
@@ -69,6 +70,12 @@ class DefaultBiometricAuthenticatorManager(
|
||||
get() = lockScreenConfig.isStrongBiometricsEnabled &&
|
||||
biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
|
||||
/**
|
||||
* Returns true if a strong biometric method (i.e.: fingerprint, some face or iris unlock implementations) can be used.
|
||||
*/
|
||||
private val canUseDeviceCredentialAuth: Boolean
|
||||
get() = biometricManager.canAuthenticate(BiometricManager.Authenticators.DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
|
||||
/**
|
||||
* Returns true if any biometric method (weak or strong) can be used.
|
||||
*/
|
||||
@@ -78,6 +85,9 @@ class DefaultBiometricAuthenticatorManager(
|
||||
override val isDeviceSecured: Boolean
|
||||
get() = keyguardManager.isDeviceSecure
|
||||
|
||||
override val canUseDeviceUnlock: Boolean
|
||||
get() = isDeviceSecured && (canUseWeakBiometricAuth || canUseStrongBiometricAuth || canUseDeviceCredentialAuth)
|
||||
|
||||
private val internalCallback = object : DefaultBiometricUnlockCallback() {
|
||||
override fun onBiometricSetupError() {
|
||||
coroutineScope.launch { disable() }
|
||||
@@ -99,6 +109,28 @@ class DefaultBiometricAuthenticatorManager(
|
||||
isAvailable = isAvailable,
|
||||
promptTitle = promptTitle,
|
||||
promptNegative = promptNegative,
|
||||
forDeviceUnlock = false,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun rememberUnlockDeviceBiometricAuthenticator(): BiometricAuthenticator {
|
||||
val isAvailableTrigger by remember {
|
||||
// Need to trigger the creation of BiometricAuthenticator twice, else the callback will not be ready.
|
||||
// (the issue already exists in [rememberUnlockBiometricAuthenticator])
|
||||
flowOf(false, true)
|
||||
}.collectAsState(initial = false)
|
||||
val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
|
||||
val isAvailable by remember(lifecycleState) {
|
||||
derivedStateOf { isAvailableTrigger && canUseDeviceUnlock }
|
||||
}
|
||||
val promptTitle = stringResource(id = R.string.screen_app_lock_biometric_unlock_title_android)
|
||||
val promptNegative = null
|
||||
return rememberBiometricAuthenticator(
|
||||
isAvailable = isAvailable,
|
||||
promptTitle = promptTitle,
|
||||
promptNegative = promptNegative,
|
||||
forDeviceUnlock = true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,6 +146,7 @@ class DefaultBiometricAuthenticatorManager(
|
||||
isAvailable = isAvailable,
|
||||
promptTitle = promptTitle,
|
||||
promptNegative = promptNegative,
|
||||
forDeviceUnlock = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -126,7 +159,8 @@ class DefaultBiometricAuthenticatorManager(
|
||||
private fun rememberBiometricAuthenticator(
|
||||
isAvailable: Boolean,
|
||||
promptTitle: String,
|
||||
promptNegative: String,
|
||||
promptNegative: String?,
|
||||
forDeviceUnlock: Boolean,
|
||||
): BiometricAuthenticator {
|
||||
val activity = LocalContext.current.findFragmentActivity()
|
||||
return remember(isAvailable) {
|
||||
@@ -136,11 +170,21 @@ class DefaultBiometricAuthenticatorManager(
|
||||
canUseWeakBiometricAuth -> BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||
else -> 0
|
||||
}
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder().apply {
|
||||
setTitle(promptTitle)
|
||||
setNegativeButtonText(promptNegative)
|
||||
setAllowedAuthenticators(authenticators)
|
||||
}.build()
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(promptTitle)
|
||||
.apply {
|
||||
if (promptNegative != null) {
|
||||
setNegativeButtonText(promptNegative)
|
||||
}
|
||||
if (forDeviceUnlock) {
|
||||
setAllowedAuthenticators(
|
||||
authenticators or if (canUseDeviceCredentialAuth) BiometricManager.Authenticators.DEVICE_CREDENTIAL else 0
|
||||
)
|
||||
} else {
|
||||
setAllowedAuthenticators(authenticators)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
DefaultBiometricAuthentication(
|
||||
activity = activity,
|
||||
promptInfo = promptInfo,
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.device
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.lockscreen.api.DeviceUnlockEntryPoint
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@Inject
|
||||
@SingleIn(AppScope::class)
|
||||
class DeviceUnlockCallbackHolder {
|
||||
private val _deviceUnlockCallback = MutableStateFlow<DeviceUnlockEntryPoint.Callback?>(null)
|
||||
val deviceUnlockCallback: StateFlow<DeviceUnlockEntryPoint.Callback?> = _deviceUnlockCallback
|
||||
|
||||
fun requestUnlock(callback: DeviceUnlockEntryPoint.Callback) {
|
||||
_deviceUnlockCallback.value = callback
|
||||
}
|
||||
|
||||
fun onDone() {
|
||||
_deviceUnlockCallback.value = null
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.device
|
||||
|
||||
sealed interface DeviceUnlockEvent {
|
||||
data object CancelPinCode : DeviceUnlockEvent
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.device
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.features.lockscreen.impl.unlock.PinUnlockPresenter
|
||||
import io.element.android.features.lockscreen.impl.unlock.PinUnlockView
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
@AssistedInject
|
||||
class DeviceUnlockNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: DeviceUnlockPresenter,
|
||||
private val presenterFactory: PinUnlockPresenter.Factory,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
if (state.showApplicationPinCode) {
|
||||
val pinUnlockPresenter = remember {
|
||||
presenterFactory.create(forDeviceUnlock = true)
|
||||
}
|
||||
val pinState = pinUnlockPresenter.present()
|
||||
PinUnlockView(
|
||||
state = pinState,
|
||||
isInAppUnlock = true,
|
||||
onCancel = {
|
||||
state.eventSink(DeviceUnlockEvent.CancelPinCode)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.device
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
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.unlock.PinUnlockHelper
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
class DeviceUnlockPresenter(
|
||||
private val pinUnlockHelper: PinUnlockHelper,
|
||||
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
|
||||
private val biometricRequester: DeviceUnlockCallbackHolder,
|
||||
private val pinCodeManager: PinCodeManager,
|
||||
) : Presenter<DeviceUnlockState> {
|
||||
@Composable
|
||||
override fun present(): DeviceUnlockState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var showApplicationPinCode by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val biometricUnlock = biometricAuthenticatorManager.rememberUnlockDeviceBiometricAuthenticator()
|
||||
val deviceUnlockCallback by biometricRequester.deviceUnlockCallback.collectAsState()
|
||||
val canUseDeviceUnlock = biometricAuthenticatorManager.canUseDeviceUnlock
|
||||
|
||||
fun setUnlock(isUnlock: Boolean) = coroutineScope.launch {
|
||||
deviceUnlockCallback?.let {
|
||||
if (isUnlock) {
|
||||
it.onUnlocked()
|
||||
} else {
|
||||
it.onCancel()
|
||||
}
|
||||
}
|
||||
showApplicationPinCode = false
|
||||
biometricRequester.onDone()
|
||||
}
|
||||
|
||||
LaunchedEffect(biometricUnlock, canUseDeviceUnlock, deviceUnlockCallback) {
|
||||
if (deviceUnlockCallback != null) {
|
||||
if (canUseDeviceUnlock) {
|
||||
biometricUnlock.setup()
|
||||
biometricUnlock.authenticate()
|
||||
} else if (pinCodeManager.hasPinCode().first()) {
|
||||
showApplicationPinCode = true
|
||||
} else {
|
||||
// No security, unlock immediately
|
||||
setUnlock(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pinUnlockHelper.OnUnlockEffect { isUnlock ->
|
||||
setUnlock(isUnlock)
|
||||
}
|
||||
|
||||
fun handleEvent(event: DeviceUnlockEvent) {
|
||||
when (event) {
|
||||
DeviceUnlockEvent.CancelPinCode -> {
|
||||
showApplicationPinCode = false
|
||||
setUnlock(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DeviceUnlockState(
|
||||
showApplicationPinCode = showApplicationPinCode,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.device
|
||||
|
||||
data class DeviceUnlockState(
|
||||
val showApplicationPinCode: Boolean,
|
||||
val eventSink: (DeviceUnlockEvent) -> Unit,
|
||||
)
|
||||
+8
-1
@@ -103,8 +103,15 @@ class LockScreenSettingsFlowNode(
|
||||
override fun onUnlock() {
|
||||
backstack.newRoot(NavTarget.Settings)
|
||||
}
|
||||
|
||||
override fun onCancel() {
|
||||
// Should not happen in this context.
|
||||
}
|
||||
}
|
||||
createNode<PinUnlockNode>(buildContext, plugins = listOf(callback))
|
||||
val inputs = PinUnlockNode.Inputs(
|
||||
forDeviceUnlock = false,
|
||||
)
|
||||
createNode<PinUnlockNode>(buildContext, plugins = listOf(callback, inputs))
|
||||
}
|
||||
NavTarget.SetupPin -> {
|
||||
createNode<SetupPinNode>(buildContext)
|
||||
|
||||
+9
-3
@@ -24,17 +24,23 @@ class PinUnlockHelper(
|
||||
private val pinCodeManager: PinCodeManager
|
||||
) {
|
||||
@Composable
|
||||
fun OnUnlockEffect(onUnlock: () -> Unit) {
|
||||
fun OnUnlockEffect(onUnlock: (Boolean) -> Unit) {
|
||||
val latestOnUnlock by rememberUpdatedState(onUnlock)
|
||||
DisposableEffect(Unit) {
|
||||
val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
|
||||
override fun onBiometricAuthenticationSuccess() {
|
||||
latestOnUnlock()
|
||||
latestOnUnlock(true)
|
||||
}
|
||||
|
||||
override fun onBiometricAuthenticationFailed(error: Exception?) {
|
||||
if (error != null) {
|
||||
latestOnUnlock(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
val pinCodeVerifiedCallback = object : DefaultPinCodeManagerCallback() {
|
||||
override fun onPinCodeVerified() {
|
||||
latestOnUnlock()
|
||||
latestOnUnlock(true)
|
||||
}
|
||||
}
|
||||
biometricAuthenticatorManager.addCallback(biometricUnlockCallback)
|
||||
|
||||
+14
-1
@@ -17,7 +17,9 @@ import com.bumble.appyx.core.plugin.Plugin
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
@@ -25,13 +27,23 @@ import io.element.android.libraries.di.SessionScope
|
||||
class PinUnlockNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: PinUnlockPresenter,
|
||||
presenterFactory: PinUnlockPresenter.Factory,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
interface Callback : Plugin {
|
||||
fun onUnlock()
|
||||
|
||||
// For feature unlock
|
||||
fun onCancel()
|
||||
}
|
||||
|
||||
data class Inputs(
|
||||
val forDeviceUnlock: Boolean,
|
||||
) : NodeInputs
|
||||
|
||||
private val callback: Callback = callback()
|
||||
private val inputs: Inputs = inputs()
|
||||
|
||||
private val presenter: PinUnlockPresenter = presenterFactory.create(inputs.forDeviceUnlock)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
@@ -46,6 +58,7 @@ class PinUnlockNode(
|
||||
// UnlockNode is only used for in-app unlock, so we can safely set isInAppUnlock to true.
|
||||
// It's set to false in PinUnlockActivity.
|
||||
isInAppUnlock = true,
|
||||
onCancel = callback::onCancel,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
+12
-3
@@ -16,7 +16,9 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
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
|
||||
@@ -32,8 +34,9 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
@AssistedInject
|
||||
class PinUnlockPresenter(
|
||||
@Assisted private val forDeviceUnlock: Boolean,
|
||||
private val pinCodeManager: PinCodeManager,
|
||||
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
|
||||
private val logoutUseCase: LogoutUseCase,
|
||||
@@ -41,6 +44,11 @@ class PinUnlockPresenter(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val pinUnlockHelper: PinUnlockHelper,
|
||||
) : Presenter<PinUnlockState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(forDeviceUnlock: Boolean): PinUnlockPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): PinUnlockState {
|
||||
val pinEntryState = remember {
|
||||
@@ -98,7 +106,7 @@ class PinUnlockPresenter(
|
||||
}
|
||||
}
|
||||
pinUnlockHelper.OnUnlockEffect {
|
||||
isUnlocked.value = true
|
||||
isUnlocked.value = it
|
||||
}
|
||||
|
||||
fun handleEvent(event: PinUnlockEvent) {
|
||||
@@ -128,6 +136,7 @@ class PinUnlockPresenter(
|
||||
}
|
||||
}
|
||||
return PinUnlockState(
|
||||
canNavigateBack = forDeviceUnlock,
|
||||
pinEntry = pinEntry,
|
||||
showWrongPinTitle = showWrongPinTitle,
|
||||
remainingAttempts = remainingAttempts,
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
|
||||
data class PinUnlockState(
|
||||
val canNavigateBack: Boolean,
|
||||
val pinEntry: AsyncData<PinEntry>,
|
||||
val showWrongPinTitle: Boolean,
|
||||
val remainingAttempts: AsyncData<Int>,
|
||||
|
||||
+11
@@ -38,10 +38,20 @@ open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
|
||||
showWrongPinTitle = true,
|
||||
isUnlocked = true,
|
||||
),
|
||||
aPinUnlockState(canNavigateBack = true),
|
||||
)
|
||||
}
|
||||
|
||||
open class PinUnlockStateCompactProvider : PreviewParameterProvider<PinUnlockState> {
|
||||
override val values: Sequence<PinUnlockState>
|
||||
get() = sequenceOf(
|
||||
aPinUnlockState(),
|
||||
aPinUnlockState(canNavigateBack = true)
|
||||
)
|
||||
}
|
||||
|
||||
fun aPinUnlockState(
|
||||
canNavigateBack: Boolean = false,
|
||||
pinEntry: AsyncData<PinEntry> = AsyncData.Success(PinEntry.createEmpty(4)),
|
||||
remainingAttempts: AsyncData<Int> = AsyncData.Success(3),
|
||||
showWrongPinTitle: Boolean = false,
|
||||
@@ -51,6 +61,7 @@ fun aPinUnlockState(
|
||||
isUnlocked: Boolean = false,
|
||||
signOutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
) = PinUnlockState(
|
||||
canNavigateBack = canNavigateBack,
|
||||
pinEntry = pinEntry,
|
||||
showWrongPinTitle = showWrongPinTitle,
|
||||
remainingAttempts = remainingAttempts,
|
||||
|
||||
+41
-14
@@ -36,6 +36,7 @@ import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -50,6 +51,7 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
@@ -65,6 +67,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
fun PinUnlockView(
|
||||
state: PinUnlockState,
|
||||
isInAppUnlock: Boolean,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
OnLifecycleEvent { _, event ->
|
||||
@@ -74,7 +77,7 @@ fun PinUnlockView(
|
||||
}
|
||||
}
|
||||
Surface(modifier) {
|
||||
PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock)
|
||||
PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock, onCancel = onCancel)
|
||||
if (state.showSignOutPrompt) {
|
||||
SignOutPrompt(
|
||||
isCancellable = state.isSignOutPromptCancellable,
|
||||
@@ -105,13 +108,14 @@ fun PinUnlockView(
|
||||
private fun PinUnlockPage(
|
||||
state: PinUnlockState,
|
||||
isInAppUnlock: Boolean,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
BoxWithConstraints {
|
||||
val commonModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.imePadding()
|
||||
.padding(all = 20.dp)
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.imePadding()
|
||||
.padding(all = 20.dp)
|
||||
|
||||
val header = @Composable {
|
||||
PinUnlockHeader(
|
||||
@@ -147,8 +151,8 @@ private fun PinUnlockPage(
|
||||
state.eventSink(PinUnlockEvent.OnPinEntryChanged(it))
|
||||
},
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -177,6 +181,15 @@ private fun PinUnlockPage(
|
||||
modifier = commonModifier,
|
||||
)
|
||||
}
|
||||
if (state.canNavigateBack) {
|
||||
BackButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.systemBarsPadding()
|
||||
.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,8 +230,8 @@ private fun PinUnlockCompactView(
|
||||
}
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
content()
|
||||
@@ -239,9 +252,9 @@ private fun PinUnlockExpandedView(
|
||||
header()
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 40.dp),
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 40.dp),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
@@ -274,8 +287,8 @@ private fun PinDot(
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.background(backgroundColor, CircleShape)
|
||||
.size(14.dp)
|
||||
.background(backgroundColor, CircleShape)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -373,6 +386,7 @@ internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider:
|
||||
PinUnlockView(
|
||||
state = state,
|
||||
isInAppUnlock = true,
|
||||
onCancel = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -384,6 +398,19 @@ internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::clas
|
||||
PinUnlockView(
|
||||
state = state,
|
||||
isInAppUnlock = false,
|
||||
onCancel = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(heightDp = 480, widthDp = 800)
|
||||
internal fun PinUnlockViewCompactPreview(@PreviewParameter(PinUnlockStateCompactProvider::class) state: PinUnlockState) {
|
||||
ElementPreview {
|
||||
PinUnlockView(
|
||||
state = state,
|
||||
isInAppUnlock = false,
|
||||
onCancel = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -41,7 +41,7 @@ class PinUnlockActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@Inject lateinit var presenter: PinUnlockPresenter
|
||||
@Inject lateinit var presenterFactory: PinUnlockPresenter.Factory
|
||||
@Inject lateinit var lockScreenService: LockScreenService
|
||||
@Inject lateinit var appPreferencesStore: AppPreferencesStore
|
||||
@Inject lateinit var featureFlagService: FeatureFlagService
|
||||
@@ -52,6 +52,7 @@ class PinUnlockActivity : AppCompatActivity() {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
bindings<PinUnlockBindings>().inject(this)
|
||||
val presenter = presenterFactory.create(forDeviceUnlock = false)
|
||||
setContent {
|
||||
val colors by remember {
|
||||
enterpriseService.semanticColorsFlow(sessionId = null)
|
||||
@@ -67,6 +68,9 @@ class PinUnlockActivity : AppCompatActivity() {
|
||||
PinUnlockView(
|
||||
state = state,
|
||||
isInAppUnlock = false,
|
||||
onCancel = {
|
||||
// Should not happen
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -8,10 +8,18 @@
|
||||
|
||||
package io.element.android.features.lockscreen.impl.biometric
|
||||
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
|
||||
class FakeBiometricAuthenticator(
|
||||
override val isActive: Boolean = false,
|
||||
private val authenticateLambda: suspend () -> BiometricAuthenticator.AuthenticationResult = { BiometricAuthenticator.AuthenticationResult.Success },
|
||||
private val setupLambda: () -> Unit = { },
|
||||
private val authenticateLambda: () -> BiometricAuthenticator.AuthenticationResult = { BiometricAuthenticator.AuthenticationResult.Success },
|
||||
) : BiometricAuthenticator {
|
||||
override suspend fun setup() = Unit
|
||||
override suspend fun authenticate() = authenticateLambda()
|
||||
override suspend fun setup() = simulateLongTask {
|
||||
setupLambda()
|
||||
}
|
||||
|
||||
override suspend fun authenticate() = simulateLongTask {
|
||||
authenticateLambda()
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -12,8 +12,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
class FakeBiometricAuthenticatorManager(
|
||||
override var isDeviceSecured: Boolean = true,
|
||||
override var hasAvailableAuthenticator: Boolean = false,
|
||||
override val isDeviceSecured: Boolean = true,
|
||||
override val canUseDeviceUnlock: Boolean = true,
|
||||
override val hasAvailableAuthenticator: Boolean = false,
|
||||
private val createBiometricAuthenticator: () -> BiometricAuthenticator = { FakeBiometricAuthenticator() },
|
||||
private val disableLambda: suspend () -> Unit = { },
|
||||
) : BiometricAuthenticatorManager {
|
||||
@@ -28,7 +29,14 @@ class FakeBiometricAuthenticatorManager(
|
||||
@Composable
|
||||
override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
|
||||
return remember {
|
||||
createBiometricAuthenticator()
|
||||
createBiometricAuthenticator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun rememberUnlockDeviceBiometricAuthenticator(): BiometricAuthenticator {
|
||||
return remember {
|
||||
createBiometricAuthenticator()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.lockscreen.impl.device
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
|
||||
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.aPinCodeManager
|
||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||
import io.element.android.features.lockscreen.impl.unlock.PinUnlockHelper
|
||||
import io.element.android.features.lockscreen.test.FakeDeviceUnlockCallback
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class DeviceUnlockPresenterTest {
|
||||
@Test
|
||||
fun `present - when unlock requested and device unlock available, use biometric authenticator`() = runTest {
|
||||
val setupLambda = lambdaRecorder<Unit> { }
|
||||
val authenticateLambda = lambdaRecorder<BiometricAuthenticator.AuthenticationResult> {
|
||||
BiometricAuthenticator.AuthenticationResult.Success
|
||||
}
|
||||
val fakeBiometricAuthenticator = FakeBiometricAuthenticator(
|
||||
setupLambda = setupLambda,
|
||||
authenticateLambda = authenticateLambda,
|
||||
)
|
||||
val biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(
|
||||
canUseDeviceUnlock = true,
|
||||
createBiometricAuthenticator = { fakeBiometricAuthenticator },
|
||||
)
|
||||
val callbackHolder = DeviceUnlockCallbackHolder()
|
||||
val callback = FakeDeviceUnlockCallback()
|
||||
|
||||
createPresenter(
|
||||
biometricAuthenticatorManager = biometricAuthenticatorManager,
|
||||
callbackHolder = callbackHolder,
|
||||
).test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.showApplicationPinCode).isFalse()
|
||||
}
|
||||
callbackHolder.requestUnlock(callback)
|
||||
advanceUntilIdle()
|
||||
setupLambda.assertions().isCalledOnce()
|
||||
authenticateLambda.assertions().isCalledOnce()
|
||||
skipItems(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when unlock requested and device unlock unavailable and app pin is configured, show app pin`() = runTest {
|
||||
val callbackHolder = DeviceUnlockCallbackHolder()
|
||||
val callback = FakeDeviceUnlockCallback()
|
||||
val pinCodeManager = aPinCodeManager().apply {
|
||||
createPinCode("1234")
|
||||
}
|
||||
createPresenter(
|
||||
biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(canUseDeviceUnlock = false),
|
||||
callbackHolder = callbackHolder,
|
||||
pinCodeManager = pinCodeManager,
|
||||
).test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.showApplicationPinCode).isFalse()
|
||||
}
|
||||
|
||||
callbackHolder.requestUnlock(callback)
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.showApplicationPinCode).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when unlock requested and no security, unlock immediately`() = runTest {
|
||||
val callbackHolder = DeviceUnlockCallbackHolder()
|
||||
val onUnlockedLambda = lambdaRecorder<Unit> { }
|
||||
val callback = FakeDeviceUnlockCallback(
|
||||
onUnlockedLambda = onUnlockedLambda,
|
||||
)
|
||||
createPresenter(
|
||||
biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(canUseDeviceUnlock = false),
|
||||
callbackHolder = callbackHolder,
|
||||
).test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.showApplicationPinCode).isFalse()
|
||||
}
|
||||
callbackHolder.requestUnlock(callback)
|
||||
skipItems(2)
|
||||
assertThat(callbackHolder.deviceUnlockCallback.value).isNull()
|
||||
onUnlockedLambda.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - CancelPinCode event cancels unlock request`() = runTest {
|
||||
val callbackHolder = DeviceUnlockCallbackHolder()
|
||||
val onCancelLambda = lambdaRecorder<Unit> { }
|
||||
val callback = FakeDeviceUnlockCallback(
|
||||
onCancelLambda = onCancelLambda,
|
||||
)
|
||||
val pinCodeManager = aPinCodeManager().apply {
|
||||
createPinCode("1234")
|
||||
}
|
||||
createPresenter(
|
||||
biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(canUseDeviceUnlock = false),
|
||||
callbackHolder = callbackHolder,
|
||||
pinCodeManager = pinCodeManager,
|
||||
).test {
|
||||
awaitItem()
|
||||
callbackHolder.requestUnlock(callback)
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.showApplicationPinCode).isTrue()
|
||||
state.eventSink(DeviceUnlockEvent.CancelPinCode)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.showApplicationPinCode).isFalse()
|
||||
}
|
||||
skipItems(1)
|
||||
onCancelLambda.assertions().isCalledOnce()
|
||||
assertThat(callbackHolder.deviceUnlockCallback.value).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
biometricAuthenticatorManager: FakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
|
||||
callbackHolder: DeviceUnlockCallbackHolder = DeviceUnlockCallbackHolder(),
|
||||
pinCodeManager: PinCodeManager = aPinCodeManager(),
|
||||
): DeviceUnlockPresenter {
|
||||
val pinUnlockHelper = PinUnlockHelper(
|
||||
biometricAuthenticatorManager = biometricAuthenticatorManager,
|
||||
pinCodeManager = pinCodeManager,
|
||||
)
|
||||
return DeviceUnlockPresenter(
|
||||
pinUnlockHelper = pinUnlockHelper,
|
||||
biometricAuthenticatorManager = biometricAuthenticatorManager,
|
||||
biometricRequester = callbackHolder,
|
||||
pinCodeManager = pinCodeManager,
|
||||
)
|
||||
}
|
||||
}
|
||||
+27
@@ -147,11 +147,37 @@ class PinUnlockPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - forDeviceUnlock is exposed in state`() = runTest {
|
||||
val presenter = createPinUnlockPresenter(forDeviceUnlock = true)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.canNavigateBack).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - pin entry changed event updates pin entry`() = runTest {
|
||||
val presenter = createPinUnlockPresenter()
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
state.eventSink(PinUnlockEvent.OnPinEntryChanged(halfCompletePin))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
state.pinEntry.assertText(halfCompletePin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AsyncData<PinEntry>.assertText(text: String) {
|
||||
dataOrNull()?.assertText(text)
|
||||
}
|
||||
|
||||
private suspend fun TestScope.createPinUnlockPresenter(
|
||||
forDeviceUnlock: Boolean = false,
|
||||
biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
|
||||
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
|
||||
logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = {}),
|
||||
@@ -162,6 +188,7 @@ class PinUnlockPresenterTest {
|
||||
createPinCode(completePin)
|
||||
}
|
||||
return PinUnlockPresenter(
|
||||
forDeviceUnlock = forDeviceUnlock,
|
||||
pinCodeManager = pinCodeManager,
|
||||
biometricAuthenticatorManager = biometricAuthenticatorManager,
|
||||
logoutUseCase = logoutUseCase,
|
||||
|
||||
Reference in New Issue
Block a user