mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
[Link new device] Unlock with device credentials or application PIN code
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
+34
-3
@@ -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) {
|
||||||
linkNewDesktopHandler.reset()
|
val callback = object : DeviceUnlockEntryPoint.Callback {
|
||||||
backstack.push(NavTarget.DesktopNotice)
|
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<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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -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() {
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.linknewdevice.impl.screens.root
|
||||||
|
|
||||||
|
enum class LinkDeviceType {
|
||||||
|
Mobile,
|
||||||
|
Desktop,
|
||||||
|
}
|
||||||
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -50,7 +50,13 @@ class LinkNewDeviceRootPresenter(
|
|||||||
LaunchedEffect(step) {
|
LaunchedEffect(step) {
|
||||||
when (val finalStep = step) {
|
when (val finalStep = step) {
|
||||||
is LinkMobileStep.Uninitialized -> {
|
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 -> {
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-36
@@ -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 = {
|
||||||
onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) },
|
if (canClick) {
|
||||||
text = stringResource(id = R.string.screen_link_new_device_root_mobile_device),
|
onUnlockDevice(LinkDeviceType.Mobile)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
}
|
||||||
leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
|
},
|
||||||
)
|
text = stringResource(
|
||||||
Button(
|
id = if (isLoading) {
|
||||||
onClick = onLinkDesktopDeviceClick,
|
R.string.screen_link_new_device_root_loading_qr_code
|
||||||
text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
|
} else {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
R.string.screen_link_new_device_root_mobile_device
|
||||||
leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
|
}
|
||||||
)
|
),
|
||||||
}
|
showProgress = isLoading,
|
||||||
is AsyncData.Loading,
|
enabled = !isLoading,
|
||||||
is AsyncData.Success -> {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Button(
|
leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
|
||||||
onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) },
|
)
|
||||||
text = stringResource(id = R.string.screen_link_new_device_root_loading_qr_code),
|
Button(
|
||||||
showProgress = true,
|
onClick = {
|
||||||
enabled = false,
|
if (canClick) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onUnlockDevice(LinkDeviceType.Desktop)
|
||||||
)
|
}
|
||||||
Button(
|
},
|
||||||
onClick = onLinkDesktopDeviceClick,
|
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(),
|
||||||
enabled = false,
|
leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
|
||||||
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 = { },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -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 {
|
||||||
|
|||||||
+5
-2
@@ -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()
|
||||||
|
|||||||
+34
-1
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-13
@@ -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)
|
||||||
setLinkNewDeviceRootView(
|
ensureCalledOnceWithParam(LinkDeviceType.Mobile) { callback ->
|
||||||
state = aLinkNewDeviceRootState(
|
setLinkNewDeviceRootView(
|
||||||
isSupported = AsyncData.Success(true),
|
state = aLinkNewDeviceRootState(
|
||||||
eventSink = eventRecorder,
|
isSupported = AsyncData.Success(true),
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.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)
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl
|
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import io.element.android.features.lockscreen.api.DeviceUnlockEntryPoint
|
||||||
|
import io.element.android.features.lockscreen.impl.device.DeviceUnlockCallbackHolder
|
||||||
|
import io.element.android.features.lockscreen.impl.device.DeviceUnlockNode
|
||||||
|
import io.element.android.libraries.architecture.createNode
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultDeviceUnlockEntryPoint(
|
||||||
|
private val deviceUnlockCallbackHolder: DeviceUnlockCallbackHolder,
|
||||||
|
) : DeviceUnlockEntryPoint {
|
||||||
|
override fun createNode(
|
||||||
|
parentNode: Node,
|
||||||
|
buildContext: BuildContext,
|
||||||
|
): Node {
|
||||||
|
return parentNode.createNode<DeviceUnlockNode>(
|
||||||
|
buildContext = buildContext,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requestUnlock(callback: DeviceUnlockEntryPoint.Callback) {
|
||||||
|
deviceUnlockCallbackHolder.requestUnlock(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -109,7 +109,9 @@ private class AuthenticationCallback(
|
|||||||
|
|
||||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
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 {
|
||||||
|
|||||||
+11
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+50
-6
@@ -35,6 +35,7 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope
|
|||||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
import io.element.android.libraries.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)
|
||||||
setNegativeButtonText(promptNegative)
|
.apply {
|
||||||
setAllowedAuthenticators(authenticators)
|
if (promptNegative != null) {
|
||||||
}.build()
|
setNegativeButtonText(promptNegative)
|
||||||
|
}
|
||||||
|
if (forDeviceUnlock) {
|
||||||
|
setAllowedAuthenticators(
|
||||||
|
authenticators or if (canUseDeviceCredentialAuth) BiometricManager.Authenticators.DEVICE_CREDENTIAL else 0
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setAllowedAuthenticators(authenticators)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
DefaultBiometricAuthentication(
|
DefaultBiometricAuthentication(
|
||||||
activity = activity,
|
activity = activity,
|
||||||
promptInfo = promptInfo,
|
promptInfo = promptInfo,
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.device
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
import io.element.android.features.lockscreen.api.DeviceUnlockEntryPoint
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
class DeviceUnlockCallbackHolder {
|
||||||
|
private val _deviceUnlockCallback = MutableStateFlow<DeviceUnlockEntryPoint.Callback?>(null)
|
||||||
|
val deviceUnlockCallback: StateFlow<DeviceUnlockEntryPoint.Callback?> = _deviceUnlockCallback
|
||||||
|
|
||||||
|
fun requestUnlock(callback: DeviceUnlockEntryPoint.Callback) {
|
||||||
|
_deviceUnlockCallback.value = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDone() {
|
||||||
|
_deviceUnlockCallback.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.device
|
||||||
|
|
||||||
|
sealed interface DeviceUnlockEvent {
|
||||||
|
data object CancelPinCode : DeviceUnlockEvent
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.device
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
|
import io.element.android.annotations.ContributesNode
|
||||||
|
import io.element.android.features.lockscreen.impl.unlock.PinUnlockPresenter
|
||||||
|
import io.element.android.features.lockscreen.impl.unlock.PinUnlockView
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
|
||||||
|
@ContributesNode(SessionScope::class)
|
||||||
|
@AssistedInject
|
||||||
|
class DeviceUnlockNode(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val presenter: DeviceUnlockPresenter,
|
||||||
|
private val presenterFactory: PinUnlockPresenter.Factory,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
if (state.showApplicationPinCode) {
|
||||||
|
val pinUnlockPresenter = remember {
|
||||||
|
presenterFactory.create(forDeviceUnlock = true)
|
||||||
|
}
|
||||||
|
val pinState = pinUnlockPresenter.present()
|
||||||
|
PinUnlockView(
|
||||||
|
state = pinState,
|
||||||
|
isInAppUnlock = true,
|
||||||
|
onCancel = {
|
||||||
|
state.eventSink(DeviceUnlockEvent.CancelPinCode)
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.device
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
|
||||||
|
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||||
|
import io.element.android.features.lockscreen.impl.unlock.PinUnlockHelper
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class DeviceUnlockPresenter(
|
||||||
|
private val pinUnlockHelper: PinUnlockHelper,
|
||||||
|
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
|
||||||
|
private val biometricRequester: DeviceUnlockCallbackHolder,
|
||||||
|
private val pinCodeManager: PinCodeManager,
|
||||||
|
) : Presenter<DeviceUnlockState> {
|
||||||
|
@Composable
|
||||||
|
override fun present(): DeviceUnlockState {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
var showApplicationPinCode by remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val biometricUnlock = biometricAuthenticatorManager.rememberUnlockDeviceBiometricAuthenticator()
|
||||||
|
val deviceUnlockCallback by biometricRequester.deviceUnlockCallback.collectAsState()
|
||||||
|
val canUseDeviceUnlock = biometricAuthenticatorManager.canUseDeviceUnlock
|
||||||
|
|
||||||
|
fun setUnlock(isUnlock: Boolean) = coroutineScope.launch {
|
||||||
|
deviceUnlockCallback?.let {
|
||||||
|
if (isUnlock) {
|
||||||
|
it.onUnlocked()
|
||||||
|
} else {
|
||||||
|
it.onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showApplicationPinCode = false
|
||||||
|
biometricRequester.onDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(biometricUnlock, canUseDeviceUnlock, deviceUnlockCallback) {
|
||||||
|
if (deviceUnlockCallback != null) {
|
||||||
|
if (canUseDeviceUnlock) {
|
||||||
|
biometricUnlock.setup()
|
||||||
|
biometricUnlock.authenticate()
|
||||||
|
} else if (pinCodeManager.hasPinCode().first()) {
|
||||||
|
showApplicationPinCode = true
|
||||||
|
} else {
|
||||||
|
// No security, unlock immediately
|
||||||
|
setUnlock(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pinUnlockHelper.OnUnlockEffect { isUnlock ->
|
||||||
|
setUnlock(isUnlock)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEvent(event: DeviceUnlockEvent) {
|
||||||
|
when (event) {
|
||||||
|
DeviceUnlockEvent.CancelPinCode -> {
|
||||||
|
showApplicationPinCode = false
|
||||||
|
setUnlock(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DeviceUnlockState(
|
||||||
|
showApplicationPinCode = showApplicationPinCode,
|
||||||
|
eventSink = ::handleEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.device
|
||||||
|
|
||||||
|
data class DeviceUnlockState(
|
||||||
|
val showApplicationPinCode: Boolean,
|
||||||
|
val eventSink: (DeviceUnlockEvent) -> Unit,
|
||||||
|
)
|
||||||
+8
-1
@@ -103,8 +103,15 @@ class LockScreenSettingsFlowNode(
|
|||||||
override fun onUnlock() {
|
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)
|
||||||
|
|||||||
+9
-3
@@ -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)
|
||||||
|
|||||||
+14
-1
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-3
@@ -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,
|
||||||
|
|||||||
+1
@@ -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>,
|
||||||
|
|||||||
+11
@@ -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,
|
||||||
|
|||||||
+41
-14
@@ -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,13 +108,14 @@ 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
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.systemBarsPadding()
|
.systemBarsPadding()
|
||||||
.imePadding()
|
.imePadding()
|
||||||
.padding(all = 20.dp)
|
.padding(all = 20.dp)
|
||||||
|
|
||||||
val header = @Composable {
|
val header = @Composable {
|
||||||
PinUnlockHeader(
|
PinUnlockHeader(
|
||||||
@@ -147,8 +151,8 @@ private fun PinUnlockPage(
|
|||||||
state.eventSink(PinUnlockEvent.OnPinEntryChanged(it))
|
state.eventSink(PinUnlockEvent.OnPinEntryChanged(it))
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,8 +230,8 @@ private fun PinUnlockCompactView(
|
|||||||
}
|
}
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
@@ -239,9 +252,9 @@ private fun PinUnlockExpandedView(
|
|||||||
header()
|
header()
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 40.dp),
|
.padding(top = 40.dp),
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
@@ -274,8 +287,8 @@ private fun PinDot(
|
|||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(14.dp)
|
.size(14.dp)
|
||||||
.background(backgroundColor, CircleShape)
|
.background(backgroundColor, CircleShape)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -41,7 +41,7 @@ class PinUnlockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Inject lateinit var presenter: PinUnlockPresenter
|
@Inject lateinit var presenterFactory: PinUnlockPresenter.Factory
|
||||||
@Inject lateinit var lockScreenService: LockScreenService
|
@Inject lateinit var 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
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -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 {
|
||||||
@@ -28,7 +29,14 @@ class FakeBiometricAuthenticatorManager(
|
|||||||
@Composable
|
@Composable
|
||||||
override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
|
override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
|
||||||
return remember {
|
return remember {
|
||||||
createBiometricAuthenticator()
|
createBiometricAuthenticator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun rememberUnlockDeviceBiometricAuthenticator(): BiometricAuthenticator {
|
||||||
|
return remember {
|
||||||
|
createBiometricAuthenticator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.device
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
|
||||||
|
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticator
|
||||||
|
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
|
||||||
|
import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
|
||||||
|
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||||
|
import io.element.android.features.lockscreen.impl.unlock.PinUnlockHelper
|
||||||
|
import io.element.android.features.lockscreen.test.FakeDeviceUnlockCallback
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DeviceUnlockPresenterTest {
|
||||||
|
@Test
|
||||||
|
fun `present - when unlock requested and device unlock available, use biometric authenticator`() = runTest {
|
||||||
|
val setupLambda = lambdaRecorder<Unit> { }
|
||||||
|
val authenticateLambda = lambdaRecorder<BiometricAuthenticator.AuthenticationResult> {
|
||||||
|
BiometricAuthenticator.AuthenticationResult.Success
|
||||||
|
}
|
||||||
|
val fakeBiometricAuthenticator = FakeBiometricAuthenticator(
|
||||||
|
setupLambda = setupLambda,
|
||||||
|
authenticateLambda = authenticateLambda,
|
||||||
|
)
|
||||||
|
val biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(
|
||||||
|
canUseDeviceUnlock = true,
|
||||||
|
createBiometricAuthenticator = { fakeBiometricAuthenticator },
|
||||||
|
)
|
||||||
|
val callbackHolder = DeviceUnlockCallbackHolder()
|
||||||
|
val callback = FakeDeviceUnlockCallback()
|
||||||
|
|
||||||
|
createPresenter(
|
||||||
|
biometricAuthenticatorManager = biometricAuthenticatorManager,
|
||||||
|
callbackHolder = callbackHolder,
|
||||||
|
).test {
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.showApplicationPinCode).isFalse()
|
||||||
|
}
|
||||||
|
callbackHolder.requestUnlock(callback)
|
||||||
|
advanceUntilIdle()
|
||||||
|
setupLambda.assertions().isCalledOnce()
|
||||||
|
authenticateLambda.assertions().isCalledOnce()
|
||||||
|
skipItems(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - when unlock requested and device unlock unavailable and app pin is configured, show app pin`() = runTest {
|
||||||
|
val callbackHolder = DeviceUnlockCallbackHolder()
|
||||||
|
val callback = FakeDeviceUnlockCallback()
|
||||||
|
val pinCodeManager = aPinCodeManager().apply {
|
||||||
|
createPinCode("1234")
|
||||||
|
}
|
||||||
|
createPresenter(
|
||||||
|
biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(canUseDeviceUnlock = false),
|
||||||
|
callbackHolder = callbackHolder,
|
||||||
|
pinCodeManager = pinCodeManager,
|
||||||
|
).test {
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.showApplicationPinCode).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackHolder.requestUnlock(callback)
|
||||||
|
skipItems(1)
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.showApplicationPinCode).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - when unlock requested and no security, unlock immediately`() = runTest {
|
||||||
|
val callbackHolder = DeviceUnlockCallbackHolder()
|
||||||
|
val onUnlockedLambda = lambdaRecorder<Unit> { }
|
||||||
|
val callback = FakeDeviceUnlockCallback(
|
||||||
|
onUnlockedLambda = onUnlockedLambda,
|
||||||
|
)
|
||||||
|
createPresenter(
|
||||||
|
biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(canUseDeviceUnlock = false),
|
||||||
|
callbackHolder = callbackHolder,
|
||||||
|
).test {
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.showApplicationPinCode).isFalse()
|
||||||
|
}
|
||||||
|
callbackHolder.requestUnlock(callback)
|
||||||
|
skipItems(2)
|
||||||
|
assertThat(callbackHolder.deviceUnlockCallback.value).isNull()
|
||||||
|
onUnlockedLambda.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - CancelPinCode event cancels unlock request`() = runTest {
|
||||||
|
val callbackHolder = DeviceUnlockCallbackHolder()
|
||||||
|
val onCancelLambda = lambdaRecorder<Unit> { }
|
||||||
|
val callback = FakeDeviceUnlockCallback(
|
||||||
|
onCancelLambda = onCancelLambda,
|
||||||
|
)
|
||||||
|
val pinCodeManager = aPinCodeManager().apply {
|
||||||
|
createPinCode("1234")
|
||||||
|
}
|
||||||
|
createPresenter(
|
||||||
|
biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(canUseDeviceUnlock = false),
|
||||||
|
callbackHolder = callbackHolder,
|
||||||
|
pinCodeManager = pinCodeManager,
|
||||||
|
).test {
|
||||||
|
awaitItem()
|
||||||
|
callbackHolder.requestUnlock(callback)
|
||||||
|
skipItems(1)
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.showApplicationPinCode).isTrue()
|
||||||
|
state.eventSink(DeviceUnlockEvent.CancelPinCode)
|
||||||
|
}
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.showApplicationPinCode).isFalse()
|
||||||
|
}
|
||||||
|
skipItems(1)
|
||||||
|
onCancelLambda.assertions().isCalledOnce()
|
||||||
|
assertThat(callbackHolder.deviceUnlockCallback.value).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPresenter(
|
||||||
|
biometricAuthenticatorManager: FakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
|
||||||
|
callbackHolder: DeviceUnlockCallbackHolder = DeviceUnlockCallbackHolder(),
|
||||||
|
pinCodeManager: PinCodeManager = aPinCodeManager(),
|
||||||
|
): DeviceUnlockPresenter {
|
||||||
|
val pinUnlockHelper = PinUnlockHelper(
|
||||||
|
biometricAuthenticatorManager = biometricAuthenticatorManager,
|
||||||
|
pinCodeManager = pinCodeManager,
|
||||||
|
)
|
||||||
|
return DeviceUnlockPresenter(
|
||||||
|
pinUnlockHelper = pinUnlockHelper,
|
||||||
|
biometricAuthenticatorManager = biometricAuthenticatorManager,
|
||||||
|
biometricRequester = callbackHolder,
|
||||||
|
pinCodeManager = pinCodeManager,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -147,11 +147,37 @@ class PinUnlockPresenterTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - forDeviceUnlock is exposed in state`() = runTest {
|
||||||
|
val presenter = createPinUnlockPresenter(forDeviceUnlock = true)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.canNavigateBack).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - pin entry changed event updates pin entry`() = runTest {
|
||||||
|
val presenter = createPinUnlockPresenter()
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
awaitItem().also { state ->
|
||||||
|
state.eventSink(PinUnlockEvent.OnPinEntryChanged(halfCompletePin))
|
||||||
|
}
|
||||||
|
awaitItem().also { state ->
|
||||||
|
state.pinEntry.assertText(halfCompletePin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun AsyncData<PinEntry>.assertText(text: String) {
|
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,
|
||||||
|
|||||||
+19
@@ -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()
|
||||||
|
}
|
||||||
+22
@@ -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()
|
||||||
|
}
|
||||||
+3
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user