mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 10:32:34 +00:00
Merge pull request #6992 from element-hq/feature/bma/protectLinkNewDevice
Protect link new device
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 onUnlock() = when (type) {
|
||||||
|
LinkDeviceType.Mobile -> {
|
||||||
|
linkNewMobileHandler.reset()
|
||||||
|
linkNewMobileHandler.createAndStartNewHandler()
|
||||||
|
}
|
||||||
|
LinkDeviceType.Desktop -> {
|
||||||
|
linkNewDesktopHandler.reset()
|
||||||
|
backstack.push(NavTarget.DesktopNotice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deviceUnlockEntryPoint.requestUnlock(callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createNode<LinkNewDeviceRootNode>(buildContext, listOf(callback))
|
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
@@ -17,9 +17,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 io.element.android.tests.testutils.robolectric.RobolectricTest
|
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -42,29 +44,31 @@ class LinkNewDeviceRootViewTest : RobolectricTest() {
|
|||||||
@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
|
||||||
@@ -85,13 +89,13 @@ class LinkNewDeviceRootViewTest : RobolectricTest() {
|
|||||||
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 onUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -59,6 +59,7 @@ class DefaultBiometricAuthentication(
|
|||||||
private var cryptoObject: CryptoObject? = null
|
private var cryptoObject: CryptoObject? = null
|
||||||
|
|
||||||
override suspend fun setup() {
|
override suspend fun setup() {
|
||||||
|
if (cryptoObject != null) return
|
||||||
try {
|
try {
|
||||||
val secretKey = ensureKey()
|
val secretKey = ensureKey()
|
||||||
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
|
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
|
||||||
@@ -109,7 +110,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, a pattern, a password, or 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 device credential method (i.e.: pattern, pin) can be used.
|
||||||
|
*/
|
||||||
|
private val canUseDeviceCredentialAuth: Boolean
|
||||||
|
get() = biometricManager.canAuthenticate(BiometricManager.Authenticators.DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if any biometric method (weak or strong) can be used.
|
* 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 pinUnlockPresenterFactory: PinUnlockPresenter.Factory,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
if (state.showApplicationPinCode) {
|
||||||
|
val pinUnlockPresenter = remember {
|
||||||
|
pinUnlockPresenterFactory.create(forDeviceUnlock = true)
|
||||||
|
}
|
||||||
|
val pinState = pinUnlockPresenter.present()
|
||||||
|
PinUnlockView(
|
||||||
|
state = pinState,
|
||||||
|
isInAppUnlock = true,
|
||||||
|
onCancel = {
|
||||||
|
state.eventSink(DeviceUnlockEvent.CancelPinCode)
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.device
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
|
||||||
|
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||||
|
import io.element.android.features.lockscreen.impl.unlock.PinUnlockHelper
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class DeviceUnlockPresenter(
|
||||||
|
private val pinUnlockHelper: PinUnlockHelper,
|
||||||
|
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
|
||||||
|
private val deviceUnlockCallbackHolder: DeviceUnlockCallbackHolder,
|
||||||
|
private val pinCodeManager: PinCodeManager,
|
||||||
|
) : Presenter<DeviceUnlockState> {
|
||||||
|
@Composable
|
||||||
|
override fun present(): DeviceUnlockState {
|
||||||
|
var showApplicationPinCode by remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val biometricUnlock = biometricAuthenticatorManager.rememberUnlockDeviceBiometricAuthenticator()
|
||||||
|
val deviceUnlockCallback by deviceUnlockCallbackHolder.deviceUnlockCallback.collectAsState()
|
||||||
|
val canUseDeviceUnlock = biometricAuthenticatorManager.canUseDeviceUnlock
|
||||||
|
|
||||||
|
fun setUnlock(isUnlock: Boolean) {
|
||||||
|
deviceUnlockCallback?.let {
|
||||||
|
if (isUnlock) {
|
||||||
|
it.onUnlock()
|
||||||
|
} else {
|
||||||
|
it.onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showApplicationPinCode = false
|
||||||
|
deviceUnlockCallbackHolder.onDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(biometricUnlock, canUseDeviceUnlock, deviceUnlockCallback) {
|
||||||
|
if (deviceUnlockCallback != null) {
|
||||||
|
if (canUseDeviceUnlock) {
|
||||||
|
biometricUnlock.setup()
|
||||||
|
biometricUnlock.authenticate()
|
||||||
|
} else if (pinCodeManager.hasPinCode().first()) {
|
||||||
|
showApplicationPinCode = true
|
||||||
|
} else {
|
||||||
|
// No security, unlock immediately
|
||||||
|
setUnlock(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pinUnlockHelper.OnUnlockEffect { isUnlock ->
|
||||||
|
setUnlock(isUnlock)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEvent(event: DeviceUnlockEvent) {
|
||||||
|
when (event) {
|
||||||
|
DeviceUnlockEvent.CancelPinCode -> {
|
||||||
|
showApplicationPinCode = false
|
||||||
|
setUnlock(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DeviceUnlockState(
|
||||||
|
showApplicationPinCode = showApplicationPinCode,
|
||||||
|
eventSink = ::handleEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.FakeDeviceUnlockEntryPointCallback
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DeviceUnlockPresenterTest {
|
||||||
|
@Test
|
||||||
|
fun `present - when unlock requested and device unlock available, use biometric authenticator`() = runTest {
|
||||||
|
val setupLambda = lambdaRecorder<Unit> { }
|
||||||
|
val authenticateLambda = lambdaRecorder<BiometricAuthenticator.AuthenticationResult> {
|
||||||
|
BiometricAuthenticator.AuthenticationResult.Success
|
||||||
|
}
|
||||||
|
val fakeBiometricAuthenticator = FakeBiometricAuthenticator(
|
||||||
|
setupLambda = setupLambda,
|
||||||
|
authenticateLambda = authenticateLambda,
|
||||||
|
)
|
||||||
|
val biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(
|
||||||
|
canUseDeviceUnlock = true,
|
||||||
|
createBiometricAuthenticator = { fakeBiometricAuthenticator },
|
||||||
|
)
|
||||||
|
val callbackHolder = DeviceUnlockCallbackHolder()
|
||||||
|
val callback = FakeDeviceUnlockEntryPointCallback()
|
||||||
|
|
||||||
|
createDeviceUnlockPresenter(
|
||||||
|
biometricAuthenticatorManager = biometricAuthenticatorManager,
|
||||||
|
callbackHolder = callbackHolder,
|
||||||
|
).test {
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.showApplicationPinCode).isFalse()
|
||||||
|
}
|
||||||
|
callbackHolder.requestUnlock(callback)
|
||||||
|
advanceUntilIdle()
|
||||||
|
setupLambda.assertions().isCalledOnce()
|
||||||
|
authenticateLambda.assertions().isCalledOnce()
|
||||||
|
skipItems(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - when unlock requested and device unlock unavailable and app pin is configured, show app pin`() = runTest {
|
||||||
|
val callbackHolder = DeviceUnlockCallbackHolder()
|
||||||
|
val callback = FakeDeviceUnlockEntryPointCallback()
|
||||||
|
val pinCodeManager = aPinCodeManager().apply {
|
||||||
|
createPinCode("1234")
|
||||||
|
}
|
||||||
|
createDeviceUnlockPresenter(
|
||||||
|
biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(canUseDeviceUnlock = false),
|
||||||
|
callbackHolder = callbackHolder,
|
||||||
|
pinCodeManager = pinCodeManager,
|
||||||
|
).test {
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.showApplicationPinCode).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackHolder.requestUnlock(callback)
|
||||||
|
skipItems(1)
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.showApplicationPinCode).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - when unlock requested and no security, unlock immediately`() = runTest {
|
||||||
|
val callbackHolder = DeviceUnlockCallbackHolder()
|
||||||
|
val onUnlockedLambda = lambdaRecorder<Unit> { }
|
||||||
|
val callback = FakeDeviceUnlockEntryPointCallback(
|
||||||
|
onUnlockedLambda = onUnlockedLambda,
|
||||||
|
)
|
||||||
|
createDeviceUnlockPresenter(
|
||||||
|
biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(canUseDeviceUnlock = false),
|
||||||
|
callbackHolder = callbackHolder,
|
||||||
|
).test {
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.showApplicationPinCode).isFalse()
|
||||||
|
}
|
||||||
|
callbackHolder.requestUnlock(callback)
|
||||||
|
skipItems(2)
|
||||||
|
assertThat(callbackHolder.deviceUnlockCallback.value).isNull()
|
||||||
|
onUnlockedLambda.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - CancelPinCode event cancels unlock request`() = runTest {
|
||||||
|
val callbackHolder = DeviceUnlockCallbackHolder()
|
||||||
|
val onCancelLambda = lambdaRecorder<Unit> { }
|
||||||
|
val callback = FakeDeviceUnlockEntryPointCallback(
|
||||||
|
onCancelLambda = onCancelLambda,
|
||||||
|
)
|
||||||
|
val pinCodeManager = aPinCodeManager().apply {
|
||||||
|
createPinCode("1234")
|
||||||
|
}
|
||||||
|
createDeviceUnlockPresenter(
|
||||||
|
biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(canUseDeviceUnlock = false),
|
||||||
|
callbackHolder = callbackHolder,
|
||||||
|
pinCodeManager = pinCodeManager,
|
||||||
|
).test {
|
||||||
|
awaitItem()
|
||||||
|
callbackHolder.requestUnlock(callback)
|
||||||
|
skipItems(1)
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.showApplicationPinCode).isTrue()
|
||||||
|
state.eventSink(DeviceUnlockEvent.CancelPinCode)
|
||||||
|
}
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.showApplicationPinCode).isFalse()
|
||||||
|
}
|
||||||
|
skipItems(1)
|
||||||
|
onCancelLambda.assertions().isCalledOnce()
|
||||||
|
assertThat(callbackHolder.deviceUnlockCallback.value).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDeviceUnlockPresenter(
|
||||||
|
biometricAuthenticatorManager: FakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
|
||||||
|
callbackHolder: DeviceUnlockCallbackHolder = DeviceUnlockCallbackHolder(),
|
||||||
|
pinCodeManager: PinCodeManager = aPinCodeManager(),
|
||||||
|
): DeviceUnlockPresenter {
|
||||||
|
val pinUnlockHelper = PinUnlockHelper(
|
||||||
|
biometricAuthenticatorManager = biometricAuthenticatorManager,
|
||||||
|
pinCodeManager = pinCodeManager,
|
||||||
|
)
|
||||||
|
return DeviceUnlockPresenter(
|
||||||
|
pinUnlockHelper = pinUnlockHelper,
|
||||||
|
biometricAuthenticatorManager = biometricAuthenticatorManager,
|
||||||
|
deviceUnlockCallbackHolder = callbackHolder,
|
||||||
|
pinCodeManager = pinCodeManager,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,
|
||||||
|
|||||||
+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()
|
||||||
|
}
|
||||||
+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 FakeDeviceUnlockEntryPointCallback(
|
||||||
|
private val onCancelLambda: () -> Unit = EnsureNeverCalled(),
|
||||||
|
private val onUnlockedLambda: () -> Unit = EnsureNeverCalled(),
|
||||||
|
) : DeviceUnlockEntryPoint.Callback {
|
||||||
|
override fun onCancel() = onCancelLambda()
|
||||||
|
override fun onUnlock() = onUnlockedLambda()
|
||||||
|
}
|
||||||
+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
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user