Merge pull request #6992 from element-hq/feature/bma/protectLinkNewDevice

Protect link new device
This commit is contained in:
Benoit Marty
2026-06-22 10:40:17 +02:00
committed by GitHub
132 changed files with 1020 additions and 279 deletions
@@ -30,6 +30,7 @@ dependencies {
// TODO Cleanup
implementation(projects.appconfig)
implementation(projects.features.enterprise.api)
implementation(projects.features.lockscreen.api)
implementation(projects.features.rageshake.api)
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
@@ -53,6 +54,7 @@ dependencies {
testCommonDependencies(libs, true)
testImplementation(projects.features.linknewdevice.test)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
@@ -13,8 +13,10 @@ import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.PermanentChild
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
@@ -34,8 +36,10 @@ import io.element.android.features.linknewdevice.impl.screens.error.ErrorNode
import io.element.android.features.linknewdevice.impl.screens.error.ErrorScreenType
import io.element.android.features.linknewdevice.impl.screens.number.EnterNumberNode
import io.element.android.features.linknewdevice.impl.screens.qrcode.ShowQrCodeNode
import io.element.android.features.linknewdevice.impl.screens.root.LinkDeviceType
import io.element.android.features.linknewdevice.impl.screens.root.LinkNewDeviceRootNode
import io.element.android.features.linknewdevice.impl.screens.scan.ScanQrCodeNode
import io.element.android.features.lockscreen.api.DeviceUnlockEntryPoint
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
@@ -67,11 +71,16 @@ class LinkNewDeviceFlowNode(
private val linkNewMobileHandler: LinkNewMobileHandler,
private val linkNewDesktopHandler: LinkNewDesktopHandler,
private val sessionEnterpriseService: SessionEnterpriseService,
private val deviceUnlockEntryPoint: DeviceUnlockEntryPoint,
) : BaseFlowNode<LinkNewDeviceFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
permanentNavModel = PermanentNavModel(
navTargets = setOf(NavTarget.LockScreen),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
@@ -124,6 +133,9 @@ class LinkNewDeviceFlowNode(
@Parcelize
data object DesktopScanQrCode : NavTarget
@Parcelize
data object LockScreen : NavTarget
@Parcelize
data class Error(
val errorScreenType: ErrorScreenType,
@@ -137,6 +149,9 @@ class LinkNewDeviceFlowNode(
Timber.tag(tag.value).d("step: ${linkMobileStep::class.java.simpleName}")
when (linkMobileStep) {
LinkMobileStep.Uninitialized -> Unit
LinkMobileStep.CreatingQrCode -> {
// This step is handled in LinkNewDeviceRootPresenter
}
LinkMobileStep.Done -> {
callback.onDone()
}
@@ -224,13 +239,28 @@ class LinkNewDeviceFlowNode(
callback.onDone()
}
override fun linkDesktopDevice() {
linkNewDesktopHandler.reset()
backstack.push(NavTarget.DesktopNotice)
override fun onUnlockDevice(type: LinkDeviceType) {
val callback = object : DeviceUnlockEntryPoint.Callback {
override fun onCancel() = Unit
override fun onUnlock() = when (type) {
LinkDeviceType.Mobile -> {
linkNewMobileHandler.reset()
linkNewMobileHandler.createAndStartNewHandler()
}
LinkDeviceType.Desktop -> {
linkNewDesktopHandler.reset()
backstack.push(NavTarget.DesktopNotice)
}
}
}
deviceUnlockEntryPoint.requestUnlock(callback)
}
}
createNode<LinkNewDeviceRootNode>(buildContext, listOf(callback))
}
is NavTarget.LockScreen -> {
deviceUnlockEntryPoint.createNode(this, buildContext)
}
NavTarget.DesktopNotice -> {
val callback = object : DesktopNoticeNode.Callback {
override fun navigateBack() {
@@ -324,5 +354,6 @@ class LinkNewDeviceFlowNode(
}
}
BackstackView()
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockScreen)
}
}
@@ -43,12 +43,15 @@ class LinkNewMobileHandler(
val stepFlow: StateFlow<LinkMobileStep>
get() = linkMobileStepFlow.asStateFlow()
fun createAndStartNewHandler() {
fun createAndStartNewHandler(forRotating: Boolean = false) {
Timber.tag(loggerTag.value).d("createAndStartNewHandler()")
currentJob?.cancel()
handler = matrixClient.createLinkMobileHandler().getOrNull()
handler?.let { h ->
currentJob = sessionScope.launch {
if (!forRotating) {
linkMobileStepFlow.emit(LinkMobileStep.CreatingQrCode)
}
h.linkMobileStep
.onEach {
linkMobileStepFlow.emit(it)
@@ -68,7 +71,7 @@ class LinkNewMobileHandler(
}
fun rotateQrCode() {
createAndStartNewHandler()
createAndStartNewHandler(forRotating = true)
}
fun onTooManyRotation() {
@@ -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.linknewdevice.impl.screens.root
enum class LinkDeviceType {
Mobile,
Desktop,
}
@@ -8,6 +8,5 @@
package io.element.android.features.linknewdevice.impl.screens.root
sealed interface LinkNewDeviceRootEvent {
data object LinkMobileDevice : LinkNewDeviceRootEvent
data object CloseDialog : LinkNewDeviceRootEvent
}
@@ -27,7 +27,7 @@ class LinkNewDeviceRootNode(
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onDone()
fun linkDesktopDevice()
fun onUnlockDevice(type: LinkDeviceType)
}
private val callback: Callback = callback()
@@ -39,7 +39,7 @@ class LinkNewDeviceRootNode(
state = state,
modifier = modifier,
onBackClick = callback::onDone,
onLinkDesktopDeviceClick = callback::linkDesktopDevice,
onUnlockDevice = callback::onUnlockDevice,
)
}
}
@@ -50,7 +50,13 @@ class LinkNewDeviceRootPresenter(
LaunchedEffect(step) {
when (val finalStep = step) {
is LinkMobileStep.Uninitialized -> {
qrCodeData = AsyncData.Uninitialized
// Ignore this step when loading QrCode
if (!qrCodeData.isLoading()) {
qrCodeData = AsyncData.Uninitialized
}
}
is LinkMobileStep.CreatingQrCode -> {
qrCodeData = AsyncData.Loading()
}
is LinkMobileStep.QrReady -> {
qrCodeData = AsyncData.Success(Unit)
@@ -64,12 +70,6 @@ class LinkNewDeviceRootPresenter(
fun handleEvent(event: LinkNewDeviceRootEvent) {
when (event) {
LinkNewDeviceRootEvent.LinkMobileDevice -> coroutineScope.launch {
qrCodeData = AsyncData.Loading()
// Wait for the QrCode to be ready
linkNewMobileHandler.reset()
linkNewMobileHandler.createAndStartNewHandler()
}
LinkNewDeviceRootEvent.CloseDialog -> coroutineScope.launch {
linkNewMobileHandler.reset()
}
@@ -41,7 +41,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun LinkNewDeviceRootView(
state: LinkNewDeviceRootState,
onBackClick: () -> Unit,
onLinkDesktopDeviceClick: () -> Unit,
onUnlockDevice: (type: LinkDeviceType) -> Unit,
modifier: Modifier = Modifier,
) {
val (title, subtitle, iconStyle) = if (state.isSupported.dataOrNull() == false) {
@@ -57,6 +57,7 @@ fun LinkNewDeviceRootView(
BigIcon.Style.Default(CompoundIcons.Devices())
)
}
FlowStepPage(
onBackClick = onBackClick,
title = title,
@@ -83,40 +84,37 @@ fun LinkNewDeviceRootView(
}
is AsyncData.Success -> {
if (state.isSupported.data) {
when (state.qrCodeData) {
AsyncData.Uninitialized,
is AsyncData.Failure -> {
Button(
onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) },
text = stringResource(id = R.string.screen_link_new_device_root_mobile_device),
modifier = Modifier.fillMaxWidth(),
leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
)
Button(
onClick = onLinkDesktopDeviceClick,
text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
modifier = Modifier.fillMaxWidth(),
leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
)
}
is AsyncData.Loading,
is AsyncData.Success -> {
Button(
onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) },
text = stringResource(id = R.string.screen_link_new_device_root_loading_qr_code),
showProgress = true,
enabled = false,
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = onLinkDesktopDeviceClick,
text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
modifier = Modifier.fillMaxWidth(),
enabled = false,
leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
)
}
}
val canClick = state.qrCodeData is AsyncData.Uninitialized
val isLoading = state.qrCodeData is AsyncData.Loading || state.qrCodeData is AsyncData.Success
Button(
onClick = {
if (canClick) {
onUnlockDevice(LinkDeviceType.Mobile)
}
},
text = stringResource(
id = if (isLoading) {
R.string.screen_link_new_device_root_loading_qr_code
} else {
R.string.screen_link_new_device_root_mobile_device
}
),
showProgress = isLoading,
enabled = !isLoading,
modifier = Modifier.fillMaxWidth(),
leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
)
Button(
onClick = {
if (canClick) {
onUnlockDevice(LinkDeviceType.Desktop)
}
},
text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
enabled = !isLoading,
modifier = Modifier.fillMaxWidth(),
leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
)
} else {
Button(
onClick = onBackClick,
@@ -147,6 +145,6 @@ internal fun LinkNewDeviceRootViewPreview(
LinkNewDeviceRootView(
state = state,
onBackClick = { },
onLinkDesktopDeviceClick = { },
onUnlockDevice = { },
)
}
@@ -13,6 +13,7 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
import io.element.android.features.lockscreen.test.FakeDeviceUnlockEntryPoint
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
@@ -39,6 +40,7 @@ class DefaultLinkNewDeviceEntryPointTest {
linkNewMobileHandler = LinkNewMobileHandler(client),
linkNewDesktopHandler = LinkNewDesktopHandler(client),
sessionEnterpriseService = FakeSessionEnterpriseService(),
deviceUnlockEntryPoint = FakeDeviceUnlockEntryPoint(),
)
}
val callback: LinkNewDeviceEntryPoint.Callback = object : LinkNewDeviceEntryPoint.Callback {
@@ -88,6 +88,7 @@ class EnterNumberPresenterTest {
navigator = navigator,
linkNewMobileHandler = linkNewMobileHandler,
).test {
skipItems(1)
val initialState = awaitItem()
linkMobileHandler.emitStep(
LinkMobileStep.QrScanned(checkCodeSender)
@@ -96,7 +97,7 @@ class EnterNumberPresenterTest {
initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
skipItems(1)
initialState.eventSink(EnterNumberEvent.Continue)
skipItems(1)
skipItems(2)
val finalState = awaitItem()
assertThat(finalState.sendingCode.isLoading()).isTrue()
advanceUntilIdle()
@@ -130,6 +131,7 @@ class EnterNumberPresenterTest {
LinkMobileStep.QrScanned(checkCodeSender)
)
runCurrent()
skipItems(1)
initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
skipItems(1)
initialState.eventSink(EnterNumberEvent.Continue)
@@ -163,6 +165,7 @@ class EnterNumberPresenterTest {
createPresenter(
linkNewMobileHandler = linkNewMobileHandler,
).test {
skipItems(1)
val initialState = awaitItem()
linkMobileHandler.emitStep(
LinkMobileStep.QrScanned(checkCodeSender)
@@ -171,7 +174,7 @@ class EnterNumberPresenterTest {
initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
skipItems(1)
initialState.eventSink(EnterNumberEvent.Continue)
skipItems(1)
skipItems(2)
val loadingState = awaitItem()
assertThat(loadingState.sendingCode.isLoading()).isTrue()
expectNoEvents()
@@ -75,15 +75,48 @@ class LinkNewDeviceRootPresenterTest {
sessionCoroutineScope = backgroundScope,
createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
)
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
createPresenter(
matrixClient = matrixClient,
linkNewMobileHandler = linkNewMobileHandler,
).test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isSupported.dataOrNull()).isTrue()
initialState.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice)
linkNewMobileHandler.createAndStartNewHandler()
skipItems(1)
val loadingState = awaitItem()
assertThat(loadingState.qrCodeData.isLoading()).isTrue()
skipItems(1)
}
}
@Test
fun `present - close dialog resets qrCodeData`() = runTest {
val fakeLinkMobileHandler = FakeLinkMobileHandler(startResult = {})
val matrixClient = FakeMatrixClient(
canLinkNewDeviceResult = { Result.success(true) },
sessionCoroutineScope = backgroundScope,
createLinkMobileHandlerResult = { Result.success(fakeLinkMobileHandler) }
)
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
createPresenter(
matrixClient = matrixClient,
linkNewMobileHandler = linkNewMobileHandler,
).test {
skipItems(1)
linkNewMobileHandler.onTooManyRotation()
var errorState = awaitItem()
while (!errorState.qrCodeData.isFailure()) {
errorState = awaitItem()
}
assertThat(errorState.qrCodeData.isFailure()).isTrue()
errorState.eventSink(LinkNewDeviceRootEvent.CloseDialog)
var resetState = awaitItem()
while (!resetState.qrCodeData.isUninitialized()) {
resetState = awaitItem()
}
assertThat(resetState.qrCodeData.isUninitialized()).isTrue()
}
}
@@ -17,9 +17,11 @@ import io.element.android.features.linknewdevice.impl.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
@@ -42,29 +44,31 @@ class LinkNewDeviceRootViewTest : RobolectricTest() {
@Test
fun `link desktop button clicked - calls the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
ensureCalledOnceWithParam(LinkDeviceType.Desktop) { callback ->
setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(true),
eventSink = eventRecorder,
),
onLinkDesktopDeviceClick = callback,
onUnlockDevice = callback,
)
clickOn(R.string.screen_link_new_device_root_desktop_computer)
}
}
@Test
fun `link mobile button clicked - emits the expected event`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>()
setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(true),
eventSink = eventRecorder,
fun `link mobile button clicked - calls the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
ensureCalledOnceWithParam(LinkDeviceType.Mobile) { callback ->
setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(true),
eventSink = eventRecorder,
),
onUnlockDevice = callback,
)
)
clickOn(R.string.screen_link_new_device_root_mobile_device)
eventRecorder.assertSingle(LinkNewDeviceRootEvent.LinkMobileDevice)
clickOn(R.string.screen_link_new_device_root_mobile_device)
}
}
@Test
@@ -85,13 +89,13 @@ class LinkNewDeviceRootViewTest : RobolectricTest() {
private fun AndroidComposeUiTest<ComponentActivity>.setLinkNewDeviceRootView(
state: LinkNewDeviceRootState = aLinkNewDeviceRootState(),
onBackClick: () -> Unit = EnsureNeverCalled(),
onLinkDesktopDeviceClick: () -> Unit = EnsureNeverCalled(),
onUnlockDevice: (type: LinkDeviceType) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
LinkNewDeviceRootView(
state = state,
onBackClick = onBackClick,
onLinkDesktopDeviceClick = onLinkDesktopDeviceClick,
onUnlockDevice = onUnlockDevice,
)
}
}
@@ -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.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
/**
* An entry point for features that want to lock the screen and require
* the user to unlock it before they can interact with the app.
* - if the system lock is available, it will be used to unlock the screen.
* - if the system lock is not available, but app lock is available, it will be used to unlock the screen.
* - if neither is available, the screen will be unlocked immediately.
*
* The Node provided by [createNode] has to be added as a PermanentChild.
*/
interface DeviceUnlockEntryPoint : FeatureEntryPoint {
fun createNode(
parentNode: Node,
buildContext: BuildContext,
): Node
fun requestUnlock(callback: Callback)
interface Callback {
fun onCancel()
fun onUnlock()
}
}
@@ -48,6 +48,7 @@ dependencies {
implementation(libs.androidx.biometric)
testCommonDependencies(libs, true)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.cryptography.test)
testImplementation(projects.libraries.cryptography.impl)
@@ -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)
}
}
@@ -59,6 +59,7 @@ class DefaultBiometricAuthentication(
private var cryptoObject: CryptoObject? = null
override suspend fun setup() {
if (cryptoObject != null) return
try {
val secretKey = ensureKey()
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
@@ -109,7 +110,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 {
@@ -21,6 +21,11 @@ interface BiometricAuthenticatorManager {
*/
val hasAvailableAuthenticator: Boolean
/**
* If the device is secured for example with a pin, a pattern, a password, or 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.
*/
@@ -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 device credential method (i.e.: pattern, pin) 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,
@@ -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
}
}
@@ -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
}
@@ -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 pinUnlockPresenterFactory: PinUnlockPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
if (state.showApplicationPinCode) {
val pinUnlockPresenter = remember {
pinUnlockPresenterFactory.create(forDeviceUnlock = true)
}
val pinState = pinUnlockPresenter.present()
PinUnlockView(
state = pinState,
isInAppUnlock = true,
onCancel = {
state.eventSink(DeviceUnlockEvent.CancelPinCode)
},
modifier = modifier,
)
}
}
}
@@ -0,0 +1,85 @@
/*
* 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.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
@Inject
class DeviceUnlockPresenter(
private val pinUnlockHelper: PinUnlockHelper,
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
private val deviceUnlockCallbackHolder: DeviceUnlockCallbackHolder,
private val pinCodeManager: PinCodeManager,
) : Presenter<DeviceUnlockState> {
@Composable
override fun present(): DeviceUnlockState {
var showApplicationPinCode by remember {
mutableStateOf(false)
}
val biometricUnlock = biometricAuthenticatorManager.rememberUnlockDeviceBiometricAuthenticator()
val deviceUnlockCallback by deviceUnlockCallbackHolder.deviceUnlockCallback.collectAsState()
val canUseDeviceUnlock = biometricAuthenticatorManager.canUseDeviceUnlock
fun setUnlock(isUnlock: Boolean) {
deviceUnlockCallback?.let {
if (isUnlock) {
it.onUnlock()
} else {
it.onCancel()
}
}
showApplicationPinCode = false
deviceUnlockCallbackHolder.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,
)
}
}
@@ -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,
)
@@ -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)
@@ -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)
@@ -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
)
}
@@ -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,
@@ -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>,
@@ -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,
@@ -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 = {},
)
}
}
@@ -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
},
)
}
}
@@ -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()
}
}
@@ -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()
}
}
@@ -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.FakeDeviceUnlockEntryPointCallback
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 = FakeDeviceUnlockEntryPointCallback()
createDeviceUnlockPresenter(
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 = FakeDeviceUnlockEntryPointCallback()
val pinCodeManager = aPinCodeManager().apply {
createPinCode("1234")
}
createDeviceUnlockPresenter(
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 = FakeDeviceUnlockEntryPointCallback(
onUnlockedLambda = onUnlockedLambda,
)
createDeviceUnlockPresenter(
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 = FakeDeviceUnlockEntryPointCallback(
onCancelLambda = onCancelLambda,
)
val pinCodeManager = aPinCodeManager().apply {
createPinCode("1234")
}
createDeviceUnlockPresenter(
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 createDeviceUnlockPresenter(
biometricAuthenticatorManager: FakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
callbackHolder: DeviceUnlockCallbackHolder = DeviceUnlockCallbackHolder(),
pinCodeManager: PinCodeManager = aPinCodeManager(),
): DeviceUnlockPresenter {
val pinUnlockHelper = PinUnlockHelper(
biometricAuthenticatorManager = biometricAuthenticatorManager,
pinCodeManager = pinCodeManager,
)
return DeviceUnlockPresenter(
pinUnlockHelper = pinUnlockHelper,
biometricAuthenticatorManager = biometricAuthenticatorManager,
deviceUnlockCallbackHolder = callbackHolder,
pinCodeManager = pinCodeManager,
)
}
}
@@ -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,
@@ -0,0 +1,22 @@
/*
* 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.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.features.lockscreen.api.DeviceUnlockEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
class FakeDeviceUnlockEntryPoint : DeviceUnlockEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
): Node = lambdaError()
override fun requestUnlock(callback: DeviceUnlockEntryPoint.Callback) = lambdaError()
}
@@ -0,0 +1,19 @@
/*
* 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.test
import io.element.android.features.lockscreen.api.DeviceUnlockEntryPoint
import io.element.android.tests.testutils.EnsureNeverCalled
class FakeDeviceUnlockEntryPointCallback(
private val onCancelLambda: () -> Unit = EnsureNeverCalled(),
private val onUnlockedLambda: () -> Unit = EnsureNeverCalled(),
) : DeviceUnlockEntryPoint.Callback {
override fun onCancel() = onCancelLambda()
override fun onUnlock() = onUnlockedLambda()
}
@@ -16,6 +16,9 @@ interface LinkMobileHandler {
sealed interface LinkMobileStep {
data object Uninitialized : LinkMobileStep
// Internal application step, for the UI
data object CreatingQrCode : LinkMobileStep
data object Starting : LinkMobileStep
data class QrReady(val data: String) : LinkMobileStep
data object QrRotating : LinkMobileStep
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7790ba46051ba39863f057bc53c937f513f91b6094094b2b449794d7de3aa875
size 32833
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bc7032e0d69a44d8ab2d3187183eb77cc28d7a5669fd7e56ee99d4f90ee05d62
size 33137
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:25ca16b3574c5cf850e06099312d4af294561c8c9833742a0b3902bc3b35cb39
size 20669
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e58621f378a1abac78c5bdfb6a31675c1f762f53434f28b869ac8c7b6f9ad8ad
size 20109
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a60781d3bc38ed6c2ed830464f447d509f88a9767b81dea3acbd29ea21b5c0f6
size 36212
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5d115c0f6d4c705dce1062aa2dbb026980df5efed8d8b2500774e6f75cbe366f
size 34987
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:de82970da241172b94f822f8792e6fc721dcfc7f0f67e3523962e253e9750884
size 255933
oid sha256:1885bbe8b0ea497ac20d7bcdd0c6a93ba0533e898378d35e1cafdc8c9924fd56
size 279984
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1ca032575e4ebd75a2ebc5a4cb9f966e48cdfa56f4ff42d6d9309fa3577a1adb
size 310609
oid sha256:fe6cb72de5079be2ac02ec39f74bac0d20492cd6f5509b71ad9cfc4cc52955d3
size 326468
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3274314ab9e39c23a6b9d66adf80ac8bb7b364ce60a300f96582374a18bc821f
size 54590
oid sha256:3dbb8ef39b6a7a0c2f576d8bd101aa97c89da696f23e6d5836846494471c01a1
size 55799
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f1dd0da147212e13a6c0ad5ad8f85ce55666c444076c238ea27eba5605cfab1f
size 56909
oid sha256:5e27c7c51fe07cca51b92a601aa9c58deab609bfa5e82f8f6f88eaab6ae59ec3
size 64307
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:65f9ee083a73669a0d3f9d88141a610ea53f3c39e89d8f706d414b04555b4764
size 59321
oid sha256:085963fcb425147d35a9ad947a7a3e4ba4712bd3d2f32e93099ff15cffe83b88
size 64761
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7a3667390d9d88894bbdd5d72b0b714b29c9ff160653e310e97b0da87b6ba8b0
size 54724
oid sha256:8a94a4cc176b5d52ef9b21339749324adeef9857b6cdaa2ae973903e2408662a
size 55904
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6f7f7415abbe51858f938e16ad03c6aab983fb3d5e2a53a028bae3c031446646
size 67848
oid sha256:28512b0fda0b71b98eab03cee15c9d3163825b1a3545670cfa277ddd5434c87a
size 58417
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6ed6a5f1a2055e19a6b8fb0af4738845bd2184f755c323022c52154cdfa07e05
size 60909
oid sha256:8a8f5aaaa7ebb0e61b07867590d99a8791c5d04cad1ce0f334d23dce6f26c6e6
size 67276
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f77d8243c23d3f37dff86dec53671cbde9fafe1314f6e6b445abdfe47d751ad6
size 56187
oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
size 3657
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:87193a7c9eb9d40234970e5f5be02e80f2b3ffe91e2c9ca6afd58b8bccdd5911
size 55075
oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
size 3657
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
size 3657
oid sha256:49f0bb16d3d57b59242e704e3dd4e8885c59721a42129837ff6a4f0d7d02de11
size 54433
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8ebee4aec3dbab6e1bb05fda7c5e423f0e931560743d0f0ccbac6b70fa9ad808
size 52921
oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
size 3657
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4420dfe9f6f95411093eb1c17825a389a31d7a5d5ef146129e5872cdd5d87aff
size 295295
oid sha256:86eac241e9084cc830ff13d231908da2eb0dff246aa5cac64dddaac68a5d5f13
size 295298
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:578a3707102c7754f607c3a14f94f32cafa30ee570db174386533c484cb4773c
size 295031
oid sha256:7076c9a44a433cd9eeca536365ae262d39b4ad99a8f854faadec272cb1b82031
size 294992
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f358da2b9b0521c14e525bcb2bd41c29c96aacaf5f007546e6d362bde2ee3fb
size 612526
oid sha256:d6441c572f64d99bc119d649e5e6422fe516b9d097f310ccbd99a9ddded9609f
size 612494
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a709de55bc68cf12064ebdbde92c0a99bef3252958d7966540458d253e8c12c2
size 611422
oid sha256:5a867dda82242d2b13bd4bc84a9f1c9b8b7b99c030e19a6cce39b2f828b517f3
size 611488
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:54e71f45db31e8664c42f8c05feb91ae839589e00cb89d12088c15c85fb9f25f
size 607824
oid sha256:e3bb005a88e9a4d2bb850478ee8ac2c4f31d32625bd6d2abf455fade461cd399
size 607806
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8bb505fe6d67669339c9366f8ee5bc75974e223837d95e76e637455a51c18711
size 606787
oid sha256:4a2a57f5547f98b9ac2d8bf7195a1e4b49e03834e8866c6cc76660a6a3b16e74
size 606823
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ff839731834d7d0c94fa9a2c412014ddbaeaa053c5af1c8cbd00a3f3a583f364
size 379511
oid sha256:94f89a37549a3f198442db467bc7d8539f0b5649d97927bfe731f090d6dfb03d
size 379505
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d0e170b4bea262de612bf3374d475fcd25470ff91b7554d20809c51857a78cec
size 377963
oid sha256:04ea6c2947a21d3d4a3d43e9c4750c4f2a49056536a4da3f34c95df8353e9de3
size 377990
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1a94d5198e27338675c0e15df5874ba3272ee315b8d77291dc7ccc7c754ed1f4
size 365082
oid sha256:b8bb5a6de6601da8d7fe59f110a054577e2e25dae4bb449f60c04e7d7a1b9551
size 365073
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8344427a0fe87cbf0883ff53a9ee4dcf90976a7011f6618d85961df48b2c53a9
size 370228
oid sha256:76e5ae44362792d570cd82f3910c8bd0acde509b8418c5892e0385e7d636f042
size 370223
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:60b9f2b920cd2a2a414805d1f0f77925232d9ae60bd607fa18d4395cda2dd73d
size 363291
oid sha256:00fe1e676086766a3eac3e4457eff52c989789fc5d8d0df2624c019ed25ce9af
size 363322
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c00e504593d8eb61b598f05f46ee4d591757975a80c0b4227b240847d2cd4868
size 368510
oid sha256:2f0d24b8011564ee7a8b2f8c2e6a24eda668ee42d451d862f884acb7926aa06c
size 368529
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:36d7cf2510d546d2b330b1d1b1674ce58df0558c547e443794a1e7b2222add87
size 347897
oid sha256:7c609a633ab23eae5729ecb57a17a385bac65e81cbf0180c01e0847f1028039e
size 347890
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a4ac79f3b8fb4e231244bd96b244a4f2526d6ed28d558f9d50734d83bbb74992
size 363909
oid sha256:991deacdfb7bc371a2df960a2364ae3b2187e588aa5afe7f39409e367040dd9c
size 363893
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:787151f8f242a8794a804bf14831f6ca680814d617e85feb3652103e8a0f20cd
size 346565
oid sha256:e30686c7c037ef102717e0316543faf4d3ed2671c21eca96a667150daab380d6
size 346614
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c1e15400fa2c5ee6087316501445c551a3932b65e16de31e68acc5efbd16a559
size 361603
oid sha256:2cd357a1fbaa425eca95587bff2f768a45e773f983f75664875653ba1900e534
size 361625
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f4431e2d9f30123949ea5676ce60f642234adcf9b5a33f2acc3f80ebf6609a66
size 369730
oid sha256:ef2bf449671b652cb152b3baab92ab100eab77a54229ab49daeab156f2f484d0
size 369724
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:273351cc9c533b2424859a0de00a8a3786628abc3a88175705c4205dedf7e9aa
size 354737
oid sha256:f7a6302320bd1e4258e03487539bf46b0b12781dfc8a22b3f9f267017a6146fa
size 354751
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:41a0e404dea7f52359b24b3ec3926b1405429b165cfed44c63b3922ea6d3cb46
size 368114
oid sha256:4c410e853089cc2c4a2c48a00dd9a8babc8095aed72bb4625057444710559c6c
size 368129
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6ca8cf82aa8ab1bdad56a27584b27789402aeb7ad8fadd038280a376fb75a72c
size 345094
oid sha256:1a92f8af94682facac66d4aacb6e49f345561c15be2f8b689041b78aeaaae831
size 345062
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a9509411777f342261cab58b92a022b3c9f8f0f7a9e4d0527120a91976575f0
size 357733
oid sha256:02ecb903124273e24151ea2088f6b99144f7dcadba00a582b6210dd23835b5c9
size 357743
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dce23463c335d01eb67293cfcede5d85e96ab2ba035cc9a188d42e8e6866bc63
size 356831
oid sha256:f0c6dafaa41849af00d5097a85bd88e3a054a0a9e41c0466a08e6ff8dd6102b2
size 356857
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a11044fe90227f1f316a1ade8587e545bb620e11cca76865fc2eb83cdb817fc5
size 364655
oid sha256:588967869cbd25ed8b82aff5618175660a8a9e45a5a861b1c7867cf1913f575b
size 364646
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:426d1c844a8c88281c5fa4c61916d93278fdfd38f8cdf2dc54bf1a821f2de3eb
size 397167
oid sha256:2528df873713755f542f61cd794080929586c1bd5f316c0378ce9e837c21d50b
size 397145
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a6cd94664f89bdad2a9761ea8b20acb83fed3ed7cb5be41af8fe4852643c0bfc
size 356175
oid sha256:a2bd77c08be705c66225f16c29fd6826decd92b41922d0f8ffc0d1a63d8c59d2
size 356193
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ffa68c8398d789444151855496e5df4ffa03218a383db6ea46fd4f979829b823
size 355831
oid sha256:9daeee1aaa04247da3c8bb5aaae9947aab1ed2d2057995e53d6ebdb63e5cd356
size 355848
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a00bd766a00b7e7c6615fc53bf469b3ca5056a6c514c096431ae38d99d604306
size 364845
oid sha256:34152c742abc486a48234e22101833bc163cf11979890d06aac41e68e3c404d0
size 364846
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ec1b0244dab22414c6719b3309426e13128d0c70fb67dc66cd8a60d42521fd0
size 355411
oid sha256:7b23d3a37774283067a8919b8c332b1bb5a79e90dceb7216b484b985491282e5
size 355389
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a1df8d22766981fc76d6d767dc3f40d98cadc7c51be20417b067b91af604232b
size 367884
oid sha256:5c3d87862c6d7fc88d0f23cd54c5b9f77028992fdd7c282a195b964dd17e85ac
size 367855
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fe0daf9161bc2da194992a2fc9505bf2009c5dbed78cdb35d2eb7ed1380a7df5
size 353037
oid sha256:f7d9b5fc0ff5d7828ad6f4dc0c9bd9520b4920553da97ed7b4e030ee844c0208
size 353054
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9a85db2f5958db867578663533f204b2e803372d470da890fd8056ffed0cf46c
size 366254
oid sha256:6a92bcf5885322e11a706071969fb674b1f6e60e021af153aaa125baa4a602ae
size 366285
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4bee12a6bdb18fea5ac6997671bfcb903296da7278cf69b2456330066ca31dfb
size 342870
oid sha256:54372845e922d047408dd960d048b9b5eae2263d589117d8e7d197741aa34f32
size 342827
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:af4a48ee80535f2c0a226c4ffa4344054fb63b7b0a88ea2c030a4eb8dd5e9644
size 356078
oid sha256:8e82011a72f36889c53ab61ed2b9947c7fb5f868c9b7010904fc10f4ca587082
size 356082
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9e2d8caf5aaae7d3406c703288e491f328552f0acd18e56afd5265b4ceaf1cd1
size 355272
oid sha256:6524cc87257cc9d3f135904f4e14594b56df695e71fe576418d2319c472d710b
size 355285
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:66d4b6ea4a697735bbb0fac936b89cde99c5077973ba828894bf50b5dae233b3
size 362848
oid sha256:93c2df04d65f6078ce3f7067fbae8a7317ec42ed0fc013dee962a3953269f3b7
size 362813
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:217ac95bf476c53d2dc6d3d0143b21312bf38b8ab86c8dfd4f9360582b584f9d
size 395476
oid sha256:e642229c58169775fe702769a306bbef22698516ae6f7ac75a761213c3f195fa
size 395446
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5fae87094ea13b14e2dc7c16ca96e4f8096ff39e962799468fb4ead4c666620f
size 354381
oid sha256:08057fdc1e1184720b438ddd2a8753f00791baf2456cc30b45de8e6bdeb57e8e
size 354404
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dabad9dcd5b8f0d4bb964a0187ad7c94b023b04552dacd29a077daddfd48d550
size 354052
oid sha256:5ed24d190f10c079f8cf176404372d40eb25cc78ce595a22febc540315057b86
size 354075
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:545c9e8933e420cc3f8fbba0fbd5162c946c65a20721faeb77c568596ca720cd
size 363045
oid sha256:66aba7b4271a990fac85b0bbc7cbdc371fa1e38a4da5bb4e2f62a80b21cb3a8f
size 363012
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:756f0cff90ddd61adce2f467c0384d74d430f6d51c091e4980e45bcc9ee6f0fd
size 353705
oid sha256:e614f4a8657637c584a283d69124236d00fe1251c2b8d739c5688a57a6fd152e
size 353693
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0dd30568e628750f922a87c2df1fd7751e9d899b9163253c9f2a0c7b03e4a869
size 56247
oid sha256:179b30927c3db3963bef12243ae69908ad0b99275906197abd02c110f012213f
size 57267
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a94d9346e04d59f90cfea7ac2ef4d71003fc7e2eef8cf8627257f7cf0aea6457
size 50445
oid sha256:dc02a7c7b38753d9509b3cb0fb2eb0e29b6d6c79b8471ba4dc7dd5b81b13d15b
size 50892
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b7fdd2e68114457368c5d19fe117b2d5a86a02ca475925c9fae0269ff92f5144
size 66312
oid sha256:44fd4c046c89628b32d85c7732ef2ed6a50b3e02928bd2409b6bfbb0c6aa3fd9
size 59064
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f966bd2eade0180013c2c019963ad9f89c04f16cadcc04954bf3a4427c854635
size 40002
oid sha256:d1a28720d554bc4698eb52e27e7c6228ac9c14b3703a8c139c3113f8b8fa284e
size 40355

Some files were not shown because too many files have changed in this diff Show More