[Link new device] Unlock with device credentials or application PIN code

This commit is contained in:
Benoit Marty
2026-06-05 14:07:45 +02:00
parent 6af3870547
commit 969880e35a
38 changed files with 828 additions and 103 deletions
@@ -30,6 +30,7 @@ dependencies {
// TODO Cleanup // TODO Cleanup
implementation(projects.appconfig) implementation(projects.appconfig)
implementation(projects.features.enterprise.api) implementation(projects.features.enterprise.api)
implementation(projects.features.lockscreen.api)
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
@@ -53,6 +54,7 @@ dependencies {
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
testImplementation(projects.features.linknewdevice.test) testImplementation(projects.features.linknewdevice.test)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.features.enterprise.test) testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
@@ -13,8 +13,10 @@ import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.PermanentChild
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext 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.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack 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.error.ErrorScreenType
import io.element.android.features.linknewdevice.impl.screens.number.EnterNumberNode 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.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.root.LinkNewDeviceRootNode
import io.element.android.features.linknewdevice.impl.screens.scan.ScanQrCodeNode 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.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
@@ -67,11 +71,16 @@ class LinkNewDeviceFlowNode(
private val linkNewMobileHandler: LinkNewMobileHandler, private val linkNewMobileHandler: LinkNewMobileHandler,
private val linkNewDesktopHandler: LinkNewDesktopHandler, private val linkNewDesktopHandler: LinkNewDesktopHandler,
private val sessionEnterpriseService: SessionEnterpriseService, private val sessionEnterpriseService: SessionEnterpriseService,
private val deviceUnlockEntryPoint: DeviceUnlockEntryPoint,
) : BaseFlowNode<LinkNewDeviceFlowNode.NavTarget>( ) : BaseFlowNode<LinkNewDeviceFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
permanentNavModel = PermanentNavModel(
navTargets = setOf(NavTarget.LockScreen),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
) { ) {
@@ -124,6 +133,9 @@ class LinkNewDeviceFlowNode(
@Parcelize @Parcelize
data object DesktopScanQrCode : NavTarget data object DesktopScanQrCode : NavTarget
@Parcelize
data object LockScreen : NavTarget
@Parcelize @Parcelize
data class Error( data class Error(
val errorScreenType: ErrorScreenType, val errorScreenType: ErrorScreenType,
@@ -137,6 +149,9 @@ class LinkNewDeviceFlowNode(
Timber.tag(tag.value).d("step: ${linkMobileStep::class.java.simpleName}") Timber.tag(tag.value).d("step: ${linkMobileStep::class.java.simpleName}")
when (linkMobileStep) { when (linkMobileStep) {
LinkMobileStep.Uninitialized -> Unit LinkMobileStep.Uninitialized -> Unit
LinkMobileStep.CreatingQrCode -> {
// This step is handled in LinkNewDeviceRootPresenter
}
LinkMobileStep.Done -> { LinkMobileStep.Done -> {
callback.onDone() callback.onDone()
} }
@@ -224,13 +239,28 @@ class LinkNewDeviceFlowNode(
callback.onDone() callback.onDone()
} }
override fun linkDesktopDevice() { 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() linkNewDesktopHandler.reset()
backstack.push(NavTarget.DesktopNotice) backstack.push(NavTarget.DesktopNotice)
} }
} }
}
deviceUnlockEntryPoint.requestUnlock(callback)
}
}
createNode<LinkNewDeviceRootNode>(buildContext, listOf(callback)) createNode<LinkNewDeviceRootNode>(buildContext, listOf(callback))
} }
is NavTarget.LockScreen -> {
deviceUnlockEntryPoint.createNode(this, buildContext)
}
NavTarget.DesktopNotice -> { NavTarget.DesktopNotice -> {
val callback = object : DesktopNoticeNode.Callback { val callback = object : DesktopNoticeNode.Callback {
override fun navigateBack() { override fun navigateBack() {
@@ -324,5 +354,6 @@ class LinkNewDeviceFlowNode(
} }
} }
BackstackView() BackstackView()
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockScreen)
} }
} }
@@ -43,12 +43,15 @@ class LinkNewMobileHandler(
val stepFlow: StateFlow<LinkMobileStep> val stepFlow: StateFlow<LinkMobileStep>
get() = linkMobileStepFlow.asStateFlow() get() = linkMobileStepFlow.asStateFlow()
fun createAndStartNewHandler() { fun createAndStartNewHandler(forRotating: Boolean = false) {
Timber.tag(loggerTag.value).d("createAndStartNewHandler()") Timber.tag(loggerTag.value).d("createAndStartNewHandler()")
currentJob?.cancel() currentJob?.cancel()
handler = matrixClient.createLinkMobileHandler().getOrNull() handler = matrixClient.createLinkMobileHandler().getOrNull()
handler?.let { h -> handler?.let { h ->
currentJob = sessionScope.launch { currentJob = sessionScope.launch {
if (!forRotating) {
linkMobileStepFlow.emit(LinkMobileStep.CreatingQrCode)
}
h.linkMobileStep h.linkMobileStep
.onEach { .onEach {
linkMobileStepFlow.emit(it) linkMobileStepFlow.emit(it)
@@ -68,7 +71,7 @@ class LinkNewMobileHandler(
} }
fun rotateQrCode() { fun rotateQrCode() {
createAndStartNewHandler() createAndStartNewHandler(forRotating = true)
} }
fun onTooManyRotation() { 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 package io.element.android.features.linknewdevice.impl.screens.root
sealed interface LinkNewDeviceRootEvent { sealed interface LinkNewDeviceRootEvent {
data object LinkMobileDevice : LinkNewDeviceRootEvent
data object CloseDialog : LinkNewDeviceRootEvent data object CloseDialog : LinkNewDeviceRootEvent
} }
@@ -27,7 +27,7 @@ class LinkNewDeviceRootNode(
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun onDone() fun onDone()
fun linkDesktopDevice() fun onUnlockDevice(type: LinkDeviceType)
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@@ -39,7 +39,7 @@ class LinkNewDeviceRootNode(
state = state, state = state,
modifier = modifier, modifier = modifier,
onBackClick = callback::onDone, onBackClick = callback::onDone,
onLinkDesktopDeviceClick = callback::linkDesktopDevice, onUnlockDevice = callback::onUnlockDevice,
) )
} }
} }
@@ -50,8 +50,14 @@ class LinkNewDeviceRootPresenter(
LaunchedEffect(step) { LaunchedEffect(step) {
when (val finalStep = step) { when (val finalStep = step) {
is LinkMobileStep.Uninitialized -> { is LinkMobileStep.Uninitialized -> {
// Ignore this step when loading QrCode
if (!qrCodeData.isLoading()) {
qrCodeData = AsyncData.Uninitialized qrCodeData = AsyncData.Uninitialized
} }
}
is LinkMobileStep.CreatingQrCode -> {
qrCodeData = AsyncData.Loading()
}
is LinkMobileStep.QrReady -> { is LinkMobileStep.QrReady -> {
qrCodeData = AsyncData.Success(Unit) qrCodeData = AsyncData.Success(Unit)
} }
@@ -64,12 +70,6 @@ class LinkNewDeviceRootPresenter(
fun handleEvent(event: LinkNewDeviceRootEvent) { fun handleEvent(event: LinkNewDeviceRootEvent) {
when (event) { when (event) {
LinkNewDeviceRootEvent.LinkMobileDevice -> coroutineScope.launch {
qrCodeData = AsyncData.Loading()
// Wait for the QrCode to be ready
linkNewMobileHandler.reset()
linkNewMobileHandler.createAndStartNewHandler()
}
LinkNewDeviceRootEvent.CloseDialog -> coroutineScope.launch { LinkNewDeviceRootEvent.CloseDialog -> coroutineScope.launch {
linkNewMobileHandler.reset() linkNewMobileHandler.reset()
} }
@@ -41,7 +41,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun LinkNewDeviceRootView( fun LinkNewDeviceRootView(
state: LinkNewDeviceRootState, state: LinkNewDeviceRootState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onLinkDesktopDeviceClick: () -> Unit, onUnlockDevice: (type: LinkDeviceType) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val (title, subtitle, iconStyle) = if (state.isSupported.dataOrNull() == false) { val (title, subtitle, iconStyle) = if (state.isSupported.dataOrNull() == false) {
@@ -57,6 +57,7 @@ fun LinkNewDeviceRootView(
BigIcon.Style.Default(CompoundIcons.Devices()) BigIcon.Style.Default(CompoundIcons.Devices())
) )
} }
FlowStepPage( FlowStepPage(
onBackClick = onBackClick, onBackClick = onBackClick,
title = title, title = title,
@@ -83,40 +84,37 @@ fun LinkNewDeviceRootView(
} }
is AsyncData.Success -> { is AsyncData.Success -> {
if (state.isSupported.data) { if (state.isSupported.data) {
when (state.qrCodeData) { val canClick = state.qrCodeData is AsyncData.Uninitialized
AsyncData.Uninitialized, val isLoading = state.qrCodeData is AsyncData.Loading || state.qrCodeData is AsyncData.Success
is AsyncData.Failure -> {
Button( Button(
onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) }, onClick = {
text = stringResource(id = R.string.screen_link_new_device_root_mobile_device), 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(), modifier = Modifier.fillMaxWidth(),
leadingIcon = IconSource.Vector(CompoundIcons.Mobile()), leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
) )
Button( Button(
onClick = onLinkDesktopDeviceClick, onClick = {
if (canClick) {
onUnlockDevice(LinkDeviceType.Desktop)
}
},
text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer), text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
enabled = !isLoading,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = IconSource.Vector(CompoundIcons.Computer()), 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()),
)
}
}
} else { } else {
Button( Button(
onClick = onBackClick, onClick = onBackClick,
@@ -147,6 +145,6 @@ internal fun LinkNewDeviceRootViewPreview(
LinkNewDeviceRootView( LinkNewDeviceRootView(
state = state, state = state,
onBackClick = { }, onBackClick = { },
onLinkDesktopDeviceClick = { }, onUnlockDevice = { },
) )
} }
@@ -13,6 +13,7 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint 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.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
@@ -39,6 +40,7 @@ class DefaultLinkNewDeviceEntryPointTest {
linkNewMobileHandler = LinkNewMobileHandler(client), linkNewMobileHandler = LinkNewMobileHandler(client),
linkNewDesktopHandler = LinkNewDesktopHandler(client), linkNewDesktopHandler = LinkNewDesktopHandler(client),
sessionEnterpriseService = FakeSessionEnterpriseService(), sessionEnterpriseService = FakeSessionEnterpriseService(),
deviceUnlockEntryPoint = FakeDeviceUnlockEntryPoint(),
) )
} }
val callback: LinkNewDeviceEntryPoint.Callback = object : LinkNewDeviceEntryPoint.Callback { val callback: LinkNewDeviceEntryPoint.Callback = object : LinkNewDeviceEntryPoint.Callback {
@@ -88,6 +88,7 @@ class EnterNumberPresenterTest {
navigator = navigator, navigator = navigator,
linkNewMobileHandler = linkNewMobileHandler, linkNewMobileHandler = linkNewMobileHandler,
).test { ).test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
linkMobileHandler.emitStep( linkMobileHandler.emitStep(
LinkMobileStep.QrScanned(checkCodeSender) LinkMobileStep.QrScanned(checkCodeSender)
@@ -96,7 +97,7 @@ class EnterNumberPresenterTest {
initialState.eventSink(EnterNumberEvent.UpdateNumber("88")) initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
skipItems(1) skipItems(1)
initialState.eventSink(EnterNumberEvent.Continue) initialState.eventSink(EnterNumberEvent.Continue)
skipItems(1) skipItems(2)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.sendingCode.isLoading()).isTrue() assertThat(finalState.sendingCode.isLoading()).isTrue()
advanceUntilIdle() advanceUntilIdle()
@@ -130,6 +131,7 @@ class EnterNumberPresenterTest {
LinkMobileStep.QrScanned(checkCodeSender) LinkMobileStep.QrScanned(checkCodeSender)
) )
runCurrent() runCurrent()
skipItems(1)
initialState.eventSink(EnterNumberEvent.UpdateNumber("88")) initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
skipItems(1) skipItems(1)
initialState.eventSink(EnterNumberEvent.Continue) initialState.eventSink(EnterNumberEvent.Continue)
@@ -163,6 +165,7 @@ class EnterNumberPresenterTest {
createPresenter( createPresenter(
linkNewMobileHandler = linkNewMobileHandler, linkNewMobileHandler = linkNewMobileHandler,
).test { ).test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
linkMobileHandler.emitStep( linkMobileHandler.emitStep(
LinkMobileStep.QrScanned(checkCodeSender) LinkMobileStep.QrScanned(checkCodeSender)
@@ -171,7 +174,7 @@ class EnterNumberPresenterTest {
initialState.eventSink(EnterNumberEvent.UpdateNumber("88")) initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
skipItems(1) skipItems(1)
initialState.eventSink(EnterNumberEvent.Continue) initialState.eventSink(EnterNumberEvent.Continue)
skipItems(1) skipItems(2)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.sendingCode.isLoading()).isTrue() assertThat(loadingState.sendingCode.isLoading()).isTrue()
expectNoEvents() expectNoEvents()
@@ -75,15 +75,48 @@ class LinkNewDeviceRootPresenterTest {
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,
createLinkMobileHandlerResult = { Result.success(linkMobileHandler) } createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
) )
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
createPresenter( createPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
linkNewMobileHandler = linkNewMobileHandler,
).test { ).test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isSupported.dataOrNull()).isTrue() assertThat(initialState.isSupported.dataOrNull()).isTrue()
initialState.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) linkNewMobileHandler.createAndStartNewHandler()
skipItems(1)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.qrCodeData.isLoading()).isTrue() 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()
} }
} }
@@ -18,9 +18,11 @@ import io.element.android.features.linknewdevice.impl.R
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled 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.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce 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.pressBackKey
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -44,29 +46,31 @@ class LinkNewDeviceRootViewTest {
@Test @Test
fun `link desktop button clicked - calls the expected callback`() = runAndroidComposeUiTest { fun `link desktop button clicked - calls the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false) val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnceWithParam(LinkDeviceType.Desktop) { callback ->
setLinkNewDeviceRootView( setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState( state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(true), isSupported = AsyncData.Success(true),
eventSink = eventRecorder, eventSink = eventRecorder,
), ),
onLinkDesktopDeviceClick = callback, onUnlockDevice = callback,
) )
clickOn(R.string.screen_link_new_device_root_desktop_computer) clickOn(R.string.screen_link_new_device_root_desktop_computer)
} }
} }
@Test @Test
fun `link mobile button clicked - emits the expected event`() = runAndroidComposeUiTest { fun `link mobile button clicked - calls the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>() val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
ensureCalledOnceWithParam(LinkDeviceType.Mobile) { callback ->
setLinkNewDeviceRootView( setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState( state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(true), isSupported = AsyncData.Success(true),
eventSink = eventRecorder, eventSink = eventRecorder,
) ),
onUnlockDevice = callback,
) )
clickOn(R.string.screen_link_new_device_root_mobile_device) clickOn(R.string.screen_link_new_device_root_mobile_device)
eventRecorder.assertSingle(LinkNewDeviceRootEvent.LinkMobileDevice) }
} }
@Test @Test
@@ -87,13 +91,13 @@ class LinkNewDeviceRootViewTest {
private fun AndroidComposeUiTest<ComponentActivity>.setLinkNewDeviceRootView( private fun AndroidComposeUiTest<ComponentActivity>.setLinkNewDeviceRootView(
state: LinkNewDeviceRootState = aLinkNewDeviceRootState(), state: LinkNewDeviceRootState = aLinkNewDeviceRootState(),
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onLinkDesktopDeviceClick: () -> Unit = EnsureNeverCalled(), onUnlockDevice: (type: LinkDeviceType) -> Unit = EnsureNeverCalledWithParam(),
) { ) {
setContent { setContent {
LinkNewDeviceRootView( LinkNewDeviceRootView(
state = state, state = state,
onBackClick = onBackClick, 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 onUnlocked()
}
}
@@ -48,6 +48,7 @@ dependencies {
implementation(libs.androidx.biometric) implementation(libs.androidx.biometric)
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.cryptography.test) testImplementation(projects.libraries.cryptography.test)
testImplementation(projects.libraries.cryptography.impl) 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)
}
}
@@ -109,7 +109,9 @@ private class AuthenticationCallback(
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result) 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() } callbacks.forEach { it.onBiometricAuthenticationSuccess() }
deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Success) deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Success)
} else { } else {
@@ -21,6 +21,11 @@ interface BiometricAuthenticatorManager {
*/ */
val hasAvailableAuthenticator: Boolean 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 addCallback(callback: BiometricAuthenticator.Callback)
fun removeCallback(callback: BiometricAuthenticator.Callback) fun removeCallback(callback: BiometricAuthenticator.Callback)
@@ -35,6 +40,12 @@ interface BiometricAuthenticatorManager {
@Composable @Composable
fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator 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. * 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.di.annotations.ApplicationContext
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
@@ -69,6 +70,12 @@ class DefaultBiometricAuthenticatorManager(
get() = lockScreenConfig.isStrongBiometricsEnabled && get() = lockScreenConfig.isStrongBiometricsEnabled &&
biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS 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. * Returns true if any biometric method (weak or strong) can be used.
*/ */
@@ -78,6 +85,9 @@ class DefaultBiometricAuthenticatorManager(
override val isDeviceSecured: Boolean override val isDeviceSecured: Boolean
get() = keyguardManager.isDeviceSecure get() = keyguardManager.isDeviceSecure
override val canUseDeviceUnlock: Boolean
get() = isDeviceSecured && (canUseWeakBiometricAuth || canUseStrongBiometricAuth || canUseDeviceCredentialAuth)
private val internalCallback = object : DefaultBiometricUnlockCallback() { private val internalCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricSetupError() { override fun onBiometricSetupError() {
coroutineScope.launch { disable() } coroutineScope.launch { disable() }
@@ -99,6 +109,28 @@ class DefaultBiometricAuthenticatorManager(
isAvailable = isAvailable, isAvailable = isAvailable,
promptTitle = promptTitle, promptTitle = promptTitle,
promptNegative = promptNegative, 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, isAvailable = isAvailable,
promptTitle = promptTitle, promptTitle = promptTitle,
promptNegative = promptNegative, promptNegative = promptNegative,
forDeviceUnlock = false,
) )
} }
@@ -126,7 +159,8 @@ class DefaultBiometricAuthenticatorManager(
private fun rememberBiometricAuthenticator( private fun rememberBiometricAuthenticator(
isAvailable: Boolean, isAvailable: Boolean,
promptTitle: String, promptTitle: String,
promptNegative: String, promptNegative: String?,
forDeviceUnlock: Boolean,
): BiometricAuthenticator { ): BiometricAuthenticator {
val activity = LocalContext.current.findFragmentActivity() val activity = LocalContext.current.findFragmentActivity()
return remember(isAvailable) { return remember(isAvailable) {
@@ -136,11 +170,21 @@ class DefaultBiometricAuthenticatorManager(
canUseWeakBiometricAuth -> BiometricManager.Authenticators.BIOMETRIC_WEAK canUseWeakBiometricAuth -> BiometricManager.Authenticators.BIOMETRIC_WEAK
else -> 0 else -> 0
} }
val promptInfo = BiometricPrompt.PromptInfo.Builder().apply { val promptInfo = BiometricPrompt.PromptInfo.Builder()
setTitle(promptTitle) .setTitle(promptTitle)
.apply {
if (promptNegative != null) {
setNegativeButtonText(promptNegative) setNegativeButtonText(promptNegative)
}
if (forDeviceUnlock) {
setAllowedAuthenticators(
authenticators or if (canUseDeviceCredentialAuth) BiometricManager.Authenticators.DEVICE_CREDENTIAL else 0
)
} else {
setAllowedAuthenticators(authenticators) setAllowedAuthenticators(authenticators)
}.build() }
}
.build()
DefaultBiometricAuthentication( DefaultBiometricAuthentication(
activity = activity, activity = activity,
promptInfo = promptInfo, 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 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,
)
}
}
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.lockscreen.impl.device
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.unlock.PinUnlockHelper
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@Inject
class DeviceUnlockPresenter(
private val pinUnlockHelper: PinUnlockHelper,
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
private val biometricRequester: DeviceUnlockCallbackHolder,
private val pinCodeManager: PinCodeManager,
) : Presenter<DeviceUnlockState> {
@Composable
override fun present(): DeviceUnlockState {
val coroutineScope = rememberCoroutineScope()
var showApplicationPinCode by remember {
mutableStateOf(false)
}
val biometricUnlock = biometricAuthenticatorManager.rememberUnlockDeviceBiometricAuthenticator()
val deviceUnlockCallback by biometricRequester.deviceUnlockCallback.collectAsState()
val canUseDeviceUnlock = biometricAuthenticatorManager.canUseDeviceUnlock
fun setUnlock(isUnlock: Boolean) = coroutineScope.launch {
deviceUnlockCallback?.let {
if (isUnlock) {
it.onUnlocked()
} else {
it.onCancel()
}
}
showApplicationPinCode = false
biometricRequester.onDone()
}
LaunchedEffect(biometricUnlock, canUseDeviceUnlock, deviceUnlockCallback) {
if (deviceUnlockCallback != null) {
if (canUseDeviceUnlock) {
biometricUnlock.setup()
biometricUnlock.authenticate()
} else if (pinCodeManager.hasPinCode().first()) {
showApplicationPinCode = true
} else {
// No security, unlock immediately
setUnlock(true)
}
}
}
pinUnlockHelper.OnUnlockEffect { isUnlock ->
setUnlock(isUnlock)
}
fun handleEvent(event: DeviceUnlockEvent) {
when (event) {
DeviceUnlockEvent.CancelPinCode -> {
showApplicationPinCode = false
setUnlock(false)
}
}
}
return DeviceUnlockState(
showApplicationPinCode = showApplicationPinCode,
eventSink = ::handleEvent,
)
}
}
@@ -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() { override fun onUnlock() {
backstack.newRoot(NavTarget.Settings) 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 -> { NavTarget.SetupPin -> {
createNode<SetupPinNode>(buildContext) createNode<SetupPinNode>(buildContext)
@@ -24,17 +24,23 @@ class PinUnlockHelper(
private val pinCodeManager: PinCodeManager private val pinCodeManager: PinCodeManager
) { ) {
@Composable @Composable
fun OnUnlockEffect(onUnlock: () -> Unit) { fun OnUnlockEffect(onUnlock: (Boolean) -> Unit) {
val latestOnUnlock by rememberUpdatedState(onUnlock) val latestOnUnlock by rememberUpdatedState(onUnlock)
DisposableEffect(Unit) { DisposableEffect(Unit) {
val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() { val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricAuthenticationSuccess() { override fun onBiometricAuthenticationSuccess() {
latestOnUnlock() latestOnUnlock(true)
}
override fun onBiometricAuthenticationFailed(error: Exception?) {
if (error != null) {
latestOnUnlock(false)
}
} }
} }
val pinCodeVerifiedCallback = object : DefaultPinCodeManagerCallback() { val pinCodeVerifiedCallback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeVerified() { override fun onPinCodeVerified() {
latestOnUnlock() latestOnUnlock(true)
} }
} }
biometricAuthenticatorManager.addCallback(biometricUnlockCallback) biometricAuthenticatorManager.addCallback(biometricUnlockCallback)
@@ -17,7 +17,9 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode 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.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@@ -25,13 +27,23 @@ import io.element.android.libraries.di.SessionScope
class PinUnlockNode( class PinUnlockNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: PinUnlockPresenter, presenterFactory: PinUnlockPresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun onUnlock() fun onUnlock()
// For feature unlock
fun onCancel()
} }
data class Inputs(
val forDeviceUnlock: Boolean,
) : NodeInputs
private val callback: Callback = callback() private val callback: Callback = callback()
private val inputs: Inputs = inputs()
private val presenter: PinUnlockPresenter = presenterFactory.create(inputs.forDeviceUnlock)
@Composable @Composable
override fun View(modifier: Modifier) { 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. // UnlockNode is only used for in-app unlock, so we can safely set isInAppUnlock to true.
// It's set to false in PinUnlockActivity. // It's set to false in PinUnlockActivity.
isInAppUnlock = true, isInAppUnlock = true,
onCancel = callback::onCancel,
modifier = modifier modifier = modifier
) )
} }
@@ -16,7 +16,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue 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.BiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.pin.PinCodeManager import io.element.android.features.lockscreen.impl.pin.PinCodeManager
@@ -32,8 +34,9 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Inject @AssistedInject
class PinUnlockPresenter( class PinUnlockPresenter(
@Assisted private val forDeviceUnlock: Boolean,
private val pinCodeManager: PinCodeManager, private val pinCodeManager: PinCodeManager,
private val biometricAuthenticatorManager: BiometricAuthenticatorManager, private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
private val logoutUseCase: LogoutUseCase, private val logoutUseCase: LogoutUseCase,
@@ -41,6 +44,11 @@ class PinUnlockPresenter(
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val pinUnlockHelper: PinUnlockHelper, private val pinUnlockHelper: PinUnlockHelper,
) : Presenter<PinUnlockState> { ) : Presenter<PinUnlockState> {
@AssistedFactory
interface Factory {
fun create(forDeviceUnlock: Boolean): PinUnlockPresenter
}
@Composable @Composable
override fun present(): PinUnlockState { override fun present(): PinUnlockState {
val pinEntryState = remember { val pinEntryState = remember {
@@ -98,7 +106,7 @@ class PinUnlockPresenter(
} }
} }
pinUnlockHelper.OnUnlockEffect { pinUnlockHelper.OnUnlockEffect {
isUnlocked.value = true isUnlocked.value = it
} }
fun handleEvent(event: PinUnlockEvent) { fun handleEvent(event: PinUnlockEvent) {
@@ -128,6 +136,7 @@ class PinUnlockPresenter(
} }
} }
return PinUnlockState( return PinUnlockState(
canNavigateBack = forDeviceUnlock,
pinEntry = pinEntry, pinEntry = pinEntry,
showWrongPinTitle = showWrongPinTitle, showWrongPinTitle = showWrongPinTitle,
remainingAttempts = remainingAttempts, remainingAttempts = remainingAttempts,
@@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
data class PinUnlockState( data class PinUnlockState(
val canNavigateBack: Boolean,
val pinEntry: AsyncData<PinEntry>, val pinEntry: AsyncData<PinEntry>,
val showWrongPinTitle: Boolean, val showWrongPinTitle: Boolean,
val remainingAttempts: AsyncData<Int>, val remainingAttempts: AsyncData<Int>,
@@ -38,10 +38,20 @@ open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
showWrongPinTitle = true, showWrongPinTitle = true,
isUnlocked = true, isUnlocked = true,
), ),
aPinUnlockState(canNavigateBack = true),
)
}
open class PinUnlockStateCompactProvider : PreviewParameterProvider<PinUnlockState> {
override val values: Sequence<PinUnlockState>
get() = sequenceOf(
aPinUnlockState(),
aPinUnlockState(canNavigateBack = true)
) )
} }
fun aPinUnlockState( fun aPinUnlockState(
canNavigateBack: Boolean = false,
pinEntry: AsyncData<PinEntry> = AsyncData.Success(PinEntry.createEmpty(4)), pinEntry: AsyncData<PinEntry> = AsyncData.Success(PinEntry.createEmpty(4)),
remainingAttempts: AsyncData<Int> = AsyncData.Success(3), remainingAttempts: AsyncData<Int> = AsyncData.Success(3),
showWrongPinTitle: Boolean = false, showWrongPinTitle: Boolean = false,
@@ -51,6 +61,7 @@ fun aPinUnlockState(
isUnlocked: Boolean = false, isUnlocked: Boolean = false,
signOutAction: AsyncAction<Unit> = AsyncAction.Uninitialized, signOutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = PinUnlockState( ) = PinUnlockState(
canNavigateBack = canNavigateBack,
pinEntry = pinEntry, pinEntry = pinEntry,
showWrongPinTitle = showWrongPinTitle, showWrongPinTitle = showWrongPinTitle,
remainingAttempts = remainingAttempts, remainingAttempts = remainingAttempts,
@@ -36,6 +36,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign 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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle 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.architecture.AsyncData
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.ProgressDialog 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.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -65,6 +67,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun PinUnlockView( fun PinUnlockView(
state: PinUnlockState, state: PinUnlockState,
isInAppUnlock: Boolean, isInAppUnlock: Boolean,
onCancel: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
OnLifecycleEvent { _, event -> OnLifecycleEvent { _, event ->
@@ -74,7 +77,7 @@ fun PinUnlockView(
} }
} }
Surface(modifier) { Surface(modifier) {
PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock) PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock, onCancel = onCancel)
if (state.showSignOutPrompt) { if (state.showSignOutPrompt) {
SignOutPrompt( SignOutPrompt(
isCancellable = state.isSignOutPromptCancellable, isCancellable = state.isSignOutPromptCancellable,
@@ -105,6 +108,7 @@ fun PinUnlockView(
private fun PinUnlockPage( private fun PinUnlockPage(
state: PinUnlockState, state: PinUnlockState,
isInAppUnlock: Boolean, isInAppUnlock: Boolean,
onCancel: () -> Unit,
) { ) {
BoxWithConstraints { BoxWithConstraints {
val commonModifier = Modifier val commonModifier = Modifier
@@ -177,6 +181,15 @@ private fun PinUnlockPage(
modifier = commonModifier, modifier = commonModifier,
) )
} }
if (state.canNavigateBack) {
BackButton(
onClick = onCancel,
modifier = Modifier
.align(Alignment.TopStart)
.systemBarsPadding()
.padding(8.dp),
)
}
} }
} }
@@ -373,6 +386,7 @@ internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider:
PinUnlockView( PinUnlockView(
state = state, state = state,
isInAppUnlock = true, isInAppUnlock = true,
onCancel = {},
) )
} }
} }
@@ -384,6 +398,19 @@ internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::clas
PinUnlockView( PinUnlockView(
state = state, state = state,
isInAppUnlock = false, 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 lockScreenService: LockScreenService
@Inject lateinit var appPreferencesStore: AppPreferencesStore @Inject lateinit var appPreferencesStore: AppPreferencesStore
@Inject lateinit var featureFlagService: FeatureFlagService @Inject lateinit var featureFlagService: FeatureFlagService
@@ -52,6 +52,7 @@ class PinUnlockActivity : AppCompatActivity() {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
bindings<PinUnlockBindings>().inject(this) bindings<PinUnlockBindings>().inject(this)
val presenter = presenterFactory.create(forDeviceUnlock = false)
setContent { setContent {
val colors by remember { val colors by remember {
enterpriseService.semanticColorsFlow(sessionId = null) enterpriseService.semanticColorsFlow(sessionId = null)
@@ -67,6 +68,9 @@ class PinUnlockActivity : AppCompatActivity() {
PinUnlockView( PinUnlockView(
state = state, state = state,
isInAppUnlock = false, isInAppUnlock = false,
onCancel = {
// Should not happen
},
) )
} }
} }
@@ -8,10 +8,18 @@
package io.element.android.features.lockscreen.impl.biometric package io.element.android.features.lockscreen.impl.biometric
import io.element.android.tests.testutils.simulateLongTask
class FakeBiometricAuthenticator( class FakeBiometricAuthenticator(
override val isActive: Boolean = false, 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 { ) : BiometricAuthenticator {
override suspend fun setup() = Unit override suspend fun setup() = simulateLongTask {
override suspend fun authenticate() = authenticateLambda() setupLambda()
}
override suspend fun authenticate() = simulateLongTask {
authenticateLambda()
}
} }
@@ -12,8 +12,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
class FakeBiometricAuthenticatorManager( class FakeBiometricAuthenticatorManager(
override var isDeviceSecured: Boolean = true, override val isDeviceSecured: Boolean = true,
override var hasAvailableAuthenticator: Boolean = false, override val canUseDeviceUnlock: Boolean = true,
override val hasAvailableAuthenticator: Boolean = false,
private val createBiometricAuthenticator: () -> BiometricAuthenticator = { FakeBiometricAuthenticator() }, private val createBiometricAuthenticator: () -> BiometricAuthenticator = { FakeBiometricAuthenticator() },
private val disableLambda: suspend () -> Unit = { }, private val disableLambda: suspend () -> Unit = { },
) : BiometricAuthenticatorManager { ) : BiometricAuthenticatorManager {
@@ -32,6 +33,13 @@ class FakeBiometricAuthenticatorManager(
} }
} }
@Composable
override fun rememberUnlockDeviceBiometricAuthenticator(): BiometricAuthenticator {
return remember {
createBiometricAuthenticator()
}
}
@Composable @Composable
override fun rememberConfirmBiometricAuthenticator(): BiometricAuthenticator { override fun rememberConfirmBiometricAuthenticator(): BiometricAuthenticator {
return remember { return remember {
@@ -0,0 +1,152 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.lockscreen.impl.device
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.unlock.PinUnlockHelper
import io.element.android.features.lockscreen.test.FakeDeviceUnlockCallback
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DeviceUnlockPresenterTest {
@Test
fun `present - when unlock requested and device unlock available, use biometric authenticator`() = runTest {
val setupLambda = lambdaRecorder<Unit> { }
val authenticateLambda = lambdaRecorder<BiometricAuthenticator.AuthenticationResult> {
BiometricAuthenticator.AuthenticationResult.Success
}
val fakeBiometricAuthenticator = FakeBiometricAuthenticator(
setupLambda = setupLambda,
authenticateLambda = authenticateLambda,
)
val biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(
canUseDeviceUnlock = true,
createBiometricAuthenticator = { fakeBiometricAuthenticator },
)
val callbackHolder = DeviceUnlockCallbackHolder()
val callback = FakeDeviceUnlockCallback()
createPresenter(
biometricAuthenticatorManager = biometricAuthenticatorManager,
callbackHolder = callbackHolder,
).test {
awaitItem().also { state ->
assertThat(state.showApplicationPinCode).isFalse()
}
callbackHolder.requestUnlock(callback)
advanceUntilIdle()
setupLambda.assertions().isCalledOnce()
authenticateLambda.assertions().isCalledOnce()
skipItems(1)
}
}
@Test
fun `present - when unlock requested and device unlock unavailable and app pin is configured, show app pin`() = runTest {
val callbackHolder = DeviceUnlockCallbackHolder()
val callback = FakeDeviceUnlockCallback()
val pinCodeManager = aPinCodeManager().apply {
createPinCode("1234")
}
createPresenter(
biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(canUseDeviceUnlock = false),
callbackHolder = callbackHolder,
pinCodeManager = pinCodeManager,
).test {
awaitItem().also { state ->
assertThat(state.showApplicationPinCode).isFalse()
}
callbackHolder.requestUnlock(callback)
skipItems(1)
awaitItem().also { state ->
assertThat(state.showApplicationPinCode).isTrue()
}
}
}
@Test
fun `present - when unlock requested and no security, unlock immediately`() = runTest {
val callbackHolder = DeviceUnlockCallbackHolder()
val onUnlockedLambda = lambdaRecorder<Unit> { }
val callback = FakeDeviceUnlockCallback(
onUnlockedLambda = onUnlockedLambda,
)
createPresenter(
biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(canUseDeviceUnlock = false),
callbackHolder = callbackHolder,
).test {
awaitItem().also { state ->
assertThat(state.showApplicationPinCode).isFalse()
}
callbackHolder.requestUnlock(callback)
skipItems(2)
assertThat(callbackHolder.deviceUnlockCallback.value).isNull()
onUnlockedLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - CancelPinCode event cancels unlock request`() = runTest {
val callbackHolder = DeviceUnlockCallbackHolder()
val onCancelLambda = lambdaRecorder<Unit> { }
val callback = FakeDeviceUnlockCallback(
onCancelLambda = onCancelLambda,
)
val pinCodeManager = aPinCodeManager().apply {
createPinCode("1234")
}
createPresenter(
biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(canUseDeviceUnlock = false),
callbackHolder = callbackHolder,
pinCodeManager = pinCodeManager,
).test {
awaitItem()
callbackHolder.requestUnlock(callback)
skipItems(1)
awaitItem().also { state ->
assertThat(state.showApplicationPinCode).isTrue()
state.eventSink(DeviceUnlockEvent.CancelPinCode)
}
awaitItem().also { state ->
assertThat(state.showApplicationPinCode).isFalse()
}
skipItems(1)
onCancelLambda.assertions().isCalledOnce()
assertThat(callbackHolder.deviceUnlockCallback.value).isNull()
}
}
private fun createPresenter(
biometricAuthenticatorManager: FakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
callbackHolder: DeviceUnlockCallbackHolder = DeviceUnlockCallbackHolder(),
pinCodeManager: PinCodeManager = aPinCodeManager(),
): DeviceUnlockPresenter {
val pinUnlockHelper = PinUnlockHelper(
biometricAuthenticatorManager = biometricAuthenticatorManager,
pinCodeManager = pinCodeManager,
)
return DeviceUnlockPresenter(
pinUnlockHelper = pinUnlockHelper,
biometricAuthenticatorManager = biometricAuthenticatorManager,
biometricRequester = callbackHolder,
pinCodeManager = pinCodeManager,
)
}
}
@@ -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) { private fun AsyncData<PinEntry>.assertText(text: String) {
dataOrNull()?.assertText(text) dataOrNull()?.assertText(text)
} }
private suspend fun TestScope.createPinUnlockPresenter( private suspend fun TestScope.createPinUnlockPresenter(
forDeviceUnlock: Boolean = false,
biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(), biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(), callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = {}), logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = {}),
@@ -162,6 +188,7 @@ class PinUnlockPresenterTest {
createPinCode(completePin) createPinCode(completePin)
} }
return PinUnlockPresenter( return PinUnlockPresenter(
forDeviceUnlock = forDeviceUnlock,
pinCodeManager = pinCodeManager, pinCodeManager = pinCodeManager,
biometricAuthenticatorManager = biometricAuthenticatorManager, biometricAuthenticatorManager = biometricAuthenticatorManager,
logoutUseCase = logoutUseCase, logoutUseCase = logoutUseCase,
@@ -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()
}
@@ -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()
}
@@ -16,6 +16,9 @@ interface LinkMobileHandler {
sealed interface LinkMobileStep { sealed interface LinkMobileStep {
data object Uninitialized : LinkMobileStep data object Uninitialized : LinkMobileStep
// Internal application step, for the UI
data object CreatingQrCode : LinkMobileStep
data object Starting : LinkMobileStep data object Starting : LinkMobileStep
data class QrReady(val data: String) : LinkMobileStep data class QrReady(val data: String) : LinkMobileStep
data object QrRotating : LinkMobileStep data object QrRotating : LinkMobileStep