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
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.features.enterprise.api)
|
||||
implementation(projects.features.lockscreen.api)
|
||||
implementation(projects.features.rageshake.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.androidutils)
|
||||
@@ -53,6 +54,7 @@ dependencies {
|
||||
|
||||
testCommonDependencies(libs, true)
|
||||
testImplementation(projects.features.linknewdevice.test)
|
||||
testImplementation(projects.features.lockscreen.test)
|
||||
testImplementation(projects.features.enterprise.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
|
||||
+34
-3
@@ -13,8 +13,10 @@ import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.composable.PermanentChild
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
@@ -34,8 +36,10 @@ import io.element.android.features.linknewdevice.impl.screens.error.ErrorNode
|
||||
import io.element.android.features.linknewdevice.impl.screens.error.ErrorScreenType
|
||||
import io.element.android.features.linknewdevice.impl.screens.number.EnterNumberNode
|
||||
import io.element.android.features.linknewdevice.impl.screens.qrcode.ShowQrCodeNode
|
||||
import io.element.android.features.linknewdevice.impl.screens.root.LinkDeviceType
|
||||
import io.element.android.features.linknewdevice.impl.screens.root.LinkNewDeviceRootNode
|
||||
import io.element.android.features.linknewdevice.impl.screens.scan.ScanQrCodeNode
|
||||
import io.element.android.features.lockscreen.api.DeviceUnlockEntryPoint
|
||||
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
@@ -67,11 +71,16 @@ class LinkNewDeviceFlowNode(
|
||||
private val linkNewMobileHandler: LinkNewMobileHandler,
|
||||
private val linkNewDesktopHandler: LinkNewDesktopHandler,
|
||||
private val sessionEnterpriseService: SessionEnterpriseService,
|
||||
private val deviceUnlockEntryPoint: DeviceUnlockEntryPoint,
|
||||
) : BaseFlowNode<LinkNewDeviceFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
permanentNavModel = PermanentNavModel(
|
||||
navTargets = setOf(NavTarget.LockScreen),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
) {
|
||||
@@ -124,6 +133,9 @@ class LinkNewDeviceFlowNode(
|
||||
@Parcelize
|
||||
data object DesktopScanQrCode : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object LockScreen : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val errorScreenType: ErrorScreenType,
|
||||
@@ -137,6 +149,9 @@ class LinkNewDeviceFlowNode(
|
||||
Timber.tag(tag.value).d("step: ${linkMobileStep::class.java.simpleName}")
|
||||
when (linkMobileStep) {
|
||||
LinkMobileStep.Uninitialized -> Unit
|
||||
LinkMobileStep.CreatingQrCode -> {
|
||||
// This step is handled in LinkNewDeviceRootPresenter
|
||||
}
|
||||
LinkMobileStep.Done -> {
|
||||
callback.onDone()
|
||||
}
|
||||
@@ -224,13 +239,28 @@ class LinkNewDeviceFlowNode(
|
||||
callback.onDone()
|
||||
}
|
||||
|
||||
override fun linkDesktopDevice() {
|
||||
linkNewDesktopHandler.reset()
|
||||
backstack.push(NavTarget.DesktopNotice)
|
||||
override fun onUnlockDevice(type: LinkDeviceType) {
|
||||
val callback = object : DeviceUnlockEntryPoint.Callback {
|
||||
override fun onCancel() = Unit
|
||||
override fun onUnlock() = when (type) {
|
||||
LinkDeviceType.Mobile -> {
|
||||
linkNewMobileHandler.reset()
|
||||
linkNewMobileHandler.createAndStartNewHandler()
|
||||
}
|
||||
LinkDeviceType.Desktop -> {
|
||||
linkNewDesktopHandler.reset()
|
||||
backstack.push(NavTarget.DesktopNotice)
|
||||
}
|
||||
}
|
||||
}
|
||||
deviceUnlockEntryPoint.requestUnlock(callback)
|
||||
}
|
||||
}
|
||||
createNode<LinkNewDeviceRootNode>(buildContext, listOf(callback))
|
||||
}
|
||||
is NavTarget.LockScreen -> {
|
||||
deviceUnlockEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
NavTarget.DesktopNotice -> {
|
||||
val callback = object : DesktopNoticeNode.Callback {
|
||||
override fun navigateBack() {
|
||||
@@ -324,5 +354,6 @@ class LinkNewDeviceFlowNode(
|
||||
}
|
||||
}
|
||||
BackstackView()
|
||||
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockScreen)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -43,12 +43,15 @@ class LinkNewMobileHandler(
|
||||
val stepFlow: StateFlow<LinkMobileStep>
|
||||
get() = linkMobileStepFlow.asStateFlow()
|
||||
|
||||
fun createAndStartNewHandler() {
|
||||
fun createAndStartNewHandler(forRotating: Boolean = false) {
|
||||
Timber.tag(loggerTag.value).d("createAndStartNewHandler()")
|
||||
currentJob?.cancel()
|
||||
handler = matrixClient.createLinkMobileHandler().getOrNull()
|
||||
handler?.let { h ->
|
||||
currentJob = sessionScope.launch {
|
||||
if (!forRotating) {
|
||||
linkMobileStepFlow.emit(LinkMobileStep.CreatingQrCode)
|
||||
}
|
||||
h.linkMobileStep
|
||||
.onEach {
|
||||
linkMobileStepFlow.emit(it)
|
||||
@@ -68,7 +71,7 @@ class LinkNewMobileHandler(
|
||||
}
|
||||
|
||||
fun rotateQrCode() {
|
||||
createAndStartNewHandler()
|
||||
createAndStartNewHandler(forRotating = true)
|
||||
}
|
||||
|
||||
fun onTooManyRotation() {
|
||||
|
||||
+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
|
||||
|
||||
sealed interface LinkNewDeviceRootEvent {
|
||||
data object LinkMobileDevice : LinkNewDeviceRootEvent
|
||||
data object CloseDialog : LinkNewDeviceRootEvent
|
||||
}
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@ class LinkNewDeviceRootNode(
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
interface Callback : Plugin {
|
||||
fun onDone()
|
||||
fun linkDesktopDevice()
|
||||
fun onUnlockDevice(type: LinkDeviceType)
|
||||
}
|
||||
|
||||
private val callback: Callback = callback()
|
||||
@@ -39,7 +39,7 @@ class LinkNewDeviceRootNode(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onBackClick = callback::onDone,
|
||||
onLinkDesktopDeviceClick = callback::linkDesktopDevice,
|
||||
onUnlockDevice = callback::onUnlockDevice,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -50,7 +50,13 @@ class LinkNewDeviceRootPresenter(
|
||||
LaunchedEffect(step) {
|
||||
when (val finalStep = step) {
|
||||
is LinkMobileStep.Uninitialized -> {
|
||||
qrCodeData = AsyncData.Uninitialized
|
||||
// Ignore this step when loading QrCode
|
||||
if (!qrCodeData.isLoading()) {
|
||||
qrCodeData = AsyncData.Uninitialized
|
||||
}
|
||||
}
|
||||
is LinkMobileStep.CreatingQrCode -> {
|
||||
qrCodeData = AsyncData.Loading()
|
||||
}
|
||||
is LinkMobileStep.QrReady -> {
|
||||
qrCodeData = AsyncData.Success(Unit)
|
||||
@@ -64,12 +70,6 @@ class LinkNewDeviceRootPresenter(
|
||||
|
||||
fun handleEvent(event: LinkNewDeviceRootEvent) {
|
||||
when (event) {
|
||||
LinkNewDeviceRootEvent.LinkMobileDevice -> coroutineScope.launch {
|
||||
qrCodeData = AsyncData.Loading()
|
||||
// Wait for the QrCode to be ready
|
||||
linkNewMobileHandler.reset()
|
||||
linkNewMobileHandler.createAndStartNewHandler()
|
||||
}
|
||||
LinkNewDeviceRootEvent.CloseDialog -> coroutineScope.launch {
|
||||
linkNewMobileHandler.reset()
|
||||
}
|
||||
|
||||
+34
-36
@@ -41,7 +41,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
fun LinkNewDeviceRootView(
|
||||
state: LinkNewDeviceRootState,
|
||||
onBackClick: () -> Unit,
|
||||
onLinkDesktopDeviceClick: () -> Unit,
|
||||
onUnlockDevice: (type: LinkDeviceType) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val (title, subtitle, iconStyle) = if (state.isSupported.dataOrNull() == false) {
|
||||
@@ -57,6 +57,7 @@ fun LinkNewDeviceRootView(
|
||||
BigIcon.Style.Default(CompoundIcons.Devices())
|
||||
)
|
||||
}
|
||||
|
||||
FlowStepPage(
|
||||
onBackClick = onBackClick,
|
||||
title = title,
|
||||
@@ -83,40 +84,37 @@ fun LinkNewDeviceRootView(
|
||||
}
|
||||
is AsyncData.Success -> {
|
||||
if (state.isSupported.data) {
|
||||
when (state.qrCodeData) {
|
||||
AsyncData.Uninitialized,
|
||||
is AsyncData.Failure -> {
|
||||
Button(
|
||||
onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) },
|
||||
text = stringResource(id = R.string.screen_link_new_device_root_mobile_device),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
|
||||
)
|
||||
Button(
|
||||
onClick = onLinkDesktopDeviceClick,
|
||||
text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
|
||||
)
|
||||
}
|
||||
is AsyncData.Loading,
|
||||
is AsyncData.Success -> {
|
||||
Button(
|
||||
onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) },
|
||||
text = stringResource(id = R.string.screen_link_new_device_root_loading_qr_code),
|
||||
showProgress = true,
|
||||
enabled = false,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = onLinkDesktopDeviceClick,
|
||||
text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = false,
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
|
||||
)
|
||||
}
|
||||
}
|
||||
val canClick = state.qrCodeData is AsyncData.Uninitialized
|
||||
val isLoading = state.qrCodeData is AsyncData.Loading || state.qrCodeData is AsyncData.Success
|
||||
Button(
|
||||
onClick = {
|
||||
if (canClick) {
|
||||
onUnlockDevice(LinkDeviceType.Mobile)
|
||||
}
|
||||
},
|
||||
text = stringResource(
|
||||
id = if (isLoading) {
|
||||
R.string.screen_link_new_device_root_loading_qr_code
|
||||
} else {
|
||||
R.string.screen_link_new_device_root_mobile_device
|
||||
}
|
||||
),
|
||||
showProgress = isLoading,
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
if (canClick) {
|
||||
onUnlockDevice(LinkDeviceType.Desktop)
|
||||
}
|
||||
},
|
||||
text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
|
||||
)
|
||||
} else {
|
||||
Button(
|
||||
onClick = onBackClick,
|
||||
@@ -147,6 +145,6 @@ internal fun LinkNewDeviceRootViewPreview(
|
||||
LinkNewDeviceRootView(
|
||||
state = state,
|
||||
onBackClick = { },
|
||||
onLinkDesktopDeviceClick = { },
|
||||
onUnlockDevice = { },
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -13,6 +13,7 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
|
||||
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
|
||||
import io.element.android.features.lockscreen.test.FakeDeviceUnlockEntryPoint
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.node.TestParentNode
|
||||
@@ -39,6 +40,7 @@ class DefaultLinkNewDeviceEntryPointTest {
|
||||
linkNewMobileHandler = LinkNewMobileHandler(client),
|
||||
linkNewDesktopHandler = LinkNewDesktopHandler(client),
|
||||
sessionEnterpriseService = FakeSessionEnterpriseService(),
|
||||
deviceUnlockEntryPoint = FakeDeviceUnlockEntryPoint(),
|
||||
)
|
||||
}
|
||||
val callback: LinkNewDeviceEntryPoint.Callback = object : LinkNewDeviceEntryPoint.Callback {
|
||||
|
||||
+5
-2
@@ -88,6 +88,7 @@ class EnterNumberPresenterTest {
|
||||
navigator = navigator,
|
||||
linkNewMobileHandler = linkNewMobileHandler,
|
||||
).test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
linkMobileHandler.emitStep(
|
||||
LinkMobileStep.QrScanned(checkCodeSender)
|
||||
@@ -96,7 +97,7 @@ class EnterNumberPresenterTest {
|
||||
initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
|
||||
skipItems(1)
|
||||
initialState.eventSink(EnterNumberEvent.Continue)
|
||||
skipItems(1)
|
||||
skipItems(2)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.sendingCode.isLoading()).isTrue()
|
||||
advanceUntilIdle()
|
||||
@@ -130,6 +131,7 @@ class EnterNumberPresenterTest {
|
||||
LinkMobileStep.QrScanned(checkCodeSender)
|
||||
)
|
||||
runCurrent()
|
||||
skipItems(1)
|
||||
initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
|
||||
skipItems(1)
|
||||
initialState.eventSink(EnterNumberEvent.Continue)
|
||||
@@ -163,6 +165,7 @@ class EnterNumberPresenterTest {
|
||||
createPresenter(
|
||||
linkNewMobileHandler = linkNewMobileHandler,
|
||||
).test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
linkMobileHandler.emitStep(
|
||||
LinkMobileStep.QrScanned(checkCodeSender)
|
||||
@@ -171,7 +174,7 @@ class EnterNumberPresenterTest {
|
||||
initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
|
||||
skipItems(1)
|
||||
initialState.eventSink(EnterNumberEvent.Continue)
|
||||
skipItems(1)
|
||||
skipItems(2)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.sendingCode.isLoading()).isTrue()
|
||||
expectNoEvents()
|
||||
|
||||
+34
-1
@@ -75,15 +75,48 @@ class LinkNewDeviceRootPresenterTest {
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
|
||||
)
|
||||
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
|
||||
createPresenter(
|
||||
matrixClient = matrixClient,
|
||||
linkNewMobileHandler = linkNewMobileHandler,
|
||||
).test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isSupported.dataOrNull()).isTrue()
|
||||
initialState.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice)
|
||||
linkNewMobileHandler.createAndStartNewHandler()
|
||||
skipItems(1)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.qrCodeData.isLoading()).isTrue()
|
||||
skipItems(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - close dialog resets qrCodeData`() = runTest {
|
||||
val fakeLinkMobileHandler = FakeLinkMobileHandler(startResult = {})
|
||||
val matrixClient = FakeMatrixClient(
|
||||
canLinkNewDeviceResult = { Result.success(true) },
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
createLinkMobileHandlerResult = { Result.success(fakeLinkMobileHandler) }
|
||||
)
|
||||
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
|
||||
createPresenter(
|
||||
matrixClient = matrixClient,
|
||||
linkNewMobileHandler = linkNewMobileHandler,
|
||||
).test {
|
||||
skipItems(1)
|
||||
linkNewMobileHandler.onTooManyRotation()
|
||||
var errorState = awaitItem()
|
||||
while (!errorState.qrCodeData.isFailure()) {
|
||||
errorState = awaitItem()
|
||||
}
|
||||
assertThat(errorState.qrCodeData.isFailure()).isTrue()
|
||||
errorState.eventSink(LinkNewDeviceRootEvent.CloseDialog)
|
||||
var resetState = awaitItem()
|
||||
while (!resetState.qrCodeData.isUninitialized()) {
|
||||
resetState = awaitItem()
|
||||
}
|
||||
assertThat(resetState.qrCodeData.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-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.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.pressBackKey
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import org.junit.Test
|
||||
@@ -42,29 +44,31 @@ class LinkNewDeviceRootViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `link desktop button clicked - calls the expected callback`() = runAndroidComposeUiTest {
|
||||
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
ensureCalledOnceWithParam(LinkDeviceType.Desktop) { callback ->
|
||||
setLinkNewDeviceRootView(
|
||||
state = aLinkNewDeviceRootState(
|
||||
isSupported = AsyncData.Success(true),
|
||||
eventSink = eventRecorder,
|
||||
),
|
||||
onLinkDesktopDeviceClick = callback,
|
||||
onUnlockDevice = callback,
|
||||
)
|
||||
clickOn(R.string.screen_link_new_device_root_desktop_computer)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `link mobile button clicked - emits the expected event`() = runAndroidComposeUiTest {
|
||||
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>()
|
||||
setLinkNewDeviceRootView(
|
||||
state = aLinkNewDeviceRootState(
|
||||
isSupported = AsyncData.Success(true),
|
||||
eventSink = eventRecorder,
|
||||
fun `link mobile button clicked - calls the expected callback`() = runAndroidComposeUiTest {
|
||||
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
|
||||
ensureCalledOnceWithParam(LinkDeviceType.Mobile) { callback ->
|
||||
setLinkNewDeviceRootView(
|
||||
state = aLinkNewDeviceRootState(
|
||||
isSupported = AsyncData.Success(true),
|
||||
eventSink = eventRecorder,
|
||||
),
|
||||
onUnlockDevice = callback,
|
||||
)
|
||||
)
|
||||
clickOn(R.string.screen_link_new_device_root_mobile_device)
|
||||
eventRecorder.assertSingle(LinkNewDeviceRootEvent.LinkMobileDevice)
|
||||
clickOn(R.string.screen_link_new_device_root_mobile_device)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -85,13 +89,13 @@ class LinkNewDeviceRootViewTest : RobolectricTest() {
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setLinkNewDeviceRootView(
|
||||
state: LinkNewDeviceRootState = aLinkNewDeviceRootState(),
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onLinkDesktopDeviceClick: () -> Unit = EnsureNeverCalled(),
|
||||
onUnlockDevice: (type: LinkDeviceType) -> Unit = EnsureNeverCalledWithParam(),
|
||||
) {
|
||||
setContent {
|
||||
LinkNewDeviceRootView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
onLinkDesktopDeviceClick = onLinkDesktopDeviceClick,
|
||||
onUnlockDevice = onUnlockDevice,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+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)
|
||||
|
||||
testCommonDependencies(libs, true)
|
||||
testImplementation(projects.features.lockscreen.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.cryptography.test)
|
||||
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
|
||||
|
||||
override suspend fun setup() {
|
||||
if (cryptoObject != null) return
|
||||
try {
|
||||
val secretKey = ensureKey()
|
||||
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
|
||||
@@ -109,7 +110,9 @@ private class AuthenticationCallback(
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
if (result.cryptoObject?.cipher.isValid()) {
|
||||
if (result.authenticationType == BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC &&
|
||||
result.cryptoObject?.cipher.isValid() ||
|
||||
result.authenticationType == BiometricPrompt.AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL) {
|
||||
callbacks.forEach { it.onBiometricAuthenticationSuccess() }
|
||||
deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Success)
|
||||
} else {
|
||||
|
||||
+11
@@ -21,6 +21,11 @@ interface BiometricAuthenticatorManager {
|
||||
*/
|
||||
val hasAvailableAuthenticator: Boolean
|
||||
|
||||
/**
|
||||
* If the device is secured for example with a pin, a pattern, a password, or biometric.
|
||||
*/
|
||||
val canUseDeviceUnlock: Boolean
|
||||
|
||||
fun addCallback(callback: BiometricAuthenticator.Callback)
|
||||
fun removeCallback(callback: BiometricAuthenticator.Callback)
|
||||
|
||||
@@ -35,6 +40,12 @@ interface BiometricAuthenticatorManager {
|
||||
@Composable
|
||||
fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator
|
||||
|
||||
/**
|
||||
* Remember a biometric authenticator ready for unlocking the app, using the device settings.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberUnlockDeviceBiometricAuthenticator(): BiometricAuthenticator
|
||||
|
||||
/**
|
||||
* Remember a biometric authenticator ready for confirmation.
|
||||
*/
|
||||
|
||||
+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.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
@@ -69,6 +70,12 @@ class DefaultBiometricAuthenticatorManager(
|
||||
get() = lockScreenConfig.isStrongBiometricsEnabled &&
|
||||
biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
|
||||
/**
|
||||
* Returns true if a device credential method (i.e.: pattern, pin) can be used.
|
||||
*/
|
||||
private val canUseDeviceCredentialAuth: Boolean
|
||||
get() = biometricManager.canAuthenticate(BiometricManager.Authenticators.DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
|
||||
/**
|
||||
* Returns true if any biometric method (weak or strong) can be used.
|
||||
*/
|
||||
@@ -78,6 +85,9 @@ class DefaultBiometricAuthenticatorManager(
|
||||
override val isDeviceSecured: Boolean
|
||||
get() = keyguardManager.isDeviceSecure
|
||||
|
||||
override val canUseDeviceUnlock: Boolean
|
||||
get() = isDeviceSecured && (canUseWeakBiometricAuth || canUseStrongBiometricAuth || canUseDeviceCredentialAuth)
|
||||
|
||||
private val internalCallback = object : DefaultBiometricUnlockCallback() {
|
||||
override fun onBiometricSetupError() {
|
||||
coroutineScope.launch { disable() }
|
||||
@@ -99,6 +109,28 @@ class DefaultBiometricAuthenticatorManager(
|
||||
isAvailable = isAvailable,
|
||||
promptTitle = promptTitle,
|
||||
promptNegative = promptNegative,
|
||||
forDeviceUnlock = false,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun rememberUnlockDeviceBiometricAuthenticator(): BiometricAuthenticator {
|
||||
val isAvailableTrigger by remember {
|
||||
// Need to trigger the creation of BiometricAuthenticator twice, else the callback will not be ready.
|
||||
// (the issue already exists in [rememberUnlockBiometricAuthenticator])
|
||||
flowOf(false, true)
|
||||
}.collectAsState(initial = false)
|
||||
val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
|
||||
val isAvailable by remember(lifecycleState) {
|
||||
derivedStateOf { isAvailableTrigger && canUseDeviceUnlock }
|
||||
}
|
||||
val promptTitle = stringResource(id = R.string.screen_app_lock_biometric_unlock_title_android)
|
||||
val promptNegative = null
|
||||
return rememberBiometricAuthenticator(
|
||||
isAvailable = isAvailable,
|
||||
promptTitle = promptTitle,
|
||||
promptNegative = promptNegative,
|
||||
forDeviceUnlock = true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,6 +146,7 @@ class DefaultBiometricAuthenticatorManager(
|
||||
isAvailable = isAvailable,
|
||||
promptTitle = promptTitle,
|
||||
promptNegative = promptNegative,
|
||||
forDeviceUnlock = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -126,7 +159,8 @@ class DefaultBiometricAuthenticatorManager(
|
||||
private fun rememberBiometricAuthenticator(
|
||||
isAvailable: Boolean,
|
||||
promptTitle: String,
|
||||
promptNegative: String,
|
||||
promptNegative: String?,
|
||||
forDeviceUnlock: Boolean,
|
||||
): BiometricAuthenticator {
|
||||
val activity = LocalContext.current.findFragmentActivity()
|
||||
return remember(isAvailable) {
|
||||
@@ -136,11 +170,21 @@ class DefaultBiometricAuthenticatorManager(
|
||||
canUseWeakBiometricAuth -> BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||
else -> 0
|
||||
}
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder().apply {
|
||||
setTitle(promptTitle)
|
||||
setNegativeButtonText(promptNegative)
|
||||
setAllowedAuthenticators(authenticators)
|
||||
}.build()
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(promptTitle)
|
||||
.apply {
|
||||
if (promptNegative != null) {
|
||||
setNegativeButtonText(promptNegative)
|
||||
}
|
||||
if (forDeviceUnlock) {
|
||||
setAllowedAuthenticators(
|
||||
authenticators or if (canUseDeviceCredentialAuth) BiometricManager.Authenticators.DEVICE_CREDENTIAL else 0
|
||||
)
|
||||
} else {
|
||||
setAllowedAuthenticators(authenticators)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
DefaultBiometricAuthentication(
|
||||
activity = activity,
|
||||
promptInfo = promptInfo,
|
||||
|
||||
+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() {
|
||||
backstack.newRoot(NavTarget.Settings)
|
||||
}
|
||||
|
||||
override fun onCancel() {
|
||||
// Should not happen in this context.
|
||||
}
|
||||
}
|
||||
createNode<PinUnlockNode>(buildContext, plugins = listOf(callback))
|
||||
val inputs = PinUnlockNode.Inputs(
|
||||
forDeviceUnlock = false,
|
||||
)
|
||||
createNode<PinUnlockNode>(buildContext, plugins = listOf(callback, inputs))
|
||||
}
|
||||
NavTarget.SetupPin -> {
|
||||
createNode<SetupPinNode>(buildContext)
|
||||
|
||||
+9
-3
@@ -24,17 +24,23 @@ class PinUnlockHelper(
|
||||
private val pinCodeManager: PinCodeManager
|
||||
) {
|
||||
@Composable
|
||||
fun OnUnlockEffect(onUnlock: () -> Unit) {
|
||||
fun OnUnlockEffect(onUnlock: (Boolean) -> Unit) {
|
||||
val latestOnUnlock by rememberUpdatedState(onUnlock)
|
||||
DisposableEffect(Unit) {
|
||||
val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
|
||||
override fun onBiometricAuthenticationSuccess() {
|
||||
latestOnUnlock()
|
||||
latestOnUnlock(true)
|
||||
}
|
||||
|
||||
override fun onBiometricAuthenticationFailed(error: Exception?) {
|
||||
if (error != null) {
|
||||
latestOnUnlock(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
val pinCodeVerifiedCallback = object : DefaultPinCodeManagerCallback() {
|
||||
override fun onPinCodeVerified() {
|
||||
latestOnUnlock()
|
||||
latestOnUnlock(true)
|
||||
}
|
||||
}
|
||||
biometricAuthenticatorManager.addCallback(biometricUnlockCallback)
|
||||
|
||||
+14
-1
@@ -17,7 +17,9 @@ import com.bumble.appyx.core.plugin.Plugin
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
@@ -25,13 +27,23 @@ import io.element.android.libraries.di.SessionScope
|
||||
class PinUnlockNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: PinUnlockPresenter,
|
||||
presenterFactory: PinUnlockPresenter.Factory,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
interface Callback : Plugin {
|
||||
fun onUnlock()
|
||||
|
||||
// For feature unlock
|
||||
fun onCancel()
|
||||
}
|
||||
|
||||
data class Inputs(
|
||||
val forDeviceUnlock: Boolean,
|
||||
) : NodeInputs
|
||||
|
||||
private val callback: Callback = callback()
|
||||
private val inputs: Inputs = inputs()
|
||||
|
||||
private val presenter: PinUnlockPresenter = presenterFactory.create(inputs.forDeviceUnlock)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
@@ -46,6 +58,7 @@ class PinUnlockNode(
|
||||
// UnlockNode is only used for in-app unlock, so we can safely set isInAppUnlock to true.
|
||||
// It's set to false in PinUnlockActivity.
|
||||
isInAppUnlock = true,
|
||||
onCancel = callback::onCancel,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
+12
-3
@@ -16,7 +16,9 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
|
||||
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
|
||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||
@@ -32,8 +34,9 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
@AssistedInject
|
||||
class PinUnlockPresenter(
|
||||
@Assisted private val forDeviceUnlock: Boolean,
|
||||
private val pinCodeManager: PinCodeManager,
|
||||
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
|
||||
private val logoutUseCase: LogoutUseCase,
|
||||
@@ -41,6 +44,11 @@ class PinUnlockPresenter(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val pinUnlockHelper: PinUnlockHelper,
|
||||
) : Presenter<PinUnlockState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(forDeviceUnlock: Boolean): PinUnlockPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): PinUnlockState {
|
||||
val pinEntryState = remember {
|
||||
@@ -98,7 +106,7 @@ class PinUnlockPresenter(
|
||||
}
|
||||
}
|
||||
pinUnlockHelper.OnUnlockEffect {
|
||||
isUnlocked.value = true
|
||||
isUnlocked.value = it
|
||||
}
|
||||
|
||||
fun handleEvent(event: PinUnlockEvent) {
|
||||
@@ -128,6 +136,7 @@ class PinUnlockPresenter(
|
||||
}
|
||||
}
|
||||
return PinUnlockState(
|
||||
canNavigateBack = forDeviceUnlock,
|
||||
pinEntry = pinEntry,
|
||||
showWrongPinTitle = showWrongPinTitle,
|
||||
remainingAttempts = remainingAttempts,
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
|
||||
data class PinUnlockState(
|
||||
val canNavigateBack: Boolean,
|
||||
val pinEntry: AsyncData<PinEntry>,
|
||||
val showWrongPinTitle: Boolean,
|
||||
val remainingAttempts: AsyncData<Int>,
|
||||
|
||||
+11
@@ -38,10 +38,20 @@ open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
|
||||
showWrongPinTitle = true,
|
||||
isUnlocked = true,
|
||||
),
|
||||
aPinUnlockState(canNavigateBack = true),
|
||||
)
|
||||
}
|
||||
|
||||
open class PinUnlockStateCompactProvider : PreviewParameterProvider<PinUnlockState> {
|
||||
override val values: Sequence<PinUnlockState>
|
||||
get() = sequenceOf(
|
||||
aPinUnlockState(),
|
||||
aPinUnlockState(canNavigateBack = true)
|
||||
)
|
||||
}
|
||||
|
||||
fun aPinUnlockState(
|
||||
canNavigateBack: Boolean = false,
|
||||
pinEntry: AsyncData<PinEntry> = AsyncData.Success(PinEntry.createEmpty(4)),
|
||||
remainingAttempts: AsyncData<Int> = AsyncData.Success(3),
|
||||
showWrongPinTitle: Boolean = false,
|
||||
@@ -51,6 +61,7 @@ fun aPinUnlockState(
|
||||
isUnlocked: Boolean = false,
|
||||
signOutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
) = PinUnlockState(
|
||||
canNavigateBack = canNavigateBack,
|
||||
pinEntry = pinEntry,
|
||||
showWrongPinTitle = showWrongPinTitle,
|
||||
remainingAttempts = remainingAttempts,
|
||||
|
||||
+41
-14
@@ -36,6 +36,7 @@ import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -50,6 +51,7 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
@@ -65,6 +67,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
fun PinUnlockView(
|
||||
state: PinUnlockState,
|
||||
isInAppUnlock: Boolean,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
OnLifecycleEvent { _, event ->
|
||||
@@ -74,7 +77,7 @@ fun PinUnlockView(
|
||||
}
|
||||
}
|
||||
Surface(modifier) {
|
||||
PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock)
|
||||
PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock, onCancel = onCancel)
|
||||
if (state.showSignOutPrompt) {
|
||||
SignOutPrompt(
|
||||
isCancellable = state.isSignOutPromptCancellable,
|
||||
@@ -105,13 +108,14 @@ fun PinUnlockView(
|
||||
private fun PinUnlockPage(
|
||||
state: PinUnlockState,
|
||||
isInAppUnlock: Boolean,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
BoxWithConstraints {
|
||||
val commonModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.imePadding()
|
||||
.padding(all = 20.dp)
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.imePadding()
|
||||
.padding(all = 20.dp)
|
||||
|
||||
val header = @Composable {
|
||||
PinUnlockHeader(
|
||||
@@ -147,8 +151,8 @@ private fun PinUnlockPage(
|
||||
state.eventSink(PinUnlockEvent.OnPinEntryChanged(it))
|
||||
},
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -177,6 +181,15 @@ private fun PinUnlockPage(
|
||||
modifier = commonModifier,
|
||||
)
|
||||
}
|
||||
if (state.canNavigateBack) {
|
||||
BackButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.systemBarsPadding()
|
||||
.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,8 +230,8 @@ private fun PinUnlockCompactView(
|
||||
}
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
content()
|
||||
@@ -239,9 +252,9 @@ private fun PinUnlockExpandedView(
|
||||
header()
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 40.dp),
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 40.dp),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
@@ -274,8 +287,8 @@ private fun PinDot(
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.background(backgroundColor, CircleShape)
|
||||
.size(14.dp)
|
||||
.background(backgroundColor, CircleShape)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -373,6 +386,7 @@ internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider:
|
||||
PinUnlockView(
|
||||
state = state,
|
||||
isInAppUnlock = true,
|
||||
onCancel = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -384,6 +398,19 @@ internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::clas
|
||||
PinUnlockView(
|
||||
state = state,
|
||||
isInAppUnlock = false,
|
||||
onCancel = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(heightDp = 480, widthDp = 800)
|
||||
internal fun PinUnlockViewCompactPreview(@PreviewParameter(PinUnlockStateCompactProvider::class) state: PinUnlockState) {
|
||||
ElementPreview {
|
||||
PinUnlockView(
|
||||
state = state,
|
||||
isInAppUnlock = false,
|
||||
onCancel = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+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 appPreferencesStore: AppPreferencesStore
|
||||
@Inject lateinit var featureFlagService: FeatureFlagService
|
||||
@@ -52,6 +52,7 @@ class PinUnlockActivity : AppCompatActivity() {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
bindings<PinUnlockBindings>().inject(this)
|
||||
val presenter = presenterFactory.create(forDeviceUnlock = false)
|
||||
setContent {
|
||||
val colors by remember {
|
||||
enterpriseService.semanticColorsFlow(sessionId = null)
|
||||
@@ -67,6 +68,9 @@ class PinUnlockActivity : AppCompatActivity() {
|
||||
PinUnlockView(
|
||||
state = state,
|
||||
isInAppUnlock = false,
|
||||
onCancel = {
|
||||
// Should not happen
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -8,10 +8,18 @@
|
||||
|
||||
package io.element.android.features.lockscreen.impl.biometric
|
||||
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
|
||||
class FakeBiometricAuthenticator(
|
||||
override val isActive: Boolean = false,
|
||||
private val authenticateLambda: suspend () -> BiometricAuthenticator.AuthenticationResult = { BiometricAuthenticator.AuthenticationResult.Success },
|
||||
private val setupLambda: () -> Unit = { },
|
||||
private val authenticateLambda: () -> BiometricAuthenticator.AuthenticationResult = { BiometricAuthenticator.AuthenticationResult.Success },
|
||||
) : BiometricAuthenticator {
|
||||
override suspend fun setup() = Unit
|
||||
override suspend fun authenticate() = authenticateLambda()
|
||||
override suspend fun setup() = simulateLongTask {
|
||||
setupLambda()
|
||||
}
|
||||
|
||||
override suspend fun authenticate() = simulateLongTask {
|
||||
authenticateLambda()
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -12,8 +12,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
class FakeBiometricAuthenticatorManager(
|
||||
override var isDeviceSecured: Boolean = true,
|
||||
override var hasAvailableAuthenticator: Boolean = false,
|
||||
override val isDeviceSecured: Boolean = true,
|
||||
override val canUseDeviceUnlock: Boolean = true,
|
||||
override val hasAvailableAuthenticator: Boolean = false,
|
||||
private val createBiometricAuthenticator: () -> BiometricAuthenticator = { FakeBiometricAuthenticator() },
|
||||
private val disableLambda: suspend () -> Unit = { },
|
||||
) : BiometricAuthenticatorManager {
|
||||
@@ -28,7 +29,14 @@ class FakeBiometricAuthenticatorManager(
|
||||
@Composable
|
||||
override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
|
||||
return remember {
|
||||
createBiometricAuthenticator()
|
||||
createBiometricAuthenticator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun rememberUnlockDeviceBiometricAuthenticator(): BiometricAuthenticator {
|
||||
return remember {
|
||||
createBiometricAuthenticator()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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) {
|
||||
dataOrNull()?.assertText(text)
|
||||
}
|
||||
|
||||
private suspend fun TestScope.createPinUnlockPresenter(
|
||||
forDeviceUnlock: Boolean = false,
|
||||
biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
|
||||
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
|
||||
logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = {}),
|
||||
@@ -162,6 +188,7 @@ class PinUnlockPresenterTest {
|
||||
createPinCode(completePin)
|
||||
}
|
||||
return PinUnlockPresenter(
|
||||
forDeviceUnlock = forDeviceUnlock,
|
||||
pinCodeManager = pinCodeManager,
|
||||
biometricAuthenticatorManager = biometricAuthenticatorManager,
|
||||
logoutUseCase = logoutUseCase,
|
||||
|
||||
+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 {
|
||||
data object Uninitialized : LinkMobileStep
|
||||
|
||||
// Internal application step, for the UI
|
||||
data object CreatingQrCode : LinkMobileStep
|
||||
data object Starting : LinkMobileStep
|
||||
data class QrReady(val data: String) : LinkMobileStep
|
||||
data object QrRotating : LinkMobileStep
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7790ba46051ba39863f057bc53c937f513f91b6094094b2b449794d7de3aa875
|
||||
size 32833
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bc7032e0d69a44d8ab2d3187183eb77cc28d7a5669fd7e56ee99d4f90ee05d62
|
||||
size 33137
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:25ca16b3574c5cf850e06099312d4af294561c8c9833742a0b3902bc3b35cb39
|
||||
size 20669
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e58621f378a1abac78c5bdfb6a31675c1f762f53434f28b869ac8c7b6f9ad8ad
|
||||
size 20109
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a60781d3bc38ed6c2ed830464f447d509f88a9767b81dea3acbd29ea21b5c0f6
|
||||
size 36212
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5d115c0f6d4c705dce1062aa2dbb026980df5efed8d8b2500774e6f75cbe366f
|
||||
size 34987
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:de82970da241172b94f822f8792e6fc721dcfc7f0f67e3523962e253e9750884
|
||||
size 255933
|
||||
oid sha256:1885bbe8b0ea497ac20d7bcdd0c6a93ba0533e898378d35e1cafdc8c9924fd56
|
||||
size 279984
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1ca032575e4ebd75a2ebc5a4cb9f966e48cdfa56f4ff42d6d9309fa3577a1adb
|
||||
size 310609
|
||||
oid sha256:fe6cb72de5079be2ac02ec39f74bac0d20492cd6f5509b71ad9cfc4cc52955d3
|
||||
size 326468
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3274314ab9e39c23a6b9d66adf80ac8bb7b364ce60a300f96582374a18bc821f
|
||||
size 54590
|
||||
oid sha256:3dbb8ef39b6a7a0c2f576d8bd101aa97c89da696f23e6d5836846494471c01a1
|
||||
size 55799
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f1dd0da147212e13a6c0ad5ad8f85ce55666c444076c238ea27eba5605cfab1f
|
||||
size 56909
|
||||
oid sha256:5e27c7c51fe07cca51b92a601aa9c58deab609bfa5e82f8f6f88eaab6ae59ec3
|
||||
size 64307
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:65f9ee083a73669a0d3f9d88141a610ea53f3c39e89d8f706d414b04555b4764
|
||||
size 59321
|
||||
oid sha256:085963fcb425147d35a9ad947a7a3e4ba4712bd3d2f32e93099ff15cffe83b88
|
||||
size 64761
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7a3667390d9d88894bbdd5d72b0b714b29c9ff160653e310e97b0da87b6ba8b0
|
||||
size 54724
|
||||
oid sha256:8a94a4cc176b5d52ef9b21339749324adeef9857b6cdaa2ae973903e2408662a
|
||||
size 55904
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6f7f7415abbe51858f938e16ad03c6aab983fb3d5e2a53a028bae3c031446646
|
||||
size 67848
|
||||
oid sha256:28512b0fda0b71b98eab03cee15c9d3163825b1a3545670cfa277ddd5434c87a
|
||||
size 58417
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6ed6a5f1a2055e19a6b8fb0af4738845bd2184f755c323022c52154cdfa07e05
|
||||
size 60909
|
||||
oid sha256:8a8f5aaaa7ebb0e61b07867590d99a8791c5d04cad1ce0f334d23dce6f26c6e6
|
||||
size 67276
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f77d8243c23d3f37dff86dec53671cbde9fafe1314f6e6b445abdfe47d751ad6
|
||||
size 56187
|
||||
oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
|
||||
size 3657
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:87193a7c9eb9d40234970e5f5be02e80f2b3ffe91e2c9ca6afd58b8bccdd5911
|
||||
size 55075
|
||||
oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
|
||||
size 3657
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
|
||||
size 3657
|
||||
oid sha256:49f0bb16d3d57b59242e704e3dd4e8885c59721a42129837ff6a4f0d7d02de11
|
||||
size 54433
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8ebee4aec3dbab6e1bb05fda7c5e423f0e931560743d0f0ccbac6b70fa9ad808
|
||||
size 52921
|
||||
oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
|
||||
size 3657
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4420dfe9f6f95411093eb1c17825a389a31d7a5d5ef146129e5872cdd5d87aff
|
||||
size 295295
|
||||
oid sha256:86eac241e9084cc830ff13d231908da2eb0dff246aa5cac64dddaac68a5d5f13
|
||||
size 295298
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:578a3707102c7754f607c3a14f94f32cafa30ee570db174386533c484cb4773c
|
||||
size 295031
|
||||
oid sha256:7076c9a44a433cd9eeca536365ae262d39b4ad99a8f854faadec272cb1b82031
|
||||
size 294992
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3f358da2b9b0521c14e525bcb2bd41c29c96aacaf5f007546e6d362bde2ee3fb
|
||||
size 612526
|
||||
oid sha256:d6441c572f64d99bc119d649e5e6422fe516b9d097f310ccbd99a9ddded9609f
|
||||
size 612494
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a709de55bc68cf12064ebdbde92c0a99bef3252958d7966540458d253e8c12c2
|
||||
size 611422
|
||||
oid sha256:5a867dda82242d2b13bd4bc84a9f1c9b8b7b99c030e19a6cce39b2f828b517f3
|
||||
size 611488
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:54e71f45db31e8664c42f8c05feb91ae839589e00cb89d12088c15c85fb9f25f
|
||||
size 607824
|
||||
oid sha256:e3bb005a88e9a4d2bb850478ee8ac2c4f31d32625bd6d2abf455fade461cd399
|
||||
size 607806
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8bb505fe6d67669339c9366f8ee5bc75974e223837d95e76e637455a51c18711
|
||||
size 606787
|
||||
oid sha256:4a2a57f5547f98b9ac2d8bf7195a1e4b49e03834e8866c6cc76660a6a3b16e74
|
||||
size 606823
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ff839731834d7d0c94fa9a2c412014ddbaeaa053c5af1c8cbd00a3f3a583f364
|
||||
size 379511
|
||||
oid sha256:94f89a37549a3f198442db467bc7d8539f0b5649d97927bfe731f090d6dfb03d
|
||||
size 379505
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d0e170b4bea262de612bf3374d475fcd25470ff91b7554d20809c51857a78cec
|
||||
size 377963
|
||||
oid sha256:04ea6c2947a21d3d4a3d43e9c4750c4f2a49056536a4da3f34c95df8353e9de3
|
||||
size 377990
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1a94d5198e27338675c0e15df5874ba3272ee315b8d77291dc7ccc7c754ed1f4
|
||||
size 365082
|
||||
oid sha256:b8bb5a6de6601da8d7fe59f110a054577e2e25dae4bb449f60c04e7d7a1b9551
|
||||
size 365073
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8344427a0fe87cbf0883ff53a9ee4dcf90976a7011f6618d85961df48b2c53a9
|
||||
size 370228
|
||||
oid sha256:76e5ae44362792d570cd82f3910c8bd0acde509b8418c5892e0385e7d636f042
|
||||
size 370223
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:60b9f2b920cd2a2a414805d1f0f77925232d9ae60bd607fa18d4395cda2dd73d
|
||||
size 363291
|
||||
oid sha256:00fe1e676086766a3eac3e4457eff52c989789fc5d8d0df2624c019ed25ce9af
|
||||
size 363322
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c00e504593d8eb61b598f05f46ee4d591757975a80c0b4227b240847d2cd4868
|
||||
size 368510
|
||||
oid sha256:2f0d24b8011564ee7a8b2f8c2e6a24eda668ee42d451d862f884acb7926aa06c
|
||||
size 368529
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:36d7cf2510d546d2b330b1d1b1674ce58df0558c547e443794a1e7b2222add87
|
||||
size 347897
|
||||
oid sha256:7c609a633ab23eae5729ecb57a17a385bac65e81cbf0180c01e0847f1028039e
|
||||
size 347890
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a4ac79f3b8fb4e231244bd96b244a4f2526d6ed28d558f9d50734d83bbb74992
|
||||
size 363909
|
||||
oid sha256:991deacdfb7bc371a2df960a2364ae3b2187e588aa5afe7f39409e367040dd9c
|
||||
size 363893
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:787151f8f242a8794a804bf14831f6ca680814d617e85feb3652103e8a0f20cd
|
||||
size 346565
|
||||
oid sha256:e30686c7c037ef102717e0316543faf4d3ed2671c21eca96a667150daab380d6
|
||||
size 346614
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c1e15400fa2c5ee6087316501445c551a3932b65e16de31e68acc5efbd16a559
|
||||
size 361603
|
||||
oid sha256:2cd357a1fbaa425eca95587bff2f768a45e773f983f75664875653ba1900e534
|
||||
size 361625
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f4431e2d9f30123949ea5676ce60f642234adcf9b5a33f2acc3f80ebf6609a66
|
||||
size 369730
|
||||
oid sha256:ef2bf449671b652cb152b3baab92ab100eab77a54229ab49daeab156f2f484d0
|
||||
size 369724
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:273351cc9c533b2424859a0de00a8a3786628abc3a88175705c4205dedf7e9aa
|
||||
size 354737
|
||||
oid sha256:f7a6302320bd1e4258e03487539bf46b0b12781dfc8a22b3f9f267017a6146fa
|
||||
size 354751
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:41a0e404dea7f52359b24b3ec3926b1405429b165cfed44c63b3922ea6d3cb46
|
||||
size 368114
|
||||
oid sha256:4c410e853089cc2c4a2c48a00dd9a8babc8095aed72bb4625057444710559c6c
|
||||
size 368129
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6ca8cf82aa8ab1bdad56a27584b27789402aeb7ad8fadd038280a376fb75a72c
|
||||
size 345094
|
||||
oid sha256:1a92f8af94682facac66d4aacb6e49f345561c15be2f8b689041b78aeaaae831
|
||||
size 345062
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3a9509411777f342261cab58b92a022b3c9f8f0f7a9e4d0527120a91976575f0
|
||||
size 357733
|
||||
oid sha256:02ecb903124273e24151ea2088f6b99144f7dcadba00a582b6210dd23835b5c9
|
||||
size 357743
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dce23463c335d01eb67293cfcede5d85e96ab2ba035cc9a188d42e8e6866bc63
|
||||
size 356831
|
||||
oid sha256:f0c6dafaa41849af00d5097a85bd88e3a054a0a9e41c0466a08e6ff8dd6102b2
|
||||
size 356857
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a11044fe90227f1f316a1ade8587e545bb620e11cca76865fc2eb83cdb817fc5
|
||||
size 364655
|
||||
oid sha256:588967869cbd25ed8b82aff5618175660a8a9e45a5a861b1c7867cf1913f575b
|
||||
size 364646
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:426d1c844a8c88281c5fa4c61916d93278fdfd38f8cdf2dc54bf1a821f2de3eb
|
||||
size 397167
|
||||
oid sha256:2528df873713755f542f61cd794080929586c1bd5f316c0378ce9e837c21d50b
|
||||
size 397145
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a6cd94664f89bdad2a9761ea8b20acb83fed3ed7cb5be41af8fe4852643c0bfc
|
||||
size 356175
|
||||
oid sha256:a2bd77c08be705c66225f16c29fd6826decd92b41922d0f8ffc0d1a63d8c59d2
|
||||
size 356193
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ffa68c8398d789444151855496e5df4ffa03218a383db6ea46fd4f979829b823
|
||||
size 355831
|
||||
oid sha256:9daeee1aaa04247da3c8bb5aaae9947aab1ed2d2057995e53d6ebdb63e5cd356
|
||||
size 355848
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a00bd766a00b7e7c6615fc53bf469b3ca5056a6c514c096431ae38d99d604306
|
||||
size 364845
|
||||
oid sha256:34152c742abc486a48234e22101833bc163cf11979890d06aac41e68e3c404d0
|
||||
size 364846
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3ec1b0244dab22414c6719b3309426e13128d0c70fb67dc66cd8a60d42521fd0
|
||||
size 355411
|
||||
oid sha256:7b23d3a37774283067a8919b8c332b1bb5a79e90dceb7216b484b985491282e5
|
||||
size 355389
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a1df8d22766981fc76d6d767dc3f40d98cadc7c51be20417b067b91af604232b
|
||||
size 367884
|
||||
oid sha256:5c3d87862c6d7fc88d0f23cd54c5b9f77028992fdd7c282a195b964dd17e85ac
|
||||
size 367855
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fe0daf9161bc2da194992a2fc9505bf2009c5dbed78cdb35d2eb7ed1380a7df5
|
||||
size 353037
|
||||
oid sha256:f7d9b5fc0ff5d7828ad6f4dc0c9bd9520b4920553da97ed7b4e030ee844c0208
|
||||
size 353054
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9a85db2f5958db867578663533f204b2e803372d470da890fd8056ffed0cf46c
|
||||
size 366254
|
||||
oid sha256:6a92bcf5885322e11a706071969fb674b1f6e60e021af153aaa125baa4a602ae
|
||||
size 366285
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4bee12a6bdb18fea5ac6997671bfcb903296da7278cf69b2456330066ca31dfb
|
||||
size 342870
|
||||
oid sha256:54372845e922d047408dd960d048b9b5eae2263d589117d8e7d197741aa34f32
|
||||
size 342827
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:af4a48ee80535f2c0a226c4ffa4344054fb63b7b0a88ea2c030a4eb8dd5e9644
|
||||
size 356078
|
||||
oid sha256:8e82011a72f36889c53ab61ed2b9947c7fb5f868c9b7010904fc10f4ca587082
|
||||
size 356082
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9e2d8caf5aaae7d3406c703288e491f328552f0acd18e56afd5265b4ceaf1cd1
|
||||
size 355272
|
||||
oid sha256:6524cc87257cc9d3f135904f4e14594b56df695e71fe576418d2319c472d710b
|
||||
size 355285
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:66d4b6ea4a697735bbb0fac936b89cde99c5077973ba828894bf50b5dae233b3
|
||||
size 362848
|
||||
oid sha256:93c2df04d65f6078ce3f7067fbae8a7317ec42ed0fc013dee962a3953269f3b7
|
||||
size 362813
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:217ac95bf476c53d2dc6d3d0143b21312bf38b8ab86c8dfd4f9360582b584f9d
|
||||
size 395476
|
||||
oid sha256:e642229c58169775fe702769a306bbef22698516ae6f7ac75a761213c3f195fa
|
||||
size 395446
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5fae87094ea13b14e2dc7c16ca96e4f8096ff39e962799468fb4ead4c666620f
|
||||
size 354381
|
||||
oid sha256:08057fdc1e1184720b438ddd2a8753f00791baf2456cc30b45de8e6bdeb57e8e
|
||||
size 354404
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dabad9dcd5b8f0d4bb964a0187ad7c94b023b04552dacd29a077daddfd48d550
|
||||
size 354052
|
||||
oid sha256:5ed24d190f10c079f8cf176404372d40eb25cc78ce595a22febc540315057b86
|
||||
size 354075
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:545c9e8933e420cc3f8fbba0fbd5162c946c65a20721faeb77c568596ca720cd
|
||||
size 363045
|
||||
oid sha256:66aba7b4271a990fac85b0bbc7cbdc371fa1e38a4da5bb4e2f62a80b21cb3a8f
|
||||
size 363012
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:756f0cff90ddd61adce2f467c0384d74d430f6d51c091e4980e45bcc9ee6f0fd
|
||||
size 353705
|
||||
oid sha256:e614f4a8657637c584a283d69124236d00fe1251c2b8d739c5688a57a6fd152e
|
||||
size 353693
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0dd30568e628750f922a87c2df1fd7751e9d899b9163253c9f2a0c7b03e4a869
|
||||
size 56247
|
||||
oid sha256:179b30927c3db3963bef12243ae69908ad0b99275906197abd02c110f012213f
|
||||
size 57267
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a94d9346e04d59f90cfea7ac2ef4d71003fc7e2eef8cf8627257f7cf0aea6457
|
||||
size 50445
|
||||
oid sha256:dc02a7c7b38753d9509b3cb0fb2eb0e29b6d6c79b8471ba4dc7dd5b81b13d15b
|
||||
size 50892
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b7fdd2e68114457368c5d19fe117b2d5a86a02ca475925c9fae0269ff92f5144
|
||||
size 66312
|
||||
oid sha256:44fd4c046c89628b32d85c7732ef2ed6a50b3e02928bd2409b6bfbb0c6aa3fd9
|
||||
size 59064
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f966bd2eade0180013c2c019963ad9f89c04f16cadcc04954bf3a4427c854635
|
||||
size 40002
|
||||
oid sha256:d1a28720d554bc4698eb52e27e7c6228ac9c14b3703a8c139c3113f8b8fa284e
|
||||
size 40355
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user