From 969880e35a86a94f8bb3099e67653ce06e4b6f07 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Jun 2026 14:07:45 +0200 Subject: [PATCH] [Link new device] Unlock with device credentials or application PIN code --- features/linknewdevice/impl/build.gradle.kts | 2 + .../impl/LinkNewDeviceFlowNode.kt | 37 ++++- .../impl/LinkNewMobileHandler.kt | 7 +- .../impl/screens/root/LinkDeviceType.kt | 13 ++ .../screens/root/LinkNewDeviceRootEvent.kt | 1 - .../screens/root/LinkNewDeviceRootNode.kt | 4 +- .../root/LinkNewDeviceRootPresenter.kt | 14 +- .../screens/root/LinkNewDeviceRootView.kt | 70 ++++---- .../DefaultLinkNewDeviceEntryPointTest.kt | 2 + .../number/EnterNumberPresenterTest.kt | 7 +- .../root/LinkNewDeviceRootPresenterTest.kt | 35 +++- .../screens/root/LinkNewDeviceRootViewTest.kt | 30 ++-- .../lockscreen/api/DeviceUnlockEntryPoint.kt | 35 ++++ features/lockscreen/impl/build.gradle.kts | 1 + .../impl/DefaultDeviceUnlockEntryPoint.kt | 35 ++++ .../impl/biometric/BiometricAuthenticator.kt | 4 +- .../BiometricAuthenticatorManager.kt | 11 ++ .../DefaultBiometricAuthenticatorManager.kt | 56 ++++++- .../impl/device/DeviceUnlockCallbackHolder.kt | 30 ++++ .../impl/device/DeviceUnlockEvent.kt | 12 ++ .../impl/device/DeviceUnlockNode.kt | 49 ++++++ .../impl/device/DeviceUnlockPresenter.kt | 88 ++++++++++ .../impl/device/DeviceUnlockState.kt | 13 ++ .../settings/LockScreenSettingsFlowNode.kt | 9 +- .../lockscreen/impl/unlock/PinUnlockHelper.kt | 12 +- .../lockscreen/impl/unlock/PinUnlockNode.kt | 15 +- .../impl/unlock/PinUnlockPresenter.kt | 15 +- .../lockscreen/impl/unlock/PinUnlockState.kt | 1 + .../impl/unlock/PinUnlockStateProvider.kt | 11 ++ .../lockscreen/impl/unlock/PinUnlockView.kt | 55 +++++-- .../impl/unlock/activity/PinUnlockActivity.kt | 6 +- .../biometric/FakeBiometricAuthenticator.kt | 14 +- .../FakeBiometricAuthenticatorManager.kt | 14 +- .../impl/device/DeviceUnlockPresenterTest.kt | 152 ++++++++++++++++++ .../impl/unlock/PinUnlockPresenterTest.kt | 27 ++++ .../test/FakeDeviceUnlockCallback.kt | 19 +++ .../test/FakeDeviceUnlockEntryPoint.kt | 22 +++ .../api/linknewdevice/LinkMobileHandler.kt | 3 + 38 files changed, 828 insertions(+), 103 deletions(-) create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkDeviceType.kt create mode 100644 features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/DeviceUnlockEntryPoint.kt create mode 100644 features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultDeviceUnlockEntryPoint.kt create mode 100644 features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockCallbackHolder.kt create mode 100644 features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockEvent.kt create mode 100644 features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockNode.kt create mode 100644 features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockPresenter.kt create mode 100644 features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockState.kt create mode 100644 features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockPresenterTest.kt create mode 100644 features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeDeviceUnlockCallback.kt create mode 100644 features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeDeviceUnlockEntryPoint.kt diff --git a/features/linknewdevice/impl/build.gradle.kts b/features/linknewdevice/impl/build.gradle.kts index adbec91e6a..4659a2d2b8 100644 --- a/features/linknewdevice/impl/build.gradle.kts +++ b/features/linknewdevice/impl/build.gradle.kts @@ -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) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt index 61645ead9d..8f434384c4 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt @@ -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( 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 onUnlocked() = when (type) { + LinkDeviceType.Mobile -> { + linkNewMobileHandler.reset() + linkNewMobileHandler.createAndStartNewHandler() + } + LinkDeviceType.Desktop -> { + linkNewDesktopHandler.reset() + backstack.push(NavTarget.DesktopNotice) + } + } + } + deviceUnlockEntryPoint.requestUnlock(callback) } } createNode(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) } } diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt index 18d67f577a..39c9c6fef3 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt @@ -43,12 +43,15 @@ class LinkNewMobileHandler( val stepFlow: StateFlow 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() { diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkDeviceType.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkDeviceType.kt new file mode 100644 index 0000000000..6b26fe5e79 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkDeviceType.kt @@ -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, +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt index 8ce6af90b0..97047fea9d 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt @@ -8,6 +8,5 @@ package io.element.android.features.linknewdevice.impl.screens.root sealed interface LinkNewDeviceRootEvent { - data object LinkMobileDevice : LinkNewDeviceRootEvent data object CloseDialog : LinkNewDeviceRootEvent } diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt index f43ffa64df..dc74f1287f 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt @@ -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, ) } } diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt index a17ed88fd3..af3ede0c27 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt @@ -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() } diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt index d9249d3e89..5778abfb2c 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt @@ -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 = { }, ) } diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt index 1b2af8f4c3..a198bc27d9 100644 --- a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt @@ -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 { diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt index fe2e11e95c..b3602c81dc 100644 --- a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt @@ -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() diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt index 88a68786f3..d2216c2737 100644 --- a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt @@ -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() } } diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt index bceb8753b2..11dc9aedf6 100644 --- a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt @@ -18,9 +18,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 org.junit.Test import org.junit.runner.RunWith @@ -44,29 +46,31 @@ class LinkNewDeviceRootViewTest { @Test fun `link desktop button clicked - calls the expected callback`() = runAndroidComposeUiTest { val eventRecorder = EventsRecorder(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() - setLinkNewDeviceRootView( - state = aLinkNewDeviceRootState( - isSupported = AsyncData.Success(true), - eventSink = eventRecorder, + fun `link mobile button clicked - calls the expected callback`() = runAndroidComposeUiTest { + val eventRecorder = EventsRecorder(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 @@ -87,13 +91,13 @@ class LinkNewDeviceRootViewTest { private fun AndroidComposeUiTest.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, ) } } diff --git a/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/DeviceUnlockEntryPoint.kt b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/DeviceUnlockEntryPoint.kt new file mode 100644 index 0000000000..2953688a84 --- /dev/null +++ b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/DeviceUnlockEntryPoint.kt @@ -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 onUnlocked() + } +} diff --git a/features/lockscreen/impl/build.gradle.kts b/features/lockscreen/impl/build.gradle.kts index 63aec590de..414b4b5909 100644 --- a/features/lockscreen/impl/build.gradle.kts +++ b/features/lockscreen/impl/build.gradle.kts @@ -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) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultDeviceUnlockEntryPoint.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultDeviceUnlockEntryPoint.kt new file mode 100644 index 0000000000..f4b78968f8 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultDeviceUnlockEntryPoint.kt @@ -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( + buildContext = buildContext, + ) + } + + override fun requestUnlock(callback: DeviceUnlockEntryPoint.Callback) { + deviceUnlockCallbackHolder.requestUnlock(callback) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt index d18d9b73b7..097049c97a 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt @@ -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 { diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticatorManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticatorManager.kt index 2ea0ed7d05..6234e65284 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticatorManager.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticatorManager.kt @@ -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. */ diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt index 117323ec0a..27408c919a 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt @@ -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, diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockCallbackHolder.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockCallbackHolder.kt new file mode 100644 index 0000000000..cf8b23d3f6 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockCallbackHolder.kt @@ -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(null) + val deviceUnlockCallback: StateFlow = _deviceUnlockCallback + + fun requestUnlock(callback: DeviceUnlockEntryPoint.Callback) { + _deviceUnlockCallback.value = callback + } + + fun onDone() { + _deviceUnlockCallback.value = null + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockEvent.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockEvent.kt new file mode 100644 index 0000000000..8b894875cb --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockEvent.kt @@ -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 +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockNode.kt new file mode 100644 index 0000000000..2df706abe7 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockNode.kt @@ -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, + 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, + ) + } + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockPresenter.kt new file mode 100644 index 0000000000..4cc2bb8fd1 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockPresenter.kt @@ -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 { + @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, + ) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockState.kt new file mode 100644 index 0000000000..48d2181605 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockState.kt @@ -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, +) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt index 0bd26729d5..a6e2c1265c 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt @@ -103,8 +103,15 @@ class LockScreenSettingsFlowNode( override fun onUnlock() { backstack.newRoot(NavTarget.Settings) } + + override fun onCancel() { + // Should not happen in this context. + } } - createNode(buildContext, plugins = listOf(callback)) + val inputs = PinUnlockNode.Inputs( + forDeviceUnlock = false, + ) + createNode(buildContext, plugins = listOf(callback, inputs)) } NavTarget.SetupPin -> { createNode(buildContext) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockHelper.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockHelper.kt index 2a475814c9..10de1f23a5 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockHelper.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockHelper.kt @@ -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) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt index 459e165e00..47e7715ea5 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt @@ -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, - 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 ) } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt index c8dd8916f9..65afba669d 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt @@ -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 { + @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, diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt index 037aa87dec..1aae6afa51 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt @@ -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, val showWrongPinTitle: Boolean, val remainingAttempts: AsyncData, diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt index 1b8166a8ac..aef2c057a2 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt @@ -38,10 +38,20 @@ open class PinUnlockStateProvider : PreviewParameterProvider { showWrongPinTitle = true, isUnlocked = true, ), + aPinUnlockState(canNavigateBack = true), + ) +} + +open class PinUnlockStateCompactProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aPinUnlockState(), + aPinUnlockState(canNavigateBack = true) ) } fun aPinUnlockState( + canNavigateBack: Boolean = false, pinEntry: AsyncData = AsyncData.Success(PinEntry.createEmpty(4)), remainingAttempts: AsyncData = AsyncData.Success(3), showWrongPinTitle: Boolean = false, @@ -51,6 +61,7 @@ fun aPinUnlockState( isUnlocked: Boolean = false, signOutAction: AsyncAction = AsyncAction.Uninitialized, ) = PinUnlockState( + canNavigateBack = canNavigateBack, pinEntry = pinEntry, showWrongPinTitle = showWrongPinTitle, remainingAttempts = remainingAttempts, diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt index 6749697b64..0292b29a0f 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt @@ -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 = {}, ) } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt index 34adfeca99..613b1cf79e 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt @@ -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().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 + }, ) } } diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticator.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticator.kt index 63729f941a..0fb2e9df8b 100644 --- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticator.kt +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticator.kt @@ -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() + } } diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticatorManager.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticatorManager.kt index 0ae8552334..1a70f25d30 100644 --- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticatorManager.kt +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticatorManager.kt @@ -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() } } diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockPresenterTest.kt new file mode 100644 index 0000000000..f94c9b4251 --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockPresenterTest.kt @@ -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 { } + val authenticateLambda = lambdaRecorder { + 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 { } + 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 { } + 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, + ) + } +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt index fa7d05b5cb..b20a99d27f 100644 --- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt @@ -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.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, diff --git a/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeDeviceUnlockCallback.kt b/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeDeviceUnlockCallback.kt new file mode 100644 index 0000000000..f960733902 --- /dev/null +++ b/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeDeviceUnlockCallback.kt @@ -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 FakeDeviceUnlockCallback( + private val onCancelLambda: () -> Unit = EnsureNeverCalled(), + private val onUnlockedLambda: () -> Unit = EnsureNeverCalled(), +) : DeviceUnlockEntryPoint.Callback { + override fun onCancel() = onCancelLambda() + override fun onUnlocked() = onUnlockedLambda() +} diff --git a/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeDeviceUnlockEntryPoint.kt b/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeDeviceUnlockEntryPoint.kt new file mode 100644 index 0000000000..8278fc882f --- /dev/null +++ b/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeDeviceUnlockEntryPoint.kt @@ -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() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt index 1947729c7f..eb2d49b5b0 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt @@ -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