diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts
index aa0bc04772..84d614b3d9 100644
--- a/appnav/build.gradle.kts
+++ b/appnav/build.gradle.kts
@@ -48,6 +48,7 @@ dependencies {
implementation(projects.features.announcement.api)
implementation(projects.features.ftue.api)
+ implementation(projects.features.linknewdevice.api)
implementation(projects.features.share.api)
implementation(projects.services.apperror.impl)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
index 38dc39ee83..92c3cfe3dd 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
@@ -53,6 +53,7 @@ import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.home.api.HomeEntryPoint
+import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
@@ -123,6 +124,7 @@ class LoggedInFlowNode(
private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val userProfileEntryPoint: UserProfileEntryPoint,
private val ftueEntryPoint: FtueEntryPoint,
+ private val linkNewDeviceEntryPoint: LinkNewDeviceEntryPoint,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val ftueService: FtueService,
@@ -293,6 +295,9 @@ class LoggedInFlowNode(
@Parcelize
data object Ftue : NavTarget
+ @Parcelize
+ data object LinkNewDevice : NavTarget
+
@Parcelize
data object RoomDirectory : NavTarget
@@ -419,6 +424,10 @@ class LoggedInFlowNode(
callback.navigateToAddAccount()
}
+ override fun navigateToLinkNewDevice() {
+ backstack.push(NavTarget.LinkNewDevice)
+ }
+
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
@@ -475,6 +484,14 @@ class LoggedInFlowNode(
NavTarget.Ftue -> {
ftueEntryPoint.createNode(this, buildContext)
}
+ NavTarget.LinkNewDevice -> {
+ val callback = object : LinkNewDeviceEntryPoint.Callback {
+ override fun onDone() {
+ backstack.pop()
+ }
+ }
+ linkNewDeviceEntryPoint.createNode(this, buildContext, callback)
+ }
NavTarget.RoomDirectory -> {
roomDirectoryEntryPoint.createNode(
parentNode = this,
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt
index e4e922f933..8f72f038de 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt
@@ -25,6 +25,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.atomic.atoms.LoadingButtonAtom
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
@@ -111,13 +112,7 @@ private fun ChooseSelfVerificationModeButtons(
AsyncData.Uninitialized,
is AsyncData.Failure,
is AsyncData.Loading -> {
- Button(
- modifier = Modifier.fillMaxWidth(),
- enabled = false,
- showProgress = true,
- text = stringResource(CommonStrings.common_loading),
- onClick = {},
- )
+ LoadingButtonAtom()
}
is AsyncData.Success -> {
if (state.buttonsState.data.canUseAnotherDevice) {
diff --git a/features/linknewdevice/api/build.gradle.kts b/features/linknewdevice/api/build.gradle.kts
new file mode 100644
index 0000000000..7d368f0a63
--- /dev/null
+++ b/features/linknewdevice/api/build.gradle.kts
@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2025 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.
+ */
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.linknewdevice.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+}
diff --git a/features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt b/features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt
new file mode 100644
index 0000000000..061bbeb084
--- /dev/null
+++ b/features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2025 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.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
+
+interface LinkNewDeviceEntryPoint : FeatureEntryPoint {
+ interface Callback : Plugin {
+ fun onDone()
+ }
+
+ fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ callback: Callback,
+ ): Node
+}
diff --git a/features/linknewdevice/impl/build.gradle.kts b/features/linknewdevice/impl/build.gradle.kts
new file mode 100644
index 0000000000..9c1aa9e990
--- /dev/null
+++ b/features/linknewdevice/impl/build.gradle.kts
@@ -0,0 +1,63 @@
+import extension.setupDependencyInjection
+import extension.testCommonDependencies
+
+/*
+ * Copyright (c) 2025 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.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ id("kotlin-parcelize")
+ alias(libs.plugins.kotlin.serialization)
+}
+
+android {
+ namespace = "io.element.android.features.linknewdevice.impl"
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+}
+
+setupDependencyInjection()
+
+dependencies {
+ // TODO Cleanup
+ implementation(projects.appconfig)
+ implementation(projects.features.enterprise.api)
+ implementation(projects.features.rageshake.api)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.featureflag.api)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.testtags)
+ implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.permissions.api)
+ implementation(projects.libraries.sessionStorage.api)
+ implementation(projects.libraries.qrcode)
+ implementation(projects.libraries.oidc.api)
+ implementation(projects.libraries.uiUtils)
+ implementation(projects.libraries.wellknown.api)
+ implementation(libs.androidx.browser)
+ implementation(libs.androidx.webkit)
+ implementation(libs.serialization.json)
+ api(projects.features.linknewdevice.api)
+
+ testCommonDependencies(libs, true)
+ testImplementation(projects.features.linknewdevice.test)
+ testImplementation(projects.features.enterprise.test)
+ testImplementation(projects.libraries.featureflag.test)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.libraries.oidc.test)
+ testImplementation(projects.libraries.permissions.test)
+ testImplementation(projects.libraries.sessionStorage.test)
+ testImplementation(projects.libraries.wellknown.test)
+}
diff --git a/features/linknewdevice/impl/src/main/AndroidManifest.xml b/features/linknewdevice/impl/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..d225716fc4
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt
new file mode 100644
index 0000000000..5edc3cdfcd
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2025 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
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.SessionScope
+
+@ContributesBinding(SessionScope::class)
+class DefaultLinkNewDeviceEntryPoint : LinkNewDeviceEntryPoint {
+ override fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ callback: LinkNewDeviceEntryPoint.Callback,
+ ): Node {
+ return parentNode.createNode(
+ buildContext = buildContext,
+ plugins = listOf(
+ callback,
+ )
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt
new file mode 100644
index 0000000000..a8a8ff14b6
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2025 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
+
+import dev.zacsweers.metro.Inject
+import dev.zacsweers.metro.SingleIn
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
+import io.element.android.libraries.matrix.api.logs.LoggerTags
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice)
+
+@Inject
+@SingleIn(SessionScope::class)
+class LinkNewDesktopHandler(
+ private val matrixClient: MatrixClient,
+) {
+ private val sessionScope = matrixClient.sessionCoroutineScope
+ private val linkDesktopStepFlow = MutableStateFlow(
+ LinkDesktopStep.Uninitialized
+ )
+
+ val stepFlow: StateFlow
+ get() = linkDesktopStepFlow.asStateFlow()
+
+ private var currentJob: Job? = null
+ private var handler: LinkDesktopHandler? = null
+
+ fun createNewHandler() {
+ currentJob?.cancel()
+ currentJob = null
+ handler = matrixClient.createLinkDesktopHandler().getOrNull()
+ }
+
+ fun reset() {
+ currentJob?.cancel()
+ currentJob = null
+ sessionScope.launch {
+ linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized)
+ }
+ }
+
+ fun onScannedCode(data: ByteArray) {
+ currentJob?.cancel()
+ currentJob = null
+ val currentHandler = handler
+ if (currentHandler == null) {
+ Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.")
+ } else {
+ currentJob = matrixClient.sessionCoroutineScope.launch {
+ currentHandler.linkDesktopStep.onEach {
+ linkDesktopStepFlow.emit(it)
+ }.launchIn(this)
+ currentHandler.handleScannedQrCode(data)
+ }
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt
new file mode 100644
index 0000000000..23c6b6ab2d
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt
@@ -0,0 +1,287 @@
+/*
+ * Copyright (c) 2025 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
+
+import android.app.Activity
+import android.os.Parcelable
+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.lifecycle.subscribe
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.newRoot
+import com.bumble.appyx.navmodel.backstack.operation.pop
+import com.bumble.appyx.navmodel.backstack.operation.push
+import com.bumble.appyx.navmodel.backstack.operation.replace
+import dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.annotations.ContributesNode
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
+import io.element.android.features.linknewdevice.impl.screens.desktop.DesktopNoticeNode
+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.LinkNewDeviceRootNode
+import io.element.android.features.linknewdevice.impl.screens.scan.ScanQrCodeNode
+import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
+import io.element.android.libraries.architecture.BackstackView
+import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.di.annotations.SessionCoroutineScope
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import io.element.android.libraries.matrix.api.logs.LoggerTags
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.parcelize.Parcelize
+import timber.log.Timber
+
+private val tag = LoggerTag("LinkNewDeviceFlowNode", LoggerTags.linkNewDevice)
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class LinkNewDeviceFlowNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ @SessionCoroutineScope
+ private val sessionCoroutineScope: CoroutineScope,
+ private val linkNewMobileHandler: LinkNewMobileHandler,
+ private val linkNewDesktopHandler: LinkNewDesktopHandler,
+) : BaseFlowNode(
+ backstack = BackStack(
+ initialElement = NavTarget.Root,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins,
+) {
+ private val callback: LinkNewDeviceEntryPoint.Callback = callback()
+ private var activity: Activity? = null
+ private var darkTheme: Boolean = false
+
+ override fun onBuilt() {
+ super.onBuilt()
+ var linkMobileHandlerJob: Job? = null
+ var linkDesktopHandlerJob: Job? = null
+
+ lifecycle.subscribe(
+ onCreate = {
+ linkNewMobileHandler.reset()
+ linkNewDesktopHandler.reset()
+ @Suppress("AssignedValueIsNeverRead")
+ linkMobileHandlerJob = observeLinkNewMobileHandler()
+ @Suppress("AssignedValueIsNeverRead")
+ linkDesktopHandlerJob = observeLinkNewDesktopHandler()
+ },
+ onDestroy = {
+ linkMobileHandlerJob?.cancel()
+ linkDesktopHandlerJob?.cancel()
+ }
+ )
+ }
+
+ sealed interface NavTarget : Parcelable {
+ // Will display the not supported state or the device type selection
+ @Parcelize
+ data object Root : NavTarget
+
+ @Parcelize
+ data class MobileShowQrCode(
+ val data: String,
+ ) : NavTarget
+
+ @Parcelize
+ data object MobileEnterNumber : NavTarget
+
+ @Parcelize
+ data object DesktopNotice : NavTarget
+
+ @Parcelize
+ data object DesktopScanQrCode : NavTarget
+
+ @Parcelize
+ data class Error(
+ val errorScreenType: ErrorScreenType,
+ ) : NavTarget
+ }
+
+ private fun observeLinkNewMobileHandler(): Job {
+ Timber.tag(tag.value).d("startObservingLinkNewMobileHandler")
+ return linkNewMobileHandler.stepFlow
+ .onEach { linkMobileStep ->
+ Timber.tag(tag.value).d("step: ${linkMobileStep::class.java.simpleName}")
+ when (linkMobileStep) {
+ LinkMobileStep.Uninitialized -> Unit
+ LinkMobileStep.Done -> {
+ callback.onDone()
+ }
+ is LinkMobileStep.Error -> {
+ navigateToError(linkMobileStep.errorType)
+ }
+ is LinkMobileStep.QrReady -> {
+ // The QrCode is ready, navigate to its display
+ backstack.push(NavTarget.MobileShowQrCode(linkMobileStep.data))
+ }
+ is LinkMobileStep.QrScanned -> {
+ backstack.replace(NavTarget.MobileEnterNumber)
+ }
+ LinkMobileStep.Starting -> {
+ // This step is not received at the moment, so do nothing
+ }
+ LinkMobileStep.SyncingSecrets -> {
+ // LinkMobileStep.Done is not received at the moment, so consider that the flow is done here
+ callback.onDone()
+ }
+ is LinkMobileStep.WaitingForAuth -> {
+ navigateToBrowser(linkMobileStep.verificationUri)
+ }
+ }
+ }
+ .launchIn(sessionCoroutineScope)
+ }
+
+ private fun observeLinkNewDesktopHandler(): Job {
+ Timber.tag(tag.value).d("startObservingLinkNewDesktopHandler")
+ return linkNewDesktopHandler.stepFlow.onEach { linkDesktopStep ->
+ Timber.tag(tag.value).d("step: ${linkDesktopStep::class.java.simpleName}")
+ when (linkDesktopStep) {
+ LinkDesktopStep.Done -> callback.onDone()
+ is LinkDesktopStep.Error -> {
+ navigateToError(linkDesktopStep.errorType)
+ }
+ is LinkDesktopStep.EstablishingSecureChannel -> Unit
+ is LinkDesktopStep.InvalidQrCode -> {
+ // This error will be handled by the ScanQrCodeNode
+ }
+ LinkDesktopStep.Starting -> Unit
+ LinkDesktopStep.SyncingSecrets -> Unit
+ LinkDesktopStep.Uninitialized -> Unit
+ is LinkDesktopStep.WaitingForAuth -> {
+ navigateToBrowser(linkDesktopStep.verificationUri)
+ }
+ }
+ }
+ .launchIn(sessionCoroutineScope)
+ }
+
+ private fun navigateToError(errorType: ErrorType) {
+ // Map the error to an error screen
+ // TODO Update this mapping
+ val error = when (errorType) {
+ is ErrorType.DeviceIdAlreadyInUse -> ErrorScreenType.UnknownError
+ is ErrorType.InvalidCheckCode -> ErrorScreenType.InsecureChannelDetected
+ is ErrorType.MissingSecretsBackup -> ErrorScreenType.UnknownError
+ is ErrorType.NotFound -> ErrorScreenType.Expired
+ is ErrorType.UnableToCreateDevice -> ErrorScreenType.UnknownError
+ is ErrorType.Unknown -> ErrorScreenType.UnknownError
+ is ErrorType.UnsupportedProtocol -> ErrorScreenType.UnknownError
+ }
+ // It is OK to push on backstack, since when user leaves the error screen, a new root will be set
+ backstack.push(NavTarget.Error(error))
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.Root -> {
+ val callback = object : LinkNewDeviceRootNode.Callback {
+ override fun onDone() {
+ callback.onDone()
+ }
+
+ override fun linkDesktopDevice() {
+ linkNewDesktopHandler.reset()
+ backstack.push(NavTarget.DesktopNotice)
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
+ NavTarget.DesktopNotice -> {
+ val callback = object : DesktopNoticeNode.Callback {
+ override fun navigateBack() {
+ backstack.pop()
+ }
+
+ override fun navigateToQrCodeScanner() {
+ backstack.push(NavTarget.DesktopScanQrCode)
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
+ NavTarget.DesktopScanQrCode -> {
+ val callback = object : ScanQrCodeNode.Callback {
+ override fun cancel() {
+ backstack.pop()
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
+ NavTarget.MobileEnterNumber -> {
+ val callback = object : EnterNumberNode.Callback {
+ override fun navigateToWrongNumberError() {
+ backstack.push(NavTarget.Error(ErrorScreenType.Mismatch2Digits))
+ }
+
+ override fun navigateBack() {
+ backstack.pop()
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
+ is NavTarget.MobileShowQrCode -> {
+ val callback = object : ShowQrCodeNode.Callback {
+ override fun navigateBack() {
+ linkNewMobileHandler.reset()
+ backstack.pop()
+ }
+ }
+ val inputs = ShowQrCodeNode.Inputs(
+ data = navTarget.data,
+ )
+ createNode(buildContext, listOf(inputs, callback))
+ }
+ is NavTarget.Error -> {
+ val callback = object : ErrorNode.Callback {
+ override fun onRetry() {
+ linkNewMobileHandler.reset()
+ linkNewDesktopHandler.reset()
+ backstack.newRoot(NavTarget.Root)
+ }
+ }
+ createNode(buildContext, listOf(callback, navTarget.errorScreenType))
+ }
+ }
+ }
+
+ private fun navigateToBrowser(url: String) {
+ activity?.openUrlInChromeCustomTab(null, darkTheme, url)
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ activity = requireNotNull(LocalActivity.current)
+ darkTheme = !ElementTheme.isLightTheme
+ DisposableEffect(Unit) {
+ onDispose {
+ activity = null
+ }
+ }
+ BackstackView()
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt
new file mode 100644
index 0000000000..157d946eaa
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2025 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
+
+import dev.zacsweers.metro.Inject
+import dev.zacsweers.metro.SingleIn
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import io.element.android.libraries.matrix.api.logs.LoggerTags
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+private val loggerTag = LoggerTag("LinkNewMobileHandler", LoggerTags.linkNewDevice)
+
+@Inject
+@SingleIn(SessionScope::class)
+class LinkNewMobileHandler(
+ private val matrixClient: MatrixClient,
+) {
+ private val sessionScope = matrixClient.sessionCoroutineScope
+ private var currentJob: Job? = null
+ private var handler: LinkMobileHandler? = null
+
+ private val linkMobileStepFlow = MutableStateFlow(
+ LinkMobileStep.Uninitialized
+ )
+
+ val stepFlow: StateFlow
+ get() = linkMobileStepFlow.asStateFlow()
+
+ fun createAndStartNewHandler() {
+ Timber.tag(loggerTag.value).d("createAndStartNewHandler()")
+ currentJob?.cancel()
+ handler = matrixClient.createLinkMobileHandler().getOrNull()
+ handler?.let { h ->
+ currentJob = sessionScope.launch {
+ h.linkMobileStep
+ .onEach {
+ linkMobileStepFlow.emit(it)
+ }
+ .launchIn(this)
+ h.start()
+ }
+ }
+ }
+
+ fun reset() {
+ currentJob?.cancel()
+ currentJob = null
+ sessionScope.launch {
+ linkMobileStepFlow.emit(LinkMobileStep.Uninitialized)
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt
new file mode 100644
index 0000000000..3d94da3fc6
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright (c) 2025 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.desktop
+
+sealed interface DesktopNoticeEvent {
+ data object Continue : DesktopNoticeEvent
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt
new file mode 100644
index 0000000000..895d02731a
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2025 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.desktop
+
+import androidx.compose.runtime.Composable
+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.libraries.architecture.callback
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class DesktopNoticeNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: DesktopNoticePresenter,
+) : Node(buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun navigateBack()
+ fun navigateToQrCodeScanner()
+ }
+
+ private val callback: Callback = callback()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ DesktopNoticeView(
+ state = state,
+ modifier = modifier,
+ onBackClick = callback::navigateBack,
+ onReadyToScanClick = callback::navigateToQrCodeScanner,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt
new file mode 100644
index 0000000000..3b01725fe1
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2025 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.desktop
+
+import android.Manifest
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.libraries.architecture.Presenter
+import io.element.android.libraries.permissions.api.PermissionsEvent
+import io.element.android.libraries.permissions.api.PermissionsPresenter
+
+@Inject
+class DesktopNoticePresenter(
+ permissionsPresenterFactory: PermissionsPresenter.Factory,
+) : Presenter {
+ private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
+ private var pendingPermissionRequest by mutableStateOf(false)
+
+ @Composable
+ override fun present(): DesktopNoticeState {
+ val cameraPermissionState = cameraPermissionPresenter.present()
+ var canContinue by remember { mutableStateOf(false) }
+ LaunchedEffect(cameraPermissionState.permissionGranted) {
+ if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
+ pendingPermissionRequest = false
+ canContinue = true
+ }
+ }
+
+ fun handleEvent(event: DesktopNoticeEvent) {
+ when (event) {
+ DesktopNoticeEvent.Continue -> if (cameraPermissionState.permissionGranted) {
+ canContinue = true
+ } else {
+ pendingPermissionRequest = true
+ cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions)
+ }
+ }
+ }
+
+ return DesktopNoticeState(
+ cameraPermissionState = cameraPermissionState,
+ canContinue = canContinue,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt
new file mode 100644
index 0000000000..81991b5ab2
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2025 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.desktop
+
+import io.element.android.libraries.permissions.api.PermissionsState
+
+data class DesktopNoticeState(
+ val cameraPermissionState: PermissionsState,
+ val canContinue: Boolean,
+ val eventSink: (DesktopNoticeEvent) -> Unit,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt
new file mode 100644
index 0000000000..194bd6fafc
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ * Copyright 2024, 2025 New Vector 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.desktop
+
+import android.Manifest
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.permissions.api.PermissionsState
+import io.element.android.libraries.permissions.api.aPermissionsState
+
+open class DesktopNoticeStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aDesktopNoticeState(),
+ aDesktopNoticeState(cameraPermissionState = aPermissionsState(showDialog = true, permission = Manifest.permission.CAMERA)),
+ )
+}
+
+fun aDesktopNoticeState(
+ cameraPermissionState: PermissionsState = aPermissionsState(
+ showDialog = false,
+ permission = Manifest.permission.CAMERA,
+ ),
+ canContinue: Boolean = false,
+ eventSink: (DesktopNoticeEvent) -> Unit = {},
+) = DesktopNoticeState(
+ cameraPermissionState = cameraPermissionState,
+ canContinue = canContinue,
+ eventSink = eventSink
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt
new file mode 100644
index 0000000000..c7f0bb61e7
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2025 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(ExperimentalMaterial3Api::class)
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.LocalBuildMeta
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
+import io.element.android.libraries.permissions.api.PermissionsView
+import kotlinx.collections.immutable.persistentListOf
+
+/**
+ * Desktop notice screen:
+ * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23618
+ */
+@Composable
+fun DesktopNoticeView(
+ state: DesktopNoticeState,
+ onBackClick: () -> Unit,
+ onReadyToScanClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val latestOnReadyToScanClick by rememberUpdatedState(onReadyToScanClick)
+ LaunchedEffect(state.canContinue) {
+ if (state.canContinue) {
+ latestOnReadyToScanClick()
+ }
+ }
+
+ val appName = LocalBuildMeta.current.applicationName
+ FlowStepPage(
+ onBackClick = onBackClick,
+ title = stringResource(R.string.screen_link_new_device_desktop_title, appName),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
+ modifier = modifier,
+ buttons = {
+ Button(
+ text = stringResource(R.string.screen_link_new_device_desktop_submit),
+ onClick = { state.eventSink(DesktopNoticeEvent.Continue) },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ ) {
+ Column(
+ Modifier.fillMaxWidth()
+ ) {
+ Spacer(modifier = Modifier.height(40.dp))
+ NumberedListOrganism(
+ modifier = Modifier.fillMaxSize(),
+ items = persistentListOf(
+ AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step1, appName)),
+ annotatedTextWithBold(
+ text = stringResource(
+ id = R.string.screen_link_new_device_mobile_step2,
+ stringResource(R.string.screen_link_new_device_mobile_step2_action),
+ ),
+ boldText = stringResource(R.string.screen_link_new_device_mobile_step2_action)
+ ),
+ AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step3)),
+ )
+ )
+ }
+ }
+
+ PermissionsView(
+ title = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_title),
+ content = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_description, appName),
+ icon = { Icon(imageVector = CompoundIcons.TakePhotoSolid(), contentDescription = null) },
+ state = state.cameraPermissionState,
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun DesktopNoticeViewPreview(
+ @PreviewParameter(DesktopNoticeStateProvider::class) state: DesktopNoticeState,
+) = ElementPreview {
+ DesktopNoticeView(
+ state = state,
+ onBackClick = { },
+ onReadyToScanClick = { },
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt
new file mode 100644
index 0000000000..70fd3b49a4
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2025 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.error
+
+import androidx.compose.runtime.Composable
+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.libraries.architecture.callback
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class ErrorNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+) : Node(buildContext = buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun onRetry()
+ }
+
+ private val callback: Callback = callback()
+ private val errorScreenType = inputs()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ ErrorView(
+ modifier = modifier,
+ errorScreenType = errorScreenType,
+ onRetry = callback::onRetry,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt
new file mode 100644
index 0000000000..b92a19ef8a
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2025 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.error
+
+import android.os.Parcelable
+import androidx.compose.runtime.Immutable
+import io.element.android.libraries.architecture.NodeInputs
+import kotlinx.parcelize.Parcelize
+
+@Immutable
+sealed interface ErrorScreenType : NodeInputs, Parcelable {
+ @Parcelize
+ data object Cancelled : ErrorScreenType
+
+ @Parcelize
+ data object Expired : ErrorScreenType
+
+ @Parcelize
+ data object Mismatch2Digits : ErrorScreenType
+
+ @Parcelize
+ data object InsecureChannelDetected : ErrorScreenType
+
+ @Parcelize
+ data object Declined : ErrorScreenType
+
+ @Parcelize
+ data object ProtocolNotSupported : ErrorScreenType
+
+ @Parcelize
+ data object SlidingSyncNotAvailable : ErrorScreenType
+
+ @Parcelize
+ data object UnknownError : ErrorScreenType
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt
new file mode 100644
index 0000000000..7fd699101b
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2025 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.error
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+class ErrorScreenTypeProvider : PreviewParameterProvider {
+ override val values: Sequence = sequenceOf(
+ ErrorScreenType.Cancelled,
+ ErrorScreenType.Declined,
+ ErrorScreenType.Expired,
+ ErrorScreenType.ProtocolNotSupported,
+ ErrorScreenType.Mismatch2Digits,
+ ErrorScreenType.InsecureChannelDetected,
+ ErrorScreenType.SlidingSyncNotAvailable,
+ ErrorScreenType.UnknownError,
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt
new file mode 100644
index 0000000000..3a77f19f49
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt
@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2025 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.error
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.LocalBuildMeta
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.persistentListOf
+
+@Composable
+fun ErrorView(
+ errorScreenType: ErrorScreenType,
+ onRetry: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val appName = LocalBuildMeta.current.applicationName
+ BackHandler(onBack = onRetry)
+ FlowStepPage(
+ modifier = modifier,
+ iconStyle = BigIcon.Style.AlertSolid,
+ title = titleText(errorScreenType, appName),
+ subTitle = subtitleText(errorScreenType, appName),
+ content = { Content(errorScreenType) },
+ buttons = { Buttons(onRetry) },
+ )
+}
+
+@Composable
+private fun titleText(errorScreenType: ErrorScreenType, appName: String) = when (errorScreenType) {
+ ErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_title)
+ ErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_title)
+ ErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_title)
+ ErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_title)
+ ErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_title)
+ ErrorScreenType.Mismatch2Digits -> stringResource(id = R.string.screen_link_new_device_wrong_number_title)
+ ErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_title, appName)
+ is ErrorScreenType.UnknownError -> stringResource(CommonStrings.common_something_went_wrong)
+}
+
+@Composable
+private fun subtitleText(errorScreenType: ErrorScreenType, appName: String) = when (errorScreenType) {
+ ErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_subtitle)
+ ErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_subtitle)
+ ErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_subtitle)
+ ErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_subtitle, appName)
+ ErrorScreenType.Mismatch2Digits -> stringResource(id = R.string.screen_link_new_device_wrong_number_subtitle)
+ ErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_description)
+ ErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_subtitle, appName)
+ is ErrorScreenType.UnknownError -> stringResource(R.string.screen_qr_code_login_unknown_error_description)
+}
+
+@Composable
+private fun ColumnScope.InsecureChannelDetectedError() {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ text = stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_header),
+ style = ElementTheme.typography.fontBodyLgMedium,
+ textAlign = TextAlign.Center,
+ )
+ NumberedListOrganism(
+ modifier = Modifier.fillMaxSize(),
+ items = persistentListOf(
+ AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_1)),
+ AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_2)),
+ AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_3)),
+ )
+ )
+}
+
+@Composable
+private fun Content(errorScreenType: ErrorScreenType) {
+ when (errorScreenType) {
+ ErrorScreenType.InsecureChannelDetected -> {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ InsecureChannelDetectedError()
+ }
+ }
+ else -> Unit
+ }
+}
+
+@Composable
+private fun Buttons(onRetry: () -> Unit) {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_start_over),
+ onClick = onRetry
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun ErrorViewPreview(@PreviewParameter(ErrorScreenTypeProvider::class) errorScreenType: ErrorScreenType) {
+ ElementPreview {
+ ErrorView(
+ errorScreenType = errorScreenType,
+ onRetry = {},
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt
new file mode 100644
index 0000000000..b61cc82a22
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright (c) 2025 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.number
+
+object Config {
+ const val VERIFICATION_CODE_LENGTH = 2
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt
new file mode 100644
index 0000000000..267b508669
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2025 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.number
+
+sealed interface EnterNumberEvent {
+ data class UpdateNumber(val number: String) : EnterNumberEvent
+ data object Continue : EnterNumberEvent
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt
new file mode 100644
index 0000000000..08d8cd02bd
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2025 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.number
+
+import androidx.compose.runtime.Composable
+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.libraries.architecture.callback
+import io.element.android.libraries.di.SessionScope
+
+interface EnterNumberNavigator {
+ fun navigateToWrongNumberError()
+}
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class EnterNumberNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ presenterFactory: EnterNumberPresenter.Factory,
+) : Node(buildContext, plugins = plugins), EnterNumberNavigator {
+ private val presenter = presenterFactory.create(this)
+
+ interface Callback : Plugin {
+ fun navigateToWrongNumberError()
+ fun navigateBack()
+ }
+
+ private val callback: Callback = callback()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ EnterNumberView(
+ state = state,
+ modifier = modifier,
+ onBackClick = callback::navigateBack,
+ )
+ }
+
+ override fun navigateToWrongNumberError() {
+ callback.navigateToWrongNumberError()
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt
new file mode 100644
index 0000000000..74b5b6b294
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2025 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.number
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedFactory
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.matrix.api.linknewdevice.CheckCodeSender
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import io.element.android.libraries.matrix.api.logs.LoggerTags
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+private val tag = LoggerTag("EnterNumberPresenter", LoggerTags.linkNewDevice)
+
+@AssistedInject
+class EnterNumberPresenter(
+ @Assisted private val navigator: EnterNumberNavigator,
+ private val linkNewMobileHandler: LinkNewMobileHandler,
+) : Presenter {
+ @AssistedFactory
+ interface Factory {
+ fun create(navigator: EnterNumberNavigator): EnterNumberPresenter
+ }
+
+ @Composable
+ override fun present(): EnterNumberState {
+ val coroutineScope = rememberCoroutineScope()
+ var number by remember { mutableStateOf("") }
+ var sendingCode by remember>> { mutableStateOf(AsyncAction.Uninitialized) }
+
+ // Observe the flow to react on ErrorType.InvalidCheckCode
+ val linkMobileStep by linkNewMobileHandler.stepFlow.collectAsState()
+
+ var checkCodeSender: CheckCodeSender? by remember { mutableStateOf(null) }
+
+ LaunchedEffect(linkMobileStep) {
+ when (val step = linkMobileStep) {
+ is LinkMobileStep.QrScanned -> {
+ checkCodeSender = step.checkCodeSender
+ }
+ else -> Unit
+ }
+ }
+
+ fun handleEvent(event: EnterNumberEvent) {
+ when (event) {
+ is EnterNumberEvent.UpdateNumber -> {
+ sendingCode = AsyncAction.Uninitialized
+ // Keep only digits as a safety measure
+ number = event.number.filter { it.isDigit() }
+ }
+ EnterNumberEvent.Continue -> coroutineScope.launch {
+ // Get the current code sender
+ val sender = checkCodeSender
+ if (sender == null) {
+ Timber.tag(tag.value).e("No check code sender available")
+ sendingCode = AsyncAction.Failure(IllegalStateException("No check code sender available"))
+ } else {
+ sendingCode = AsyncAction.Loading
+ val uByte = number.toUByte()
+ val isValid = sender.validate(uByte)
+ if (isValid) {
+ sender.send(uByte)
+ .fold(
+ onSuccess = {
+ Timber.tag(tag.value).d("Code sent successfully")
+ // Keep loading, do not set sendingCode to AsyncAction.Success(Unit)
+ },
+ onFailure = {
+ Timber.tag(tag.value).e(it, "Failed to send number code")
+ sendingCode = AsyncAction.Failure(it)
+ }
+ )
+ } else {
+ // Navigate to the error state
+ navigator.navigateToWrongNumberError()
+ }
+ }
+ }
+ }
+ }
+
+ return EnterNumberState(
+ number = number,
+ sendingCode = sendingCode,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt
new file mode 100644
index 0000000000..b8f66018dd
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2025 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.number
+
+import io.element.android.features.linknewdevice.impl.screens.number.model.Number
+import io.element.android.libraries.architecture.AsyncAction
+
+data class EnterNumberState(
+ val number: String,
+ val sendingCode: AsyncAction,
+ val eventSink: (EnterNumberEvent) -> Unit,
+) {
+ val numberEntry = Number.createEmpty(Config.VERIFICATION_CODE_LENGTH).fillWith(number)
+ val isContinueButtonEnabled: Boolean
+ get() = numberEntry.isComplete() && !sendingCode.isLoading()
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt
new file mode 100644
index 0000000000..126bfeee52
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2025 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.number
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
+
+open class EnterNumberStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aEnterNumberState(),
+ aEnterNumberState(number = "1"),
+ aEnterNumberState(number = "12"),
+ aEnterNumberState(number = "12", sendingCode = AsyncAction.Loading),
+ aEnterNumberState(number = "12", sendingCode = AsyncAction.Failure(ErrorType.InvalidCheckCode("Invalid"))),
+ aEnterNumberState(number = "12", sendingCode = AsyncAction.Failure(Exception("Failed to send code"))),
+ )
+}
+
+fun aEnterNumberState(
+ number: String = "",
+ sendingCode: AsyncAction = AsyncAction.Uninitialized,
+ eventSink: (EnterNumberEvent) -> Unit = {},
+) = EnterNumberState(
+ number = number,
+ sendingCode = sendingCode,
+ eventSink = eventSink,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt
new file mode 100644
index 0000000000..92b3447615
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2025 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(ExperimentalMaterial3Api::class)
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.features.linknewdevice.impl.screens.number.component.NumberTextField
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
+import io.element.android.libraries.ui.strings.CommonStrings
+
+/**
+ * Form to enter number:
+ * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2076-81604
+ */
+@Composable
+fun EnterNumberView(
+ state: EnterNumberState,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FlowStepPage(
+ onBackClick = onBackClick,
+ title = stringResource(R.string.screen_link_new_device_enter_number_title),
+ subTitle = stringResource(R.string.screen_link_new_device_enter_number_subtitle),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
+ modifier = modifier,
+ buttons = {
+ Button(
+ text = stringResource(CommonStrings.action_continue),
+ onClick = { state.eventSink(EnterNumberEvent.Continue) },
+ enabled = state.isContinueButtonEnabled,
+ showProgress = state.sendingCode.isLoading(),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ ) {
+ Column(
+ Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ text = stringResource(R.string.screen_link_new_device_enter_number_notice),
+ textAlign = TextAlign.Center,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textPrimary,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ NumberTextField(
+ number = state.numberEntry,
+ onValueChange = { state.eventSink(EnterNumberEvent.UpdateNumber(it)) },
+ onDone = {
+ if (state.isContinueButtonEnabled) {
+ state.eventSink(EnterNumberEvent.Continue)
+ }
+ },
+ )
+ val failure = state.sendingCode.errorOrNull()
+ if (failure != null) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(
+ modifier = Modifier.size(14.dp),
+ imageVector = CompoundIcons.ErrorSolid(),
+ contentDescription = null,
+ tint = ElementTheme.colors.iconCriticalPrimary,
+ )
+ val errorMessage = when (failure) {
+ is ErrorType.InvalidCheckCode -> stringResource(R.string.screen_link_new_device_enter_number_error_numbers_do_not_match)
+ else -> failure.message ?: stringResource(CommonStrings.error_unknown)
+ }
+ Text(
+ text = errorMessage,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textCriticalPrimary,
+ )
+ }
+ }
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun EnterNumberViewPreview(
+ @PreviewParameter(EnterNumberStateProvider::class) state: EnterNumberState,
+) = ElementPreview {
+ EnterNumberView(
+ state = state,
+ onBackClick = { },
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt
new file mode 100644
index 0000000000..568a729ed6
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt
@@ -0,0 +1,169 @@
+/*
+ * Copyright (c) 2025 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.number.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.linknewdevice.impl.screens.number.model.Digit
+import io.element.android.features.linknewdevice.impl.screens.number.model.Number
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import kotlinx.coroutines.delay
+
+@Composable
+fun NumberTextField(
+ number: Number,
+ onValueChange: (String) -> Unit,
+ onDone: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused = LocalInspectionMode.current || interactionSource.collectIsFocusedAsState().value
+ BasicTextField(
+ modifier = modifier,
+ value = number.toText(),
+ onValueChange = {
+ onValueChange(it)
+ },
+ interactionSource = interactionSource,
+ maxLines = 1,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ onDone()
+ }
+ ),
+ decorationBox = {
+ NumberRow(
+ number = number,
+ hasFocus = isFocused,
+ )
+ }
+ )
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun NumberRow(
+ number: Number,
+ hasFocus: Boolean,
+) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.CenterHorizontally),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ val length = number.length()
+ number.digits.forEachIndexed { index, digit ->
+ DigitView(
+ digit = digit,
+ isCurrent = index == length,
+ drawCursor = hasFocus,
+ )
+ }
+ }
+}
+
+@Composable
+private fun DigitView(
+ digit: Digit,
+ isCurrent: Boolean,
+ drawCursor: Boolean,
+) {
+ val shape = RoundedCornerShape(4.dp)
+ val appearanceModifier = when (digit) {
+ Digit.Empty -> {
+ val color = if (isCurrent) {
+ ElementTheme.colors.textPrimary
+ } else {
+ ElementTheme.colors.borderInteractiveSecondary
+ }
+ Modifier.border(1.dp, color, shape)
+ }
+ is Digit.Filled -> {
+ Modifier.background(ElementTheme.colors.bgActionSecondaryPressed, shape)
+ }
+ }
+ Box(
+ modifier = Modifier
+ .size(42.dp, 56.dp)
+ .then(appearanceModifier),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (digit is Digit.Filled) {
+ Text(
+ text = digit.value.toString(),
+ style = ElementTheme.typography.fontHeadingLgBold,
+ color = ElementTheme.colors.textPrimary,
+ )
+ } else if (drawCursor && isCurrent) {
+ // Draw a blinking cursor
+ BlinkingCursor()
+ }
+ }
+}
+
+@Composable
+private fun BlinkingCursor() {
+ var isCursorVisible by remember { mutableStateOf(true) }
+ LaunchedEffect(isCursorVisible) {
+ delay(500)
+ // Toggle cursor visibility
+ isCursorVisible = !isCursorVisible
+ }
+ if (isCursorVisible) {
+ Spacer(
+ modifier = Modifier
+ .size(2.dp, 24.dp)
+ .offset(x = (-5).dp)
+ .background(ElementTheme.colors.textPrimary, RoundedCornerShape(1.dp))
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun NumberTextFieldPreview() {
+ ElementPreview {
+ val number = Number.createEmpty(4).fillWith("12")
+ NumberTextField(
+ number = number,
+ onValueChange = {},
+ onDone = {},
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt
new file mode 100644
index 0000000000..b8565ea6ca
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2025 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.number.model
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+sealed interface Digit {
+ data object Empty : Digit
+ data class Filled(val value: Char) : Digit
+
+ fun toText(): String {
+ return when (this) {
+ is Empty -> ""
+ is Filled -> value.toString()
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt
new file mode 100644
index 0000000000..be60f27f76
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2025 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.number.model
+
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+data class Number(
+ val digits: ImmutableList,
+) {
+ companion object {
+ fun createEmpty(size: Int): Number {
+ val digits = List(size) { Digit.Empty }
+ return Number(
+ digits = digits.toImmutableList()
+ )
+ }
+ }
+
+ val size = digits.size
+
+ /**
+ * Fill the first digits with the given text.
+ * Can't be more than the size of the NumberEntry
+ * Keep the Empty digits at the end
+ * @return the new NumberEntry
+ */
+ fun fillWith(text: String): Number {
+ val newDigits = MutableList(size) { Digit.Empty }
+ text.forEachIndexed { index, char ->
+ if (index < size && char.isDigit()) {
+ newDigits[index] = Digit.Filled(char)
+ }
+ }
+ return copy(digits = newDigits.toImmutableList())
+ }
+
+ fun length(): Int {
+ return digits.count { it is Digit.Filled }
+ }
+
+ fun toText(): String {
+ return digits.joinToString("") {
+ it.toText()
+ }
+ }
+
+ fun isComplete(): Boolean {
+ return digits.all { it is Digit.Filled }
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt
new file mode 100644
index 0000000000..a884c3e97f
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2025 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.qrcode
+
+import androidx.compose.runtime.Composable
+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.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)
+@AssistedInject
+class ShowQrCodeNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+) : Node(buildContext, plugins = plugins) {
+ class Inputs(
+ val data: String,
+ ) : NodeInputs
+
+ interface Callback : Plugin {
+ fun navigateBack()
+ }
+
+ private val inputs: Inputs = inputs()
+ private val callback: Callback = callback()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ ShowQrCodeView(
+ data = inputs.data,
+ modifier = modifier,
+ onBackClick = callback::navigateBack,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt
new file mode 100644
index 0000000000..501415f621
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2025 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(ExperimentalMaterial3Api::class)
+
+package io.element.android.features.linknewdevice.impl.screens.qrcode
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.LocalBuildMeta
+import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
+import io.element.android.libraries.qrcode.QrCodeImage
+import kotlinx.collections.immutable.persistentListOf
+
+/**
+ * QrCode display screen:
+ * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23617
+ */
+@Composable
+fun ShowQrCodeView(
+ data: String,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val appName = LocalBuildMeta.current.applicationName
+ FlowStepPage(
+ onBackClick = onBackClick,
+ title = stringResource(R.string.screen_link_new_device_mobile_title, appName),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.TakePhotoSolid()),
+ modifier = modifier,
+ ) {
+ Column(
+ Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ QrCodeImage(
+ data = data,
+ modifier = Modifier
+ .size(220.dp)
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ NumberedListOrganism(
+ modifier = Modifier.fillMaxSize(),
+ items = persistentListOf(
+ AnnotatedString(stringResource(R.string.screen_link_new_device_mobile_step1, appName)),
+ annotatedTextWithBold(
+ text = stringResource(
+ id = R.string.screen_link_new_device_mobile_step2,
+ stringResource(R.string.screen_link_new_device_mobile_step2_action),
+ ),
+ boldText = stringResource(R.string.screen_link_new_device_mobile_step2_action)
+ ),
+ AnnotatedString(stringResource(R.string.screen_link_new_device_mobile_step3)),
+ )
+ )
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun ShowQrCodeViewPreview() = ElementPreview {
+ ShowQrCodeView(
+ data = "DATA",
+ onBackClick = { },
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt
new file mode 100644
index 0000000000..8ce6af90b0
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2025 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
+
+sealed interface LinkNewDeviceRootEvent {
+ data object LinkMobileDevice : LinkNewDeviceRootEvent
+ data object CloseDialog : LinkNewDeviceRootEvent
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt
new file mode 100644
index 0000000000..f43ffa64df
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2025 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
+
+import androidx.compose.runtime.Composable
+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.libraries.architecture.callback
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class LinkNewDeviceRootNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: LinkNewDeviceRootPresenter,
+) : Node(buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun onDone()
+ fun linkDesktopDevice()
+ }
+
+ private val callback: Callback = callback()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ LinkNewDeviceRootView(
+ state = state,
+ modifier = modifier,
+ onBackClick = callback::onDone,
+ onLinkDesktopDeviceClick = callback::linkDesktopDevice,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt
new file mode 100644
index 0000000000..a17ed88fd3
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2025 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
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import dev.zacsweers.metro.Inject
+import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import kotlinx.coroutines.launch
+
+@Inject
+class LinkNewDeviceRootPresenter(
+ private val matrixClient: MatrixClient,
+ private val linkNewMobileHandler: LinkNewMobileHandler,
+) : Presenter {
+ @Composable
+ override fun present(): LinkNewDeviceRootState {
+ val coroutineScope = rememberCoroutineScope()
+ var isSupported by remember { mutableStateOf>(AsyncData.Uninitialized) }
+ var qrCodeData by remember { mutableStateOf>(AsyncData.Uninitialized) }
+
+ LaunchedEffect(Unit) {
+ matrixClient.canLinkNewDevice().fold(
+ onSuccess = { supported ->
+ isSupported = AsyncData.Success(supported)
+ },
+ onFailure = {
+ isSupported = AsyncData.Failure(it)
+ }
+ )
+ }
+
+ val step by linkNewMobileHandler.stepFlow.collectAsState()
+
+ LaunchedEffect(step) {
+ when (val finalStep = step) {
+ is LinkMobileStep.Uninitialized -> {
+ qrCodeData = AsyncData.Uninitialized
+ }
+ is LinkMobileStep.QrReady -> {
+ qrCodeData = AsyncData.Success(Unit)
+ }
+ is LinkMobileStep.Error -> {
+ qrCodeData = AsyncData.Failure(finalStep.errorType)
+ }
+ else -> Unit
+ }
+ }
+
+ 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()
+ }
+ }
+ }
+
+ return LinkNewDeviceRootState(
+ isSupported = isSupported,
+ qrCodeData = qrCodeData,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt
new file mode 100644
index 0000000000..6cf6694b2a
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2025 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
+
+import io.element.android.libraries.architecture.AsyncData
+
+data class LinkNewDeviceRootState(
+ val isSupported: AsyncData,
+ val qrCodeData: AsyncData,
+ val eventSink: (LinkNewDeviceRootEvent) -> Unit,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt
new file mode 100644
index 0000000000..f1bb7ad455
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2025 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
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
+
+open class LinkNewDeviceRootStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aLinkNewDeviceRootState(),
+ aLinkNewDeviceRootState(isSupported = AsyncData.Success(true)),
+ aLinkNewDeviceRootState(isSupported = AsyncData.Success(false)),
+ aLinkNewDeviceRootState(isSupported = AsyncData.Failure(Exception("Should not happen"))),
+ aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(true),
+ qrCodeData = AsyncData.Loading(),
+ ),
+ aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(true),
+ qrCodeData = AsyncData.Failure(ErrorType.NotFound("The rendezvous session was not found and might have expired")),
+ ),
+ )
+}
+
+fun aLinkNewDeviceRootState(
+ isSupported: AsyncData = AsyncData.Uninitialized,
+ qrCodeData: AsyncData = AsyncData.Uninitialized,
+ eventSink: (LinkNewDeviceRootEvent) -> Unit = { },
+) = LinkNewDeviceRootState(
+ isSupported = isSupported,
+ qrCodeData = qrCodeData,
+ eventSink = eventSink,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt
new file mode 100644
index 0000000000..d9249d3e89
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) 2025 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(ExperimentalMaterial3Api::class)
+
+package io.element.android.features.linknewdevice.impl.screens.root
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.atomic.atoms.LoadingButtonAtom
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.IconSource
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.ui.strings.CommonStrings
+
+/**
+ * Device selection screen:
+ * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23616
+ * Not supported screen:
+ * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2186-70004
+ */
+@Composable
+fun LinkNewDeviceRootView(
+ state: LinkNewDeviceRootState,
+ onBackClick: () -> Unit,
+ onLinkDesktopDeviceClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val (title, subtitle, iconStyle) = if (state.isSupported.dataOrNull() == false) {
+ Triple(
+ stringResource(R.string.screen_link_new_device_error_not_supported_title),
+ stringResource(R.string.screen_link_new_device_error_not_supported_subtitle),
+ BigIcon.Style.AlertSolid
+ )
+ } else {
+ Triple(
+ stringResource(R.string.screen_link_new_device_root_title),
+ null,
+ BigIcon.Style.Default(CompoundIcons.Devices())
+ )
+ }
+ FlowStepPage(
+ onBackClick = onBackClick,
+ title = title,
+ subTitle = subtitle,
+ iconStyle = iconStyle,
+ buttons = {
+ when (state.isSupported) {
+ is AsyncData.Uninitialized,
+ is AsyncData.Loading -> {
+ LoadingButtonAtom()
+ }
+ is AsyncData.Failure -> {
+ Text(
+ text = stringResource(id = CommonStrings.error_unknown),
+ color = ElementTheme.colors.textCriticalPrimary,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ textAlign = TextAlign.Center,
+ )
+ Button(
+ onClick = onBackClick,
+ text = stringResource(CommonStrings.action_dismiss),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ 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()),
+ )
+ }
+ }
+ } else {
+ Button(
+ onClick = onBackClick,
+ text = stringResource(CommonStrings.action_dismiss),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ }
+ },
+ modifier = modifier,
+ )
+
+ val failure = state.qrCodeData.errorOrNull()
+ if (failure != null) {
+ ErrorDialog(
+ content = failure.message ?: stringResource(CommonStrings.error_unknown),
+ onSubmit = { state.eventSink(LinkNewDeviceRootEvent.CloseDialog) },
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun LinkNewDeviceRootViewPreview(
+ @PreviewParameter(LinkNewDeviceRootStateProvider::class) state: LinkNewDeviceRootState
+) = ElementPreview {
+ LinkNewDeviceRootView(
+ state = state,
+ onBackClick = { },
+ onLinkDesktopDeviceClick = { },
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt
new file mode 100644
index 0000000000..c17a28649c
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2025 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.scan
+
+sealed interface ScanQrCodeEvent {
+ data class QrCodeScanned(val data: ByteArray) : ScanQrCodeEvent
+ data object TryAgain : ScanQrCodeEvent
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt
new file mode 100644
index 0000000000..ff4f88f9ae
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2025 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.scan
+
+import androidx.compose.runtime.Composable
+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.libraries.architecture.callback
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class ScanQrCodeNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: ScanQrCodePresenter,
+) : Node(buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun cancel()
+ }
+
+ private val callback: Callback = callback()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ ScanQrCodeView(
+ state = state,
+ onBackClick = callback::cancel,
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt
new file mode 100644
index 0000000000..5f0131a8df
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2025 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.scan
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import dev.zacsweers.metro.Inject
+import io.element.android.features.linknewdevice.impl.LinkNewDesktopHandler
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
+import kotlinx.coroutines.launch
+
+@Inject
+class ScanQrCodePresenter(
+ private val linkNewDesktopHandler: LinkNewDesktopHandler,
+) : Presenter {
+ @Composable
+ override fun present(): ScanQrCodeState {
+ val coroutineScope = rememberCoroutineScope()
+ var scanAction: AsyncAction by remember { mutableStateOf(AsyncAction.Loading) }
+
+ // Observe the flow to react on LinkDesktopStep.InvalidQrCode
+ val linkDesktopStep by linkNewDesktopHandler.stepFlow.collectAsState()
+
+ LaunchedEffect(Unit) {
+ linkNewDesktopHandler.createNewHandler()
+ }
+
+ LaunchedEffect(linkDesktopStep) {
+ when (val step = linkDesktopStep) {
+ is LinkDesktopStep.InvalidQrCode -> {
+ scanAction = AsyncAction.Failure(Exception(step.error))
+ }
+ else -> Unit
+ }
+ }
+
+ fun handleEvent(event: ScanQrCodeEvent) {
+ when (event) {
+ ScanQrCodeEvent.TryAgain -> {
+ scanAction = AsyncAction.Loading
+ }
+ is ScanQrCodeEvent.QrCodeScanned -> coroutineScope.launch {
+ // In this case the scanning will stop and a loader will be shown
+ scanAction = AsyncAction.Success(Unit)
+ try {
+ linkNewDesktopHandler.onScannedCode(event.data)
+ } catch (e: Exception) {
+ // Should not happen as errors are handled through the LinkDesktopStep flow
+ scanAction = AsyncAction.Failure(e)
+ }
+ }
+ }
+ }
+
+ return ScanQrCodeState(
+ scanAction = scanAction,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt
new file mode 100644
index 0000000000..6cb0363836
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2025 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.scan
+
+import io.element.android.libraries.architecture.AsyncAction
+
+data class ScanQrCodeState(
+ val scanAction: AsyncAction,
+ val eventSink: (ScanQrCodeEvent) -> Unit,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt
new file mode 100644
index 0000000000..40e7df8884
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2025 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.scan
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncAction
+
+open class ScanQrCodeStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aScanQrCodeState(),
+ aScanQrCodeState(scanAction = AsyncAction.Loading),
+ aScanQrCodeState(scanAction = AsyncAction.Success(Unit)),
+ aScanQrCodeState(scanAction = AsyncAction.Failure(Exception("Scan failed"))),
+ )
+}
+
+fun aScanQrCodeState(
+ scanAction: AsyncAction = AsyncAction.Uninitialized,
+ eventSink: (ScanQrCodeEvent) -> Unit = {},
+) = ScanQrCodeState(
+ scanAction = scanAction,
+ eventSink = eventSink
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt
new file mode 100644
index 0000000000..937aee08b4
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt
@@ -0,0 +1,174 @@
+/*
+ * Copyright (c) 2025 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.scan
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.progressSemantics
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.modifiers.cornerBorder
+import io.element.android.libraries.designsystem.modifiers.squareSize
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.qrcode.QrCodeCameraView
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun ScanQrCodeView(
+ state: ScanQrCodeState,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FlowStepPage(
+ modifier = modifier,
+ onBackClick = onBackClick,
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
+ title = stringResource(R.string.screen_link_new_device_desktop_scanning_title),
+ content = { Content(state = state) },
+ buttons = { Buttons(state = state) }
+ )
+}
+
+@Composable
+private fun Content(
+ state: ScanQrCodeState,
+) {
+ BoxWithConstraints(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ val modifier = if (constraints.maxWidth > constraints.maxHeight) {
+ Modifier.fillMaxHeight()
+ } else {
+ Modifier.fillMaxWidth()
+ }.then(
+ Modifier
+ .padding(start = 20.dp, end = 20.dp, top = 50.dp, bottom = 32.dp)
+ .squareSize()
+ .cornerBorder(
+ strokeWidth = 4.dp,
+ color = ElementTheme.colors.textPrimary,
+ cornerSizeDp = 42.dp,
+ )
+ )
+ Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center,
+ ) {
+ QrCodeCameraView(
+ modifier = Modifier.fillMaxSize(),
+ onScanQrCode = { state.eventSink.invoke(ScanQrCodeEvent.QrCodeScanned(it)) },
+ isScanning = state.scanAction.isLoading(),
+ )
+ }
+ }
+}
+
+@Composable
+private fun ColumnScope.Buttons(
+ state: ScanQrCodeState,
+) {
+ Column(Modifier.heightIn(min = 130.dp)) {
+ when (state.scanAction) {
+ is AsyncAction.Failure -> {
+ Button(
+ text = stringResource(id = CommonStrings.action_try_again),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 16.dp),
+ onClick = {
+ state.eventSink.invoke(ScanQrCodeEvent.TryAgain)
+ }
+ )
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = CompoundIcons.ErrorSolid(),
+ tint = ElementTheme.colors.iconCriticalPrimary,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = stringResource(R.string.screen_qr_code_login_invalid_scan_state_subtitle),
+ textAlign = TextAlign.Center,
+ color = ElementTheme.colors.textCriticalPrimary,
+ style = ElementTheme.typography.fontBodySmMedium,
+ )
+ }
+ Text(
+ text = stringResource(R.string.screen_qr_code_login_invalid_scan_state_description),
+ textAlign = TextAlign.Center,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ }
+ is AsyncAction.Success -> {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .progressSemantics()
+ .size(20.dp),
+ strokeWidth = 2.dp
+ )
+ }
+ }
+ AsyncAction.Loading,
+ AsyncAction.Uninitialized,
+ is AsyncAction.Confirming -> Unit
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun ScanQrCodeViewPreview(@PreviewParameter(ScanQrCodeStateProvider::class) state: ScanQrCodeState) = ElementPreview {
+ ScanQrCodeView(
+ state = state,
+ onBackClick = {},
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/res/values/localazy.xml b/features/linknewdevice/impl/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..321b168751
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,57 @@
+
+
+ "Scan the QR code"
+ "Open %1$s on a laptop or desktop computer"
+ "Scan the QR code with this device"
+ "Ready to scan"
+ "Open %1$s on a desktop computer to get the QR code"
+ "The numbers don’t match"
+ "Enter 2-digit code"
+ "This will verify that the connection to your other device is secure."
+ "Enter the number shown on your other device"
+ "Your account provider does not support %1$s."
+ "%1$s not supported"
+ "Your account provider doesn’t support signing into a new device with a QR code."
+ "QR code not supported"
+ "The sign in was cancelled on the other device."
+ "Sign in request cancelled"
+ "Sign in expired. Please try again."
+ "The sign in was not completed in time"
+ "Open %1$s on the other device"
+ "Select %1$s"
+ "“Sign in with QR code”"
+ "Scan the QR code shown here with the other device"
+ "Open %1$s on the other device"
+ "Desktop computer"
+ "Loading QR code…"
+ "Mobile device"
+ "What type of device do you want to link?"
+ "Please try again and make sure that you’ve entered the 2-digit code correctly. If the numbers still don’t match then contact your account provider."
+ "The numbers don’t match"
+ "A secure connection could not be made to the new device. Your existing devices are still safe and you don\'t need to worry about them."
+ "What now?"
+ "Try signing in again with a QR code in case this was a network problem"
+ "If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi"
+ "If that doesn’t work, sign in manually"
+ "Connection not secure"
+ "The sign in was cancelled on the other device."
+ "Sign in request cancelled"
+ "The sign in was declined on the other device."
+ "Sign in declined"
+ "You don’t need to do anything else."
+ "Your other device is already signed in"
+ "Sign in expired. Please try again."
+ "The sign in was not completed in time"
+ "Your other device does not support signing in to %s with a QR code.
+
+Try signing in manually, or scan the QR code with another device."
+ "QR code not supported"
+ "Your account provider does not support %1$s."
+ "%1$s not supported"
+ "Use the QR code shown on the other device."
+ "Try again"
+ "Wrong QR code"
+ "You need to give permission for %1$s to use your device’s camera in order to continue."
+ "Allow camera access to scan the QR code"
+ "An unexpected error occurred. Please try again."
+
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt
new file mode 100644
index 0000000000..2957a89495
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2025 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
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.tests.testutils.lambda.lambdaError
+import io.element.android.tests.testutils.node.TestParentNode
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class DefaultLinkNewDeviceEntryPointTest {
+ @get:Rule
+ val instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ @Test
+ fun `test node creation`() = runTest {
+ val entryPoint = DefaultLinkNewDeviceEntryPoint()
+ val client = FakeMatrixClient()
+ val parentNode = TestParentNode.create { buildContext, plugins ->
+ LinkNewDeviceFlowNode(
+ buildContext = buildContext,
+ plugins = plugins,
+ sessionCoroutineScope = backgroundScope,
+ linkNewMobileHandler = LinkNewMobileHandler(client),
+ linkNewDesktopHandler = LinkNewDesktopHandler(client),
+ )
+ }
+ val callback: LinkNewDeviceEntryPoint.Callback = object : LinkNewDeviceEntryPoint.Callback {
+ override fun onDone() = lambdaError()
+ }
+ val result = entryPoint.createNode(parentNode, BuildContext.root(null), callback)
+ assertThat(result).isInstanceOf(LinkNewDeviceFlowNode::class.java)
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt
new file mode 100644
index 0000000000..b6b9769b64
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2025 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.desktop
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.permissions.test.FakePermissionsPresenter
+import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DesktopNoticePresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createPresenter()
+ presenter.test {
+ awaitItem().run {
+ assertThat(cameraPermissionState.permission).isEqualTo("android.permission.POST_NOTIFICATIONS")
+ assertThat(canContinue).isFalse()
+ }
+ }
+ }
+
+ @Test
+ fun `present - Continue with camera permissions can continue`() = runTest {
+ val permissionsPresenter = FakePermissionsPresenter().apply { setPermissionGranted() }
+ val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
+ val presenter = createPresenter(permissionsPresenterFactory = permissionsPresenterFactory)
+ presenter.test {
+ awaitItem().eventSink(DesktopNoticeEvent.Continue)
+ assertThat(awaitItem().canContinue).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - Continue with unknown camera permissions opens permission dialog`() = runTest {
+ val permissionsPresenter = FakePermissionsPresenter()
+ val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
+ val presenter = createPresenter(permissionsPresenterFactory = permissionsPresenterFactory)
+ presenter.test {
+ awaitItem().eventSink(DesktopNoticeEvent.Continue)
+ assertThat(awaitItem().cameraPermissionState.showDialog).isTrue()
+ }
+ }
+}
+
+private fun createPresenter(
+ permissionsPresenterFactory: FakePermissionsPresenterFactory = FakePermissionsPresenterFactory(),
+) = DesktopNoticePresenter(
+ permissionsPresenterFactory = permissionsPresenterFactory,
+)
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt
new file mode 100644
index 0000000000..ac0a129f49
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2025 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.desktop
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.tests.testutils.EnsureNeverCalled
+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.pressBack
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class DesktopNoticeViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aDesktopNoticeState(),
+ onBackClicked = callback,
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `on back button clicked - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aDesktopNoticeState(),
+ onBackClicked = callback,
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `when can continue - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aDesktopNoticeState(canContinue = true),
+ onReadyToScanClick = callback,
+ )
+ }
+ }
+
+ @Test
+ fun `on submit button clicked - emits the Continue event`() {
+ val eventRecorder = EventsRecorder()
+ rule.setView(
+ state = aDesktopNoticeState(eventSink = eventRecorder),
+ )
+ rule.clickOn(R.string.screen_link_new_device_desktop_submit)
+ eventRecorder.assertSingle(DesktopNoticeEvent.Continue)
+ }
+
+ private fun AndroidComposeTestRule.setView(
+ state: DesktopNoticeState,
+ onBackClicked: () -> Unit = EnsureNeverCalled(),
+ onReadyToScanClick: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ DesktopNoticeView(
+ state = state,
+ onBackClick = onBackClicked,
+ onReadyToScanClick = onReadyToScanClick,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt
new file mode 100644
index 0000000000..8f44182dd4
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2025 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.error
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ErrorViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the onRetry callback`() {
+ ensureCalledOnce { callback ->
+ rule.setErrorView(
+ onRetry = callback
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `on start over button clicked - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setErrorView(
+ onRetry = callback
+ )
+ rule.clickOn(CommonStrings.action_start_over)
+ }
+ }
+
+ private fun AndroidComposeTestRule.setErrorView(
+ onRetry: () -> Unit,
+ errorScreenType: ErrorScreenType = ErrorScreenType.UnknownError,
+ ) {
+ setContent {
+ ErrorView(
+ errorScreenType = errorScreenType,
+ onRetry = onRetry,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt
new file mode 100644
index 0000000000..fe2e11e95c
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright (c) 2025 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.linknewdevice.impl.screens.number
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.linknewdevice.FakeCheckCodeSender
+import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkMobileHandler
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class EnterNumberPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state`() = runTest {
+ createPresenter().test {
+ val initialState = awaitItem()
+ assertThat(initialState.number).isEmpty()
+ assertThat(initialState.sendingCode.isUninitialized()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - enter numbers`() = runTest {
+ createPresenter().test {
+ val initialState = awaitItem()
+ assertThat(initialState.number).isEmpty()
+ initialState.eventSink(EnterNumberEvent.UpdateNumber("12"))
+ val state2 = awaitItem()
+ assertThat(state2.number).isEqualTo("12")
+ // Non numeric characters are ignored
+ state2.eventSink(EnterNumberEvent.UpdateNumber("1a"))
+ val state3 = awaitItem()
+ assertThat(state3.number).isEqualTo("1")
+ }
+ }
+
+ @Test
+ fun `present - continue in wrong state generates an error`() = runTest {
+ createPresenter().test {
+ val initialState = awaitItem()
+ initialState.eventSink(EnterNumberEvent.Continue)
+ val state2 = awaitItem()
+ assertThat(state2.sendingCode.isFailure()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - continue when number is not valid invokes the navigator`() = runTest {
+ val linkMobileHandler = FakeLinkMobileHandler(
+ startResult = {},
+ )
+ val validateResult = lambdaRecorder { false }
+ val checkCodeSender = FakeCheckCodeSender(
+ validateResult = validateResult,
+ )
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
+ )
+ val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
+ linkNewMobileHandler.createAndStartNewHandler()
+ val navigateToWrongNumberErrorLambda = lambdaRecorder { }
+ val navigator = FakeEnterNumberNavigator(
+ navigateToWrongNumberErrorLambda = navigateToWrongNumberErrorLambda,
+ )
+ createPresenter(
+ navigator = navigator,
+ linkNewMobileHandler = linkNewMobileHandler,
+ ).test {
+ val initialState = awaitItem()
+ linkMobileHandler.emitStep(
+ LinkMobileStep.QrScanned(checkCodeSender)
+ )
+ runCurrent()
+ initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
+ skipItems(1)
+ initialState.eventSink(EnterNumberEvent.Continue)
+ skipItems(1)
+ val finalState = awaitItem()
+ assertThat(finalState.sendingCode.isLoading()).isTrue()
+ advanceUntilIdle()
+ validateResult.assertions().isCalledOnce().with(value(88.toUByte()))
+ navigateToWrongNumberErrorLambda.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - continue when the number is valid but sending fails`() = runTest {
+ val linkMobileHandler = FakeLinkMobileHandler(
+ startResult = {},
+ )
+ val validateResult = lambdaRecorder { true }
+ val sendResult = lambdaRecorder> { Result.failure(AN_EXCEPTION) }
+ val checkCodeSender = FakeCheckCodeSender(
+ validateResult = validateResult,
+ sendResult = sendResult,
+ )
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
+ )
+ val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
+ linkNewMobileHandler.createAndStartNewHandler()
+ createPresenter(
+ linkNewMobileHandler = linkNewMobileHandler,
+ ).test {
+ val initialState = awaitItem()
+ linkMobileHandler.emitStep(
+ LinkMobileStep.QrScanned(checkCodeSender)
+ )
+ runCurrent()
+ initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
+ skipItems(1)
+ initialState.eventSink(EnterNumberEvent.Continue)
+ skipItems(1)
+ val loadingState = awaitItem()
+ assertThat(loadingState.sendingCode.isLoading()).isTrue()
+ val finalState = awaitItem()
+ assertThat(finalState.sendingCode.isFailure()).isTrue()
+ validateResult.assertions().isCalledOnce().with(value(88.toUByte()))
+ sendResult.assertions().isCalledOnce().with(value(88.toUByte()))
+ }
+ }
+
+ @Test
+ fun `present - continue when the number is valid and sending is successful`() = runTest {
+ val linkMobileHandler = FakeLinkMobileHandler(
+ startResult = {},
+ )
+ val validateResult = lambdaRecorder { true }
+ val sendResult = lambdaRecorder> { Result.success(Unit) }
+ val checkCodeSender = FakeCheckCodeSender(
+ validateResult = validateResult,
+ sendResult = sendResult,
+ )
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
+ )
+ val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
+ linkNewMobileHandler.createAndStartNewHandler()
+ createPresenter(
+ linkNewMobileHandler = linkNewMobileHandler,
+ ).test {
+ val initialState = awaitItem()
+ linkMobileHandler.emitStep(
+ LinkMobileStep.QrScanned(checkCodeSender)
+ )
+ runCurrent()
+ initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
+ skipItems(1)
+ initialState.eventSink(EnterNumberEvent.Continue)
+ skipItems(1)
+ val loadingState = awaitItem()
+ assertThat(loadingState.sendingCode.isLoading()).isTrue()
+ expectNoEvents()
+ advanceUntilIdle()
+ validateResult.assertions().isCalledOnce().with(value(88.toUByte()))
+ sendResult.assertions().isCalledOnce().with(value(88.toUByte()))
+ }
+ }
+
+ private fun createPresenter(
+ navigator: EnterNumberNavigator = FakeEnterNumberNavigator(),
+ linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()),
+ ) = EnterNumberPresenter(
+ navigator = navigator,
+ linkNewMobileHandler = linkNewMobileHandler,
+ )
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt
new file mode 100644
index 0000000000..e7466a1b21
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) 2025 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.number
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.impl.screens.number.model.Digit
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import org.junit.Test
+
+class EnterNumberStateTest {
+ @Test
+ fun `isContinueButtonEnabled is false if number is not complete`() {
+ val sut = aEnterNumberState(
+ number = "",
+ sendingCode = AsyncAction.Uninitialized,
+ )
+ assertThat(sut.copy(number = "1").isContinueButtonEnabled).isFalse()
+ }
+
+ @Test
+ fun `isContinueButtonEnabled is true if number is complete`() {
+ val sut = aEnterNumberState(
+ number = "12",
+ sendingCode = AsyncAction.Uninitialized,
+ )
+ assertThat(sut.isContinueButtonEnabled).isTrue()
+ }
+
+ @Test
+ fun `isContinueButtonEnabled is false if number is complete and sending is loading`() {
+ val sut = aEnterNumberState(
+ number = "12",
+ sendingCode = AsyncAction.Loading,
+ )
+ assertThat(sut.isContinueButtonEnabled).isFalse()
+ }
+
+ @Test
+ fun `isContinueButtonEnabled is true if number is complete and sending is not loading`() {
+ listOf(
+ AsyncAction.Uninitialized,
+ AsyncAction.Failure(AN_EXCEPTION),
+ AsyncAction.Success(Unit),
+ ).forEach { action ->
+ val sut = aEnterNumberState(
+ number = "12",
+ sendingCode = action,
+ )
+ assertThat(sut.isContinueButtonEnabled).isTrue()
+ }
+ }
+
+ @Test
+ fun `numberEntry is computed from number - case empty`() {
+ val sut = aEnterNumberState(
+ number = "",
+ )
+ assertThat(sut.numberEntry.size).isEqualTo(2)
+ assertThat(sut.numberEntry.digits).containsExactly(
+ Digit.Empty,
+ Digit.Empty,
+ )
+ }
+
+ @Test
+ fun `numberEntry is computed from number - case half filled`() {
+ val sut = aEnterNumberState(
+ number = "1",
+ )
+ assertThat(sut.numberEntry.size).isEqualTo(2)
+ assertThat(sut.numberEntry.digits).containsExactly(
+ Digit.Filled('1'),
+ Digit.Empty,
+ )
+ }
+
+ @Test
+ fun `numberEntry is computed from number - case filled`() {
+ val sut = aEnterNumberState(
+ number = "12",
+ )
+ assertThat(sut.numberEntry.size).isEqualTo(2)
+ assertThat(sut.numberEntry.digits).containsExactly(
+ Digit.Filled('1'),
+ Digit.Filled('2'),
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt
new file mode 100644
index 0000000000..20e1d898dd
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2025 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.number
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+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.pressBack
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class EnterNumberViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aEnterNumberState(),
+ onBackClicked = callback,
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `on back button clicked - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aEnterNumberState(),
+ onBackClicked = callback,
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `on continue button clicked - emits the Continue event`() {
+ val eventRecorder = EventsRecorder()
+ rule.setView(
+ state = aEnterNumberState(
+ number = "12",
+ eventSink = eventRecorder,
+ ),
+ )
+ rule.clickOn(CommonStrings.action_continue)
+ eventRecorder.assertSingle(EnterNumberEvent.Continue)
+ }
+
+ @Test
+ fun `when the number is not complete, continue button is disabled`() {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ rule.setView(
+ state = aEnterNumberState(
+ number = "1",
+ eventSink = eventRecorder,
+ ),
+ )
+ val continueStr = rule.activity.getString(CommonStrings.action_continue)
+ rule.onNodeWithText(continueStr).assertIsNotEnabled()
+ }
+
+ private fun AndroidComposeTestRule.setView(
+ state: EnterNumberState,
+ onBackClicked: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ EnterNumberView(
+ state = state,
+ onBackClick = onBackClicked,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt
new file mode 100644
index 0000000000..a96ab7fe30
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright (c) 2025 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.number
+
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeEnterNumberNavigator(
+ private val navigateToWrongNumberErrorLambda: () -> Unit = { lambdaError() },
+) : EnterNumberNavigator {
+ override fun navigateToWrongNumberError() {
+ navigateToWrongNumberErrorLambda()
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt
new file mode 100644
index 0000000000..c6c89ba818
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2025 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.qrcode
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ShowQrCodeViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ onBackClick = callback
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ private fun AndroidComposeTestRule.setView(
+ onBackClick: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ ShowQrCodeView(
+ data = "DATA",
+ onBackClick = onBackClick,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt
new file mode 100644
index 0000000000..88a68786f3
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2025 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
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkMobileHandler
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class LinkNewDeviceRootPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state`() = runTest {
+ val matrixClient = FakeMatrixClient(
+ canLinkNewDeviceResult = { Result.success(true) },
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.isSupported.isUninitialized()).isTrue()
+ assertThat(awaitItem().isSupported.dataOrNull()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - new login device not supported`() = runTest {
+ val matrixClient = FakeMatrixClient(
+ canLinkNewDeviceResult = { Result.success(false) },
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.isSupported.isUninitialized()).isTrue()
+ assertThat(awaitItem().isSupported.dataOrNull()).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - error`() = runTest {
+ val matrixClient = FakeMatrixClient(
+ canLinkNewDeviceResult = { Result.failure(AN_EXCEPTION) },
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.isSupported.isUninitialized()).isTrue()
+ assertThat(awaitItem().isSupported.isFailure()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - link new mobile device`() = runTest {
+ val linkMobileHandler = FakeLinkMobileHandler(
+ startResult = {},
+ )
+ val matrixClient = FakeMatrixClient(
+ canLinkNewDeviceResult = { Result.success(true) },
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState.isSupported.dataOrNull()).isTrue()
+ initialState.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice)
+ val loadingState = awaitItem()
+ assertThat(loadingState.qrCodeData.isLoading()).isTrue()
+ }
+ }
+
+ private fun createPresenter(
+ matrixClient: MatrixClient = FakeMatrixClient(),
+ linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(matrixClient),
+ ) = LinkNewDeviceRootPresenter(
+ matrixClient = matrixClient,
+ linkNewMobileHandler = linkNewMobileHandler,
+ )
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt
new file mode 100644
index 0000000000..e352debfb0
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2025 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
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+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.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class LinkNewDeviceRootViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the onRetry callback`() {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce { callback ->
+ rule.setLinkNewDeviceRootView(
+ state = aLinkNewDeviceRootState(
+ eventSink = eventRecorder,
+ ),
+ onBackClick = callback
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `link desktop button clicked - calls the expected callback`() {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce { callback ->
+ rule.setLinkNewDeviceRootView(
+ state = aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(true),
+ eventSink = eventRecorder,
+ ),
+ onLinkDesktopDeviceClick = callback,
+ )
+ rule.clickOn(R.string.screen_link_new_device_root_desktop_computer)
+ }
+ }
+
+ @Test
+ fun `link mobile button clicked - emits the expected event`() {
+ val eventRecorder = EventsRecorder()
+ rule.setLinkNewDeviceRootView(
+ state = aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(true),
+ eventSink = eventRecorder,
+ )
+ )
+ rule.clickOn(R.string.screen_link_new_device_root_mobile_device)
+ eventRecorder.assertSingle(LinkNewDeviceRootEvent.LinkMobileDevice)
+ }
+
+ @Test
+ fun `not supported - dismiss click - invokes the expected callback`() {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce { callback ->
+ rule.setLinkNewDeviceRootView(
+ state = aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(false),
+ eventSink = eventRecorder,
+ ),
+ onBackClick = callback,
+ )
+ rule.clickOn(CommonStrings.action_dismiss)
+ }
+ }
+
+ private fun AndroidComposeTestRule.setLinkNewDeviceRootView(
+ state: LinkNewDeviceRootState = aLinkNewDeviceRootState(),
+ onBackClick: () -> Unit = EnsureNeverCalled(),
+ onLinkDesktopDeviceClick: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ LinkNewDeviceRootView(
+ state = state,
+ onBackClick = onBackClick,
+ onLinkDesktopDeviceClick = onLinkDesktopDeviceClick,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt
new file mode 100644
index 0000000000..50c3ce767b
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 2025 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.linknewdevice.impl.screens.scan
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.impl.LinkNewDesktopHandler
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE
+import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkDesktopHandler
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class ScanQrCodePresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state`() = runTest {
+ val matrixClient = FakeMatrixClient(
+ createLinkDesktopHandlerResult = { Result.success(FakeLinkDesktopHandler()) }
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.scanAction.isLoading()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - handle scanned event - success`() = runTest {
+ val handleScannedQrCodeResult = lambdaRecorder { }
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkDesktopHandlerResult = {
+ Result.success(
+ FakeLinkDesktopHandler(
+ handleScannedQrCodeResult = handleScannedQrCodeResult,
+ )
+ )
+ }
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.scanAction.isLoading()).isTrue()
+ initialState.eventSink(ScanQrCodeEvent.QrCodeScanned(QR_CODE_DATA_RECIPROCATE))
+ val scannedState = awaitItem()
+ assertThat(scannedState.scanAction.isSuccess()).isTrue()
+ runCurrent()
+ handleScannedQrCodeResult.assertions().isCalledOnce().with(value(QR_CODE_DATA_RECIPROCATE))
+ }
+ }
+
+ @Test
+ fun `present - handle scanned event - failure`() = runTest {
+ val handleScannedQrCodeResult = lambdaRecorder { }
+ val handler = FakeLinkDesktopHandler(
+ handleScannedQrCodeResult = handleScannedQrCodeResult,
+ )
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkDesktopHandlerResult = {
+ Result.success(handler)
+ }
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.scanAction.isLoading()).isTrue()
+ initialState.eventSink(ScanQrCodeEvent.QrCodeScanned(QR_CODE_DATA_RECIPROCATE))
+ val scannedState = awaitItem()
+ assertThat(scannedState.scanAction.isSuccess()).isTrue()
+ handler.emitStep(LinkDesktopStep.InvalidQrCode(QrCodeDecodeException.Crypto("Invalid QR Code")))
+ skipItems(1)
+ val errorState = awaitItem()
+ assertThat(errorState.scanAction.isFailure()).isTrue()
+ handleScannedQrCodeResult.assertions().isCalledOnce().with(value(QR_CODE_DATA_RECIPROCATE))
+ // Reset by trying again
+ errorState.eventSink(ScanQrCodeEvent.TryAgain)
+ val resetState = awaitItem()
+ assertThat(resetState.scanAction.isLoading()).isTrue()
+ }
+ }
+}
+
+private fun createPresenter(
+ matrixClient: MatrixClient,
+) = ScanQrCodePresenter(
+ linkNewDesktopHandler = LinkNewDesktopHandler(matrixClient),
+)
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt
new file mode 100644
index 0000000000..fcc3afeb7d
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2025 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.scan
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+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.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ScanQrCodeViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the expected callback`() {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aScanQrCodeState(
+ eventSink = eventRecorder,
+ ),
+ onBackClick = callback
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `try again button clicked - emits the expected event`() {
+ val eventRecorder = EventsRecorder()
+ rule.setView(
+ state = aScanQrCodeState(
+ scanAction = AsyncAction.Failure(AN_EXCEPTION),
+ eventSink = eventRecorder,
+ )
+ )
+ rule.clickOn(CommonStrings.action_try_again)
+ eventRecorder.assertSingle(ScanQrCodeEvent.TryAgain)
+ }
+
+ private fun AndroidComposeTestRule.setView(
+ state: ScanQrCodeState = aScanQrCodeState(),
+ onBackClick: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ ScanQrCodeView(
+ state = state,
+ onBackClick = onBackClick,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/test/build.gradle.kts b/features/linknewdevice/test/build.gradle.kts
new file mode 100644
index 0000000000..388612f920
--- /dev/null
+++ b/features/linknewdevice/test/build.gradle.kts
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2025 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.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+}
+
+android {
+ namespace = "io.element.android.features.linknewdevice.test"
+}
+
+dependencies {
+ implementation(projects.features.linknewdevice.api)
+ implementation(projects.tests.testutils)
+}
diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml
index 9b235558c8..832c3b7f71 100644
--- a/features/login/impl/src/main/res/values/localazy.xml
+++ b/features/login/impl/src/main/res/values/localazy.xml
@@ -60,6 +60,8 @@
"Sign in request cancelled"
"The sign in was declined on the other device."
"Sign in declined"
+ "You don’t need to do anything else."
+ "Your other device is already signed in"
"Sign in expired. Please try again."
"The sign in was not completed in time"
"Your other device does not support signing in to %s with a QR code.
diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt
index 82ff1e7edd..5a59d9be8a 100644
--- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt
+++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt
@@ -41,6 +41,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun navigateToAddAccount()
+ fun navigateToLinkNewDevice()
fun navigateToBugReport()
fun navigateToSecureBackup()
fun navigateToRoomNotificationSettings(roomId: RoomId)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
index c7328fb6ed..c646923c77 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
@@ -163,6 +163,10 @@ class PreferencesFlowNode(
backstack.push(NavTarget.Labs)
}
+ override fun navigateToLinkNewDevice() {
+ callback.navigateToLinkNewDevice()
+ }
+
override fun navigateToUserProfile(matrixUser: MatrixUser) {
backstack.push(NavTarget.UserProfile(matrixUser))
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
index 7dafcfae81..6b54a763af 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
@@ -45,6 +45,7 @@ class PreferencesRootNode(
fun navigateToLockScreenSettings()
fun navigateToAdvancedSettings()
fun navigateToLabs()
+ fun navigateToLinkNewDevice()
fun navigateToUserProfile(matrixUser: MatrixUser)
fun navigateToBlockedUsers()
fun startSignOutFlow()
@@ -84,6 +85,7 @@ class PreferencesRootNode(
onOpenDeveloperSettings = callback::navigateToDeveloperSettings,
onOpenAdvancedSettings = callback::navigateToAdvancedSettings,
onOpenLabs = callback::navigateToLabs,
+ onLinkNewDeviceClick = callback::navigateToLinkNewDevice,
onManageAccountClick = { onManageAccountClick(activity, it, isDark) },
onOpenNotificationSettings = callback::navigateToNotificationSettings,
onOpenLockScreenSettings = callback::navigateToLockScreenSettings,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
index 4e83f7c6b2..1e056c0bf0 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
@@ -69,6 +69,9 @@ class PreferencesRootPresenter(
val isMultiAccountEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
}.collectAsState(initial = false)
+ val showLinkNewDevice by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
+ }.collectAsState(initial = false)
val otherSessions by remember {
sessionStore.sessionsFlow().map { list ->
@@ -146,6 +149,7 @@ class PreferencesRootPresenter(
devicesManagementUrl = devicesManagementUrl.value,
showAnalyticsSettings = hasAnalyticsProviders,
canReportBug = canReportBug,
+ showLinkNewDevice = showLinkNewDevice,
showDeveloperSettings = showDeveloperSettings,
canDeactivateAccount = canDeactivateAccount,
showBlockedUsersItem = showBlockedUsersItem,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
index dd03b3e775..d637ae6c87 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
@@ -25,6 +25,7 @@ data class PreferencesRootState(
val accountManagementUrl: String?,
val devicesManagementUrl: String?,
val canReportBug: Boolean,
+ val showLinkNewDevice: Boolean,
val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean,
val canDeactivateAccount: Boolean,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
index c979bd2580..b8d1f1c2b6 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
@@ -31,6 +31,7 @@ fun aPreferencesRootState(
accountManagementUrl = "aUrl",
devicesManagementUrl = "anOtherUrl",
showAnalyticsSettings = true,
+ showLinkNewDevice = true,
canReportBug = true,
showDeveloperSettings = true,
showBlockedUsersItem = true,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
index 66398d9dbc..5e3c9d6759 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
@@ -54,6 +54,7 @@ fun PreferencesRootView(
onAddAccountClick: () -> Unit,
onSecureBackupClick: () -> Unit,
onManageAccountClick: (url: String) -> Unit,
+ onLinkNewDeviceClick: () -> Unit,
onOpenAnalytics: () -> Unit,
onOpenRageShake: () -> Unit,
onOpenLockScreenSettings: () -> Unit,
@@ -101,6 +102,7 @@ fun PreferencesRootView(
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
+ onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
@@ -193,8 +195,16 @@ private fun ColumnScope.ManageAppSection(
private fun ColumnScope.ManageAccountSection(
state: PreferencesRootState,
onManageAccountClick: (url: String) -> Unit,
+ onLinkNewDeviceClick: () -> Unit,
onOpenBlockedUsers: () -> Unit,
) {
+ if (state.showLinkNewDevice) {
+ ListItem(
+ headlineContent = { Text(stringResource(id = CommonStrings.common_link_new_device)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())),
+ onClick = onLinkNewDeviceClick,
+ )
+ }
state.accountManagementUrl?.let { url ->
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) },
@@ -353,6 +363,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onOpenAbout = {},
onSecureBackupClick = {},
onManageAccountClick = {},
+ onLinkNewDeviceClick = {},
onOpenNotificationSettings = {},
onOpenLockScreenSettings = {},
onOpenUserProfile = {},
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt
index 7a950629be..963d7846b4 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt
@@ -50,6 +50,7 @@ class DefaultPreferencesEntryPointTest {
}
val callback = object : PreferencesEntryPoint.Callback {
override fun navigateToAddAccount() = lambdaError()
+ override fun navigateToLinkNewDevice() = lambdaError()
override fun navigateToBugReport() = lambdaError()
override fun navigateToSecureBackup() = lambdaError()
override fun navigateToRoomNotificationSettings(roomId: RoomId) = lambdaError()
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
index 1fa141b297..d10f860f0d 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
@@ -87,6 +87,7 @@ class PreferencesRootPresenterTest {
assertThat(loadedState.accountManagementUrl).isNull()
assertThat(loadedState.devicesManagementUrl).isNull()
assertThat(loadedState.showAnalyticsSettings).isFalse()
+ assertThat(loadedState.showLinkNewDevice).isFalse()
assertThat(loadedState.showDeveloperSettings).isTrue()
assertThat(loadedState.canDeactivateAccount).isTrue()
assertThat(loadedState.canReportBug).isTrue()
@@ -258,6 +259,22 @@ class PreferencesRootPresenterTest {
}
}
+ @Test
+ fun `present - link new device`() = runTest {
+ createPresenter(
+ matrixClient = FakeMatrixClient(
+ sessionId = A_SESSION_ID,
+ canDeactivateAccountResult = { true },
+ ),
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.QrCodeLogin.key to true)
+ ),
+ ).test {
+ val state = awaitFirstItem()
+ assertThat(state.showLinkNewDevice).isTrue()
+ }
+ }
+
private suspend fun ReceiveTurbine.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e41d29813f..108504ae75 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -211,6 +211,9 @@ maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.2.0"
zxing_cpp = "io.github.zxing-cpp:android:2.3.0"
+# Stick to 3.3.3 because of https://github.com/zxing/zxing/issues/1170
+google_zxing = "com.google.zxing:core:3.3.3"
+
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
color_picker = "io.mhssn:colorpicker:1.0.0"
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/Brightness.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/Brightness.kt
new file mode 100644
index 0000000000..8cf1cc6827
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/Brightness.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2025 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.libraries.androidutils.system
+
+import android.app.Activity
+import android.view.WindowManager
+
+/**
+ * Set the screen brightness for the given activity.
+ *
+ * @receiver current Activity.
+ * @param full If true, override brightness to full; otherwise, set to none (default).
+ */
+fun Activity.setFullBrightness(full: Boolean) {
+ window.attributes = window.attributes.apply {
+ screenBrightness = if (full) {
+ WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
+ } else {
+ WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
+ }
+ }
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/LoadingButtonAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/LoadingButtonAtom.kt
new file mode 100644
index 0000000000..666d48d457
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/LoadingButtonAtom.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2025 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.libraries.designsystem.atomic.atoms
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun LoadingButtonAtom(
+ modifier: Modifier = Modifier,
+) = Button(
+ modifier = modifier.fillMaxWidth(),
+ enabled = false,
+ showProgress = true,
+ text = stringResource(CommonStrings.common_loading),
+ onClick = {},
+)
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceMaxBrightness.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceMaxBrightness.kt
new file mode 100644
index 0000000000..b0c9e8cea7
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceMaxBrightness.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2025 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.libraries.designsystem.utils
+
+import androidx.activity.compose.LocalActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import io.element.android.libraries.androidutils.system.setFullBrightness
+
+@Composable
+fun ForceMaxBrightness() {
+ val activity = LocalActivity.current ?: return
+ DisposableEffect(Unit) {
+ activity.setFullBrightness(true)
+ onDispose {
+ activity.setFullBrightness(false)
+ }
+ }
+}
diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
index 2cbfb50944..f7227a9ac9 100644
--- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
+++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
@@ -125,4 +125,11 @@ enum class FeatureFlags(
defaultValue = { true },
isFinished = false,
),
+ QrCodeLogin(
+ key = "feature.qr_code_login",
+ title = "QR Code Login",
+ description = "Allow logging in on other devices using a QR code.",
+ defaultValue = { false },
+ isFinished = false,
+ ),
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
index de6257094b..dca269453a 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
@@ -20,6 +20,8 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.encryption.EncryptionService
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.notification.NotificationService
@@ -197,6 +199,21 @@ interface MatrixClient {
*/
suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result
+ /**
+ * Check if linking a new device using QrCode is supported by the server.
+ */
+ suspend fun canLinkNewDevice(): Result
+
+ /**
+ * Create a handler to link a new mobile device, i.e. a device capable of scanning QrCodes.
+ */
+ fun createLinkMobileHandler(): Result
+
+ /**
+ * Create a handler to link a new desktop device, i.e. a device not capable of scanning QrCodes.
+ */
+ fun createLinkDesktopHandler(): Result
+
suspend fun performDatabaseVacuum(): Result
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/CheckCodeSender.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/CheckCodeSender.kt
new file mode 100644
index 0000000000..ecb440de83
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/CheckCodeSender.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.api.linknewdevice
+
+interface CheckCodeSender {
+ /**
+ * Validates the given [code]. Returns true if the code is valid, false otherwise.
+ * This method can be called multiple times to validate different codes.
+ */
+ suspend fun validate(code: UByte): Boolean
+
+ /**
+ * Sends the given [code].
+ * This method can be called only once.
+ */
+ suspend fun send(code: UByte): Result
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ErrorType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ErrorType.kt
new file mode 100644
index 0000000000..0f61007d47
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ErrorType.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.api.linknewdevice
+
+sealed class ErrorType(message: String) : Exception(message) {
+ /**
+ * The requested device ID is already in use.
+ */
+ class DeviceIdAlreadyInUse(message: String) : ErrorType(message)
+
+ /**
+ * The check code was incorrect.
+ */
+ class InvalidCheckCode(message: String) : ErrorType(message)
+
+ /**
+ * The other client proposed an unsupported protocol.
+ */
+ class UnsupportedProtocol(message: String) : ErrorType(message)
+
+ /**
+ * Secrets backup not set up properly.
+ */
+ class MissingSecretsBackup(message: String) : ErrorType(message)
+
+ /**
+ * The rendezvous session was not found and might have expired.
+ */
+ class NotFound(message: String) : ErrorType(message)
+
+ /**
+ * The device could not be created.
+ */
+ class UnableToCreateDevice(message: String) : ErrorType(message)
+
+ /**
+ * An unknown error has happened.
+ */
+ class Unknown(message: String) : ErrorType(message)
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt
new file mode 100644
index 0000000000..d600c1a8bf
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.api.linknewdevice
+
+import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException
+import kotlinx.coroutines.flow.StateFlow
+
+interface LinkDesktopHandler {
+ val linkDesktopStep: StateFlow
+ suspend fun handleScannedQrCode(data: ByteArray)
+}
+
+sealed interface LinkDesktopStep {
+ data object Uninitialized : LinkDesktopStep
+ data object Starting : LinkDesktopStep
+ data class WaitingForAuth(
+ val verificationUri: String,
+ ) : LinkDesktopStep
+
+ data class EstablishingSecureChannel(
+ val checkCode: UByte,
+ val checkCodeString: String,
+ ) : LinkDesktopStep
+
+ data class InvalidQrCode(
+ val error: QrCodeDecodeException,
+ ) : LinkDesktopStep
+
+ data class Error(
+ val errorType: ErrorType,
+ ) : LinkDesktopStep
+
+ data object SyncingSecrets : LinkDesktopStep
+
+ data object Done : LinkDesktopStep
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt
new file mode 100644
index 0000000000..0c261cdd1a
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.api.linknewdevice
+
+import kotlinx.coroutines.flow.Flow
+
+interface LinkMobileHandler {
+ val linkMobileStep: Flow
+ suspend fun start()
+}
+
+sealed interface LinkMobileStep {
+ data object Uninitialized : LinkMobileStep
+ data object Starting : LinkMobileStep
+ data class QrReady(val data: String) : LinkMobileStep
+ data class WaitingForAuth(val verificationUri: String) : LinkMobileStep
+ data class QrScanned(val checkCodeSender: CheckCodeSender) : LinkMobileStep
+ data class Error(val errorType: ErrorType) : LinkMobileStep
+ data object SyncingSecrets : LinkMobileStep
+ data object Done : LinkMobileStep
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/logs/LoggerTags.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/logs/LoggerTags.kt
new file mode 100644
index 0000000000..7f19b2d3db
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/logs/LoggerTags.kt
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.api.logs
+
+import io.element.android.libraries.core.log.logger.LoggerTag
+
+object LoggerTags {
+ val linkNewDevice = LoggerTag("LinkNewDevice")
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
index 739a14a533..f3ceb1e425 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
@@ -27,6 +27,8 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.room.BaseRoom
@@ -47,6 +49,9 @@ import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService
import io.element.android.libraries.matrix.impl.exception.mapClientException
+import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler
+import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkMobileHandler
+import io.element.android.libraries.matrix.impl.linknewdevice.RustQrCodeDataParser
import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
@@ -739,6 +744,35 @@ class RustMatrixClient(
}
}
+ override suspend fun canLinkNewDevice(): Result = withContext(sessionDispatcher) {
+ runCatchingExceptions {
+ innerClient.isLoginWithQrCodeSupported()
+ }
+ }
+
+ override fun createLinkMobileHandler(): Result {
+ return runCatchingExceptions {
+ val handler = innerClient.newGrantLoginWithQrCodeHandler()
+ RustLinkMobileHandler(
+ inner = handler,
+ sessionCoroutineScope = sessionCoroutineScope,
+ sessionDispatcher = sessionDispatcher,
+ )
+ }
+ }
+
+ override fun createLinkDesktopHandler(): Result {
+ return runCatchingExceptions {
+ val handler = innerClient.newGrantLoginWithQrCodeHandler()
+ RustLinkDesktopHandler(
+ inner = handler,
+ sessionCoroutineScope = sessionCoroutineScope,
+ sessionDispatcher = sessionDispatcher,
+ qrCodeDataParser = RustQrCodeDataParser(),
+ )
+ }
+ }
+
override suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result = withContext(sessionDispatcher) {
runCatchingExceptions {
val room = innerClient.getRoom(roomId.value) ?: error("Could not fetch associated room")
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/HumanQrGrantLoginExceptionExtension.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/HumanQrGrantLoginExceptionExtension.kt
new file mode 100644
index 0000000000..2d47b60def
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/HumanQrGrantLoginExceptionExtension.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.impl.linknewdevice
+
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
+import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException
+
+internal fun HumanQrGrantLoginException.map() = when (this) {
+ is HumanQrGrantLoginException.DeviceIdAlreadyInUse -> ErrorType.DeviceIdAlreadyInUse(message.orEmpty())
+ is HumanQrGrantLoginException.InvalidCheckCode -> ErrorType.InvalidCheckCode(message.orEmpty())
+ is HumanQrGrantLoginException.MissingSecretsBackup -> ErrorType.MissingSecretsBackup(message.orEmpty())
+ is HumanQrGrantLoginException.NotFound -> ErrorType.NotFound(message.orEmpty())
+ is HumanQrGrantLoginException.UnableToCreateDevice -> ErrorType.UnableToCreateDevice(message.orEmpty())
+ is HumanQrGrantLoginException.Unknown -> ErrorType.Unknown(message.orEmpty())
+ is HumanQrGrantLoginException.UnsupportedProtocol -> ErrorType.UnsupportedProtocol(message.orEmpty())
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/QrCodeDataParser.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/QrCodeDataParser.kt
new file mode 100644
index 0000000000..6472850a8c
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/QrCodeDataParser.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.impl.linknewdevice
+
+import org.matrix.rustcomponents.sdk.QrCodeData
+
+interface QrCodeDataParser {
+ fun parse(data: ByteArray): QrCodeData
+}
+
+class RustQrCodeDataParser : QrCodeDataParser {
+ override fun parse(data: ByteArray): QrCodeData {
+ return QrCodeData.fromBytes(data)
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSender.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSender.kt
new file mode 100644
index 0000000000..9919d1fafc
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSender.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.impl.linknewdevice
+
+import io.element.android.libraries.core.extensions.runCatchingExceptions
+import io.element.android.libraries.matrix.api.linknewdevice.CheckCodeSender
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import org.matrix.rustcomponents.sdk.CheckCodeSender as FfiCheckCodeSender
+
+class RustCheckCodeSender(
+ private val inner: FfiCheckCodeSender,
+ private val sessionDispatcher: CoroutineDispatcher,
+) : CheckCodeSender {
+ override suspend fun validate(code: UByte): Boolean = withContext(sessionDispatcher) {
+ runCatchingExceptions {
+ // TODO https://github.com/matrix-org/matrix-rust-sdk/pull/5957
+ // inner.validate(code)
+ true
+ }.getOrNull() ?: true
+ }
+
+ override suspend fun send(code: UByte): Result = withContext(sessionDispatcher) {
+ runCatchingExceptions {
+ inner.send(code)
+ }
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt
new file mode 100644
index 0000000000..211bdc3d4e
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.impl.linknewdevice
+
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
+import io.element.android.libraries.matrix.api.logs.LoggerTags
+import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeHandler
+import org.matrix.rustcomponents.sdk.GrantQrLoginProgress
+import org.matrix.rustcomponents.sdk.GrantQrLoginProgressListener
+import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException
+import org.matrix.rustcomponents.sdk.QrCodeDecodeException
+import timber.log.Timber
+
+private val tag = LoggerTag("RustLinkDesktopHandler", LoggerTags.linkNewDevice)
+
+class RustLinkDesktopHandler(
+ private val inner: GrantLoginWithQrCodeHandler,
+ private val sessionCoroutineScope: CoroutineScope,
+ private val sessionDispatcher: CoroutineDispatcher,
+ private val qrCodeDataParser: QrCodeDataParser,
+) : LinkDesktopHandler {
+ private val _linkDesktopStep = MutableStateFlow(LinkDesktopStep.Uninitialized)
+ override val linkDesktopStep: StateFlow = _linkDesktopStep.asStateFlow()
+
+ override suspend fun handleScannedQrCode(data: ByteArray) = withContext(sessionDispatcher) {
+ Timber.tag(tag.value).d("Emit Uninitialized")
+ _linkDesktopStep.emit(LinkDesktopStep.Uninitialized)
+ try {
+ val qrCodeData = qrCodeDataParser.parse(data)
+ inner.scan(
+ qrCodeData = qrCodeData,
+ progressListener = object : GrantQrLoginProgressListener {
+ override fun onUpdate(state: GrantQrLoginProgress) {
+ sessionCoroutineScope.launch {
+ val mappedState = state.map()
+ Timber.tag(tag.value).d("Emit ${mappedState::class.java.simpleName}")
+ _linkDesktopStep.emit(mappedState)
+ }
+ }
+ }
+ )
+ } catch (e: QrCodeDecodeException) {
+ Timber.tag(tag.value).w(e, "Invalid QR code scanned")
+ _linkDesktopStep.emit(
+ LinkDesktopStep.InvalidQrCode(
+ error = QrErrorMapper.map(e)
+ )
+ )
+ } catch (e: HumanQrGrantLoginException) {
+ Timber.tag(tag.value).w(e, "Error during QR login grant")
+ _linkDesktopStep.emit(LinkDesktopStep.Error(e.map()))
+ }
+ }
+
+ private fun GrantQrLoginProgress.map() = when (this) {
+ GrantQrLoginProgress.Done -> LinkDesktopStep.Done
+ GrantQrLoginProgress.Starting -> LinkDesktopStep.Starting
+ GrantQrLoginProgress.SyncingSecrets -> LinkDesktopStep.SyncingSecrets
+ is GrantQrLoginProgress.WaitingForAuth -> LinkDesktopStep.WaitingForAuth(
+ verificationUri = verificationUri,
+ )
+ is GrantQrLoginProgress.EstablishingSecureChannel -> LinkDesktopStep.EstablishingSecureChannel(
+ checkCode = checkCode,
+ checkCodeString = checkCodeString,
+ )
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt
new file mode 100644
index 0000000000..0189987d96
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.impl.linknewdevice
+
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import io.element.android.libraries.matrix.api.logs.LoggerTags
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgress
+import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgressListener
+import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeHandler
+import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException
+import timber.log.Timber
+
+private val tag = LoggerTag("RustLinkMobileHandler", LoggerTags.linkNewDevice)
+
+class RustLinkMobileHandler(
+ private val inner: GrantLoginWithQrCodeHandler,
+ private val sessionCoroutineScope: CoroutineScope,
+ private val sessionDispatcher: CoroutineDispatcher,
+) : LinkMobileHandler {
+ private val _linkMobileStep = MutableStateFlow(LinkMobileStep.Uninitialized)
+ override val linkMobileStep: Flow = _linkMobileStep.asStateFlow()
+
+ override suspend fun start() = withContext(sessionDispatcher) {
+ Timber.tag(tag.value).d("Emit Uninitialized")
+ _linkMobileStep.emit(LinkMobileStep.Uninitialized)
+ try {
+ inner.generate(
+ progressListener = object : GrantGeneratedQrLoginProgressListener {
+ override fun onUpdate(state: GrantGeneratedQrLoginProgress) {
+ sessionCoroutineScope.launch {
+ val mappedState = state.map()
+ Timber.tag(tag.value).d("Emit ${mappedState::class.java.simpleName}")
+ _linkMobileStep.emit(mappedState)
+ }
+ }
+ }
+ )
+ } catch (e: HumanQrGrantLoginException) {
+ Timber.tag(tag.value).w(e, "Error during QR login grant")
+ _linkMobileStep.emit(LinkMobileStep.Error(e.map()))
+ }
+ }
+
+ private fun GrantGeneratedQrLoginProgress.map(): LinkMobileStep {
+ return when (this) {
+ GrantGeneratedQrLoginProgress.Done -> LinkMobileStep.Done
+ is GrantGeneratedQrLoginProgress.QrReady -> {
+ LinkMobileStep.QrReady(String(qrCode.toBytes(), Charsets.ISO_8859_1))
+ }
+ is GrantGeneratedQrLoginProgress.QrScanned -> LinkMobileStep.QrScanned(
+ RustCheckCodeSender(
+ inner = checkCodeSender,
+ sessionDispatcher = sessionDispatcher,
+ )
+ )
+ GrantGeneratedQrLoginProgress.Starting -> LinkMobileStep.Starting
+ GrantGeneratedQrLoginProgress.SyncingSecrets -> LinkMobileStep.SyncingSecrets
+ is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth(verificationUri)
+ }
+ }
+}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiCheckCodeSender.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiCheckCodeSender.kt
new file mode 100644
index 0000000000..a19c4e3766
--- /dev/null
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiCheckCodeSender.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.impl.fixtures.fakes
+
+import io.element.android.tests.testutils.lambda.lambdaError
+import org.matrix.rustcomponents.sdk.CheckCodeSender
+import org.matrix.rustcomponents.sdk.NoHandle
+
+class FakeFfiCheckCodeSender(
+ private val sendResult: (UByte) -> Unit = { _ -> lambdaError() }
+) : CheckCodeSender(NoHandle) {
+ override suspend fun send(code: UByte) {
+ sendResult(code)
+ }
+}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt
new file mode 100644
index 0000000000..cd0733695b
--- /dev/null
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.impl.fixtures.fakes
+
+import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgress
+import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgressListener
+import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeHandler
+import org.matrix.rustcomponents.sdk.GrantQrLoginProgress
+import org.matrix.rustcomponents.sdk.GrantQrLoginProgressListener
+import org.matrix.rustcomponents.sdk.NoHandle
+import org.matrix.rustcomponents.sdk.QrCodeData
+
+class FakeFfiGrantLoginWithQrCodeHandler(
+ private val generateResult: () -> Unit = {},
+ private val scanResult: (QrCodeData) -> Unit = {},
+) : GrantLoginWithQrCodeHandler(NoHandle) {
+ private var generateProgressListener: GrantGeneratedQrLoginProgressListener? = null
+ private var scanProgressListener: GrantQrLoginProgressListener? = null
+ override suspend fun generate(progressListener: GrantGeneratedQrLoginProgressListener) {
+ generateProgressListener = progressListener
+ generateResult()
+ }
+
+ fun emitGenerateProgress(progress: GrantGeneratedQrLoginProgress) {
+ generateProgressListener?.onUpdate(progress)
+ }
+
+ override suspend fun scan(qrCodeData: QrCodeData, progressListener: GrantQrLoginProgressListener) {
+ scanProgressListener = progressListener
+ scanResult(qrCodeData)
+ }
+
+ fun emitScanProgress(progress: GrantQrLoginProgress) {
+ scanProgressListener?.onUpdate(progress)
+ }
+}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiQrCodeData.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiQrCodeData.kt
index d377643fa7..4070d1b2cd 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiQrCodeData.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiQrCodeData.kt
@@ -14,8 +14,13 @@ import org.matrix.rustcomponents.sdk.QrCodeData
class FakeFfiQrCodeData(
private val serverNameResult: () -> String? = { lambdaError() },
+ private val toBytesResult: () -> ByteArray = { lambdaError() },
) : QrCodeData(NoHandle) {
override fun serverName(): String? {
return serverNameResult()
}
+
+ override fun toBytes(): ByteArray {
+ return toBytesResult()
+ }
}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeQrCodeDataParser.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeQrCodeDataParser.kt
new file mode 100644
index 0000000000..254258fcf7
--- /dev/null
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeQrCodeDataParser.kt
@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.impl.linknewdevice
+
+import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData
+import org.matrix.rustcomponents.sdk.QrCodeData
+
+class FakeQrCodeDataParser : QrCodeDataParser {
+ override fun parse(data: ByteArray): QrCodeData {
+ return FakeFfiQrCodeData()
+ }
+}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSenderTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSenderTest.kt
new file mode 100644
index 0000000000..5fb4698976
--- /dev/null
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSenderTest.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.impl.linknewdevice
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiCheckCodeSender
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class RustCheckCodeSenderTest {
+ @Test
+ fun `send invokes the Ffi object`() = runTest {
+ val sendResult = lambdaRecorder { }
+ val sut = RustCheckCodeSender(
+ inner = FakeFfiCheckCodeSender(
+ sendResult = sendResult,
+ ),
+ sessionDispatcher = StandardTestDispatcher(testScheduler),
+ )
+ sut.send(1.toUByte())
+ sendResult.assertions().isCalledOnce().with(value(1.toUByte()))
+ }
+
+ @Test
+ fun `validate always returns true for now`() = runTest {
+ val sut = RustCheckCodeSender(
+ inner = FakeFfiCheckCodeSender(),
+ sessionDispatcher = StandardTestDispatcher(testScheduler),
+ )
+ val result = sut.validate(1.toUByte())
+ assertThat(result).isTrue()
+ }
+}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt
new file mode 100644
index 0000000000..a180e4d515
--- /dev/null
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.impl.linknewdevice
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
+import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler
+import io.element.android.libraries.matrix.test.QR_CODE_DATA
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.matrix.rustcomponents.sdk.GrantQrLoginProgress
+import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException
+import org.matrix.rustcomponents.sdk.QrCodeDecodeException
+
+class RustLinkDesktopHandlerTest {
+ @Test
+ fun `handleScannedQrCode function works as expected`() = runTest {
+ val handler = FakeFfiGrantLoginWithQrCodeHandler()
+ val sut = createRustLinkDesktopHandler(
+ handler,
+ )
+ sut.linkDesktopStep.test {
+ val initialItem = awaitItem()
+ assertThat(initialItem).isEqualTo(LinkDesktopStep.Uninitialized)
+ backgroundScope.launch {
+ sut.handleScannedQrCode(QR_CODE_DATA)
+ }
+ runCurrent()
+ // progress from the handler is mapped and emitted
+ listOf(
+ GrantQrLoginProgress.Starting to LinkDesktopStep.Starting,
+ GrantQrLoginProgress.SyncingSecrets to LinkDesktopStep.SyncingSecrets,
+ GrantQrLoginProgress.WaitingForAuth("aVerificationUri")
+ to LinkDesktopStep.WaitingForAuth("aVerificationUri"),
+ GrantQrLoginProgress.EstablishingSecureChannel(1.toUByte(), "1")
+ to LinkDesktopStep.EstablishingSecureChannel(1.toUByte(), "1"),
+ GrantQrLoginProgress.Done to LinkDesktopStep.Done,
+ ).forEach { (progress, expectedStep) ->
+ handler.emitScanProgress(progress)
+ assertThat(awaitItem()).isEqualTo(expectedStep)
+ }
+ }
+ }
+
+ @Test
+ fun `when handleScannedQrCode throws QrCodeDecodeException, the handler emits error step`() = runTest {
+ val handler = FakeFfiGrantLoginWithQrCodeHandler(
+ scanResult = { throw QrCodeDecodeException.Crypto("Scan failed") }
+ )
+ val sut = createRustLinkDesktopHandler(
+ handler,
+ )
+ sut.linkDesktopStep.test {
+ val initialItem = awaitItem()
+ assertThat(initialItem).isEqualTo(LinkDesktopStep.Uninitialized)
+ backgroundScope.launch {
+ sut.handleScannedQrCode(QR_CODE_DATA)
+ }
+ runCurrent()
+ val errorState = awaitItem()
+ assertThat(errorState).isInstanceOf(LinkDesktopStep.InvalidQrCode::class.java)
+ }
+ }
+
+ @Test
+ fun `when handleScannedQrCode throws HumanQrGrantLoginException, the handler emits error step`() = runTest {
+ val handler = FakeFfiGrantLoginWithQrCodeHandler(
+ scanResult = { throw HumanQrGrantLoginException.InvalidCheckCode("Invalid check code") }
+ )
+ val sut = createRustLinkDesktopHandler(
+ handler,
+ )
+ sut.linkDesktopStep.test {
+ val initialItem = awaitItem()
+ assertThat(initialItem).isEqualTo(LinkDesktopStep.Uninitialized)
+ backgroundScope.launch {
+ sut.handleScannedQrCode(QR_CODE_DATA)
+ }
+ runCurrent()
+ val errorState = awaitItem()
+ assertThat(errorState).isInstanceOf(LinkDesktopStep.Error::class.java)
+ val errorType = (errorState as LinkDesktopStep.Error).errorType
+ assertThat(errorType).isInstanceOf(ErrorType.InvalidCheckCode::class.java)
+ }
+ }
+
+ private fun TestScope.createRustLinkDesktopHandler(
+ handler: FakeFfiGrantLoginWithQrCodeHandler = FakeFfiGrantLoginWithQrCodeHandler(),
+ ) = RustLinkDesktopHandler(
+ inner = handler,
+ sessionCoroutineScope = backgroundScope,
+ sessionDispatcher = StandardTestDispatcher(testScheduler),
+ qrCodeDataParser = FakeQrCodeDataParser(),
+ )
+}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt
new file mode 100644
index 0000000000..aa13996e8a
--- /dev/null
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.impl.linknewdevice
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiCheckCodeSender
+import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler
+import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData
+import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgress
+import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException
+
+class RustLinkMobileHandlerTest {
+ @Test
+ fun `start function works as expected`() = runTest {
+ val handler = FakeFfiGrantLoginWithQrCodeHandler()
+ val sut = createRustLinkMobileHandler(
+ handler,
+ )
+ sut.linkMobileStep.test {
+ val initialItem = awaitItem()
+ assertThat(initialItem).isEqualTo(LinkMobileStep.Uninitialized)
+ backgroundScope.launch {
+ sut.start()
+ }
+ runCurrent()
+ // progress from the handler is mapped and emitted
+ listOf(
+ GrantGeneratedQrLoginProgress.Starting to LinkMobileStep.Starting::class.java,
+ GrantGeneratedQrLoginProgress.SyncingSecrets to LinkMobileStep.SyncingSecrets::class.java,
+ GrantGeneratedQrLoginProgress.WaitingForAuth("aVerificationUri")
+ to LinkMobileStep.WaitingForAuth::class.java,
+ GrantGeneratedQrLoginProgress.QrScanned(FakeFfiCheckCodeSender())
+ to LinkMobileStep.QrScanned::class.java,
+ GrantGeneratedQrLoginProgress.QrReady(FakeFfiQrCodeData(toBytesResult = { QR_CODE_DATA_RECIPROCATE }))
+ to LinkMobileStep.QrReady::class.java,
+ GrantGeneratedQrLoginProgress.Done to LinkMobileStep.Done::class.java,
+ ).forEach { (progress, expectedStepClass) ->
+ handler.emitGenerateProgress(progress)
+ assertThat(awaitItem()).isInstanceOf(expectedStepClass)
+ }
+ }
+ }
+
+ @Test
+ fun `when start throws HumanQrGrantLoginException, the handler emits error step`() = runTest {
+ val handler = FakeFfiGrantLoginWithQrCodeHandler(
+ generateResult = { throw HumanQrGrantLoginException.NotFound("Timeout") }
+ )
+ val sut = createRustLinkMobileHandler(
+ handler,
+ )
+ sut.linkMobileStep.test {
+ val initialItem = awaitItem()
+ assertThat(initialItem).isEqualTo(LinkMobileStep.Uninitialized)
+ backgroundScope.launch {
+ sut.start()
+ }
+ runCurrent()
+ val errorState = awaitItem()
+ assertThat(errorState).isInstanceOf(LinkMobileStep.Error::class.java)
+ val errorType = (errorState as LinkMobileStep.Error).errorType
+ assertThat(errorType).isInstanceOf(ErrorType.NotFound::class.java)
+ }
+ }
+
+ private fun TestScope.createRustLinkMobileHandler(
+ handler: FakeFfiGrantLoginWithQrCodeHandler = FakeFfiGrantLoginWithQrCodeHandler(),
+ ) = RustLinkMobileHandler(
+ inner = handler,
+ sessionCoroutineScope = backgroundScope,
+ sessionDispatcher = StandardTestDispatcher(testScheduler),
+ )
+}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
index a2088713a7..a740e71b0c 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
@@ -19,6 +19,8 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.encryption.EncryptionService
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.notification.NotificationService
@@ -96,6 +98,9 @@ class FakeMatrixClient(
private val deactivateAccountResult: (String, Boolean) -> Result = { _, _ -> lambdaError() },
private val currentSlidingSyncVersionLambda: () -> Result = { lambdaError() },
private val ignoreUserResult: (UserId) -> Result = { lambdaError() },
+ private val canLinkNewDeviceResult: () -> Result = { lambdaError() },
+ private val createLinkMobileHandlerResult: () -> Result = { lambdaError() },
+ private val createLinkDesktopHandlerResult: () -> Result = { lambdaError() },
private var unIgnoreUserResult: (UserId) -> Result = { Result.success(Unit) },
private val canReportRoomLambda: () -> Boolean = { false },
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
@@ -362,4 +367,16 @@ class FakeMatrixClient(
override suspend fun performDatabaseVacuum(): Result {
return performDatabaseVacuumLambda()
}
+
+ override suspend fun canLinkNewDevice(): Result = simulateLongTask {
+ return canLinkNewDeviceResult()
+ }
+
+ override fun createLinkDesktopHandler(): Result {
+ return createLinkDesktopHandlerResult()
+ }
+
+ override fun createLinkMobileHandler(): Result {
+ return createLinkMobileHandlerResult()
+ }
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
index dbbf2f2fae..dcacadd975 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
@@ -100,3 +100,31 @@ const val A_LOGIN_HINT = "mxid:@alice:example.org"
@ColorInt
const val A_COLOR_INT: Int = 0xFFFF0000.toInt()
+
+// From https://github.com/matrix-org/matrix-rust-sdk/blob/3a63838cdb50cde3d74da920186fbae0a2e6db37/crates/matrix-sdk-crypto/src/types/qr_login.rs#L275
+// Test vector for the QR code data, copied from the MSC.
+@Suppress("ktlint:standard:argument-list-wrapping")
+val QR_CODE_DATA = listOf(
+ 0x4D, 0x41, 0x54, 0x52, 0x49, 0x58, 0x02, 0x03, 0xd8, 0x86, 0x68, 0x6a, 0xb2, 0x19, 0x7b,
+ 0x78, 0x0e, 0x30, 0x0a, 0x9d, 0x4a, 0x21, 0x47, 0x48, 0x07, 0x00, 0xd7, 0x92, 0x9f, 0x39,
+ 0xab, 0x31, 0xb9, 0xe5, 0x14, 0x37, 0x02, 0x48, 0xed, 0x6b, 0x00, 0x47, 0x68, 0x74, 0x74,
+ 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x72, 0x65, 0x6e, 0x64, 0x65, 0x7a, 0x76, 0x6f, 0x75, 0x73,
+ 0x2e, 0x6c, 0x61, 0x62, 0x2e, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x64, 0x65,
+ 0x76, 0x2f, 0x65, 0x38, 0x64, 0x61, 0x36, 0x33, 0x35, 0x35, 0x2d, 0x35, 0x35, 0x30, 0x62,
+ 0x2d, 0x34, 0x61, 0x33, 0x32, 0x2d, 0x61, 0x31, 0x39, 0x33, 0x2d, 0x31, 0x36, 0x31, 0x39,
+ 0x64, 0x39, 0x38, 0x33, 0x30, 0x36, 0x36, 0x38,
+).map { it.toByte() }.toByteArray()
+
+// Test vector for the QR code data, copied from the MSC, with the mode set to reciprocate.
+@Suppress("ktlint:standard:argument-list-wrapping")
+val QR_CODE_DATA_RECIPROCATE = listOf(
+ 0x4D, 0x41, 0x54, 0x52, 0x49, 0x58, 0x02, 0x04, 0xd8, 0x86, 0x68, 0x6a, 0xb2, 0x19, 0x7b,
+ 0x78, 0x0e, 0x30, 0x0a, 0x9d, 0x4a, 0x21, 0x47, 0x48, 0x07, 0x00, 0xd7, 0x92, 0x9f, 0x39,
+ 0xab, 0x31, 0xb9, 0xe5, 0x14, 0x37, 0x02, 0x48, 0xed, 0x6b, 0x00, 0x47, 0x68, 0x74, 0x74,
+ 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x72, 0x65, 0x6e, 0x64, 0x65, 0x7a, 0x76, 0x6f, 0x75, 0x73,
+ 0x2e, 0x6c, 0x61, 0x62, 0x2e, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x64, 0x65,
+ 0x76, 0x2f, 0x65, 0x38, 0x64, 0x61, 0x36, 0x33, 0x35, 0x35, 0x2d, 0x35, 0x35, 0x30, 0x62,
+ 0x2d, 0x34, 0x61, 0x33, 0x32, 0x2d, 0x61, 0x31, 0x39, 0x33, 0x2d, 0x31, 0x36, 0x31, 0x39,
+ 0x64, 0x39, 0x38, 0x33, 0x30, 0x36, 0x36, 0x38, 0x00, 0x0A, 0x6d, 0x61, 0x74, 0x72, 0x69,
+ 0x78, 0x2e, 0x6f, 0x72, 0x67,
+).map { it.toByte() }.toByteArray()
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeCheckCodeSender.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeCheckCodeSender.kt
new file mode 100644
index 0000000000..a0692b6f5c
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeCheckCodeSender.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.test.linknewdevice
+
+import io.element.android.libraries.matrix.api.linknewdevice.CheckCodeSender
+import io.element.android.tests.testutils.lambda.lambdaError
+import io.element.android.tests.testutils.simulateLongTask
+
+class FakeCheckCodeSender(
+ private val validateResult: (UByte) -> Boolean = { lambdaError() },
+ private val sendResult: (UByte) -> Result = { lambdaError() },
+) : CheckCodeSender {
+ override suspend fun validate(code: UByte): Boolean = simulateLongTask {
+ validateResult(code)
+ }
+
+ override suspend fun send(code: UByte): Result = simulateLongTask {
+ sendResult(code)
+ }
+}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkDesktopHandler.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkDesktopHandler.kt
new file mode 100644
index 0000000000..0bf9dafd01
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkDesktopHandler.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.test.linknewdevice
+
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
+import io.element.android.tests.testutils.lambda.lambdaError
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class FakeLinkDesktopHandler(
+ private val handleScannedQrCodeResult: (ByteArray) -> Unit = { lambdaError() },
+) : LinkDesktopHandler {
+ private val mutableLinkDesktopStep: MutableStateFlow = MutableStateFlow(LinkDesktopStep.Uninitialized)
+ override val linkDesktopStep: StateFlow
+ get() = mutableLinkDesktopStep.asStateFlow()
+
+ override suspend fun handleScannedQrCode(data: ByteArray) {
+ handleScannedQrCodeResult(data)
+ }
+
+ suspend fun emitStep(step: LinkDesktopStep) {
+ mutableLinkDesktopStep.emit(step)
+ }
+}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkMobileHandler.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkMobileHandler.kt
new file mode 100644
index 0000000000..de704ea2dd
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkMobileHandler.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2025 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.libraries.matrix.test.linknewdevice
+
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import io.element.android.tests.testutils.lambda.lambdaError
+import io.element.android.tests.testutils.simulateLongTask
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class FakeLinkMobileHandler(
+ private val startResult: () -> Unit = { lambdaError() },
+) : LinkMobileHandler {
+ private val mutableLinkMobileStep: MutableStateFlow = MutableStateFlow(LinkMobileStep.Uninitialized)
+ override val linkMobileStep: StateFlow
+ get() = mutableLinkMobileStep.asStateFlow()
+
+ override suspend fun start() = simulateLongTask {
+ startResult()
+ }
+
+ suspend fun emitStep(step: LinkMobileStep) {
+ mutableLinkMobileStep.emit(step)
+ }
+}
diff --git a/libraries/qrcode/build.gradle.kts b/libraries/qrcode/build.gradle.kts
index cbf4c2de78..cf76e117c0 100644
--- a/libraries/qrcode/build.gradle.kts
+++ b/libraries/qrcode/build.gradle.kts
@@ -19,4 +19,5 @@ dependencies {
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.camera2)
implementation(libs.zxing.cpp)
+ implementation(libs.google.zxing)
}
diff --git a/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt
index 7f089818d0..18bd1cff10 100644
--- a/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt
+++ b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt
@@ -31,6 +31,7 @@ import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -117,7 +118,13 @@ fun QrCodeCameraView(
.background(color = ElementTheme.colors.bgSubtlePrimary),
contentAlignment = Alignment.Center,
) {
- Text("CameraView")
+ Text(
+ text = buildString {
+ append("CameraView\n")
+ append(if (isScanning) "scanning" else "frozen")
+ },
+ textAlign = TextAlign.Center,
+ )
}
} else {
AndroidView(
diff --git a/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeImage.kt b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeImage.kt
new file mode 100644
index 0000000000..e045e42f17
--- /dev/null
+++ b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeImage.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2025 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.libraries.qrcode
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import androidx.annotation.ColorInt
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.IntSize
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.common.BitMatrix
+import com.google.zxing.qrcode.QRCodeWriter
+import io.element.android.libraries.designsystem.modifiers.squareSize
+import io.element.android.libraries.designsystem.utils.ForceMaxBrightness
+
+private fun String.toBitMatrix(size: Int): BitMatrix {
+ return QRCodeWriter().encode(
+ this,
+ BarcodeFormat.QR_CODE,
+ size,
+ size,
+ )
+}
+
+private fun BitMatrix.toBitmap(
+ @ColorInt backgroundColor: Int = Color.WHITE,
+ @ColorInt foregroundColor: Int = Color.BLACK,
+): Bitmap {
+ val colorBuffer = IntArray(width * height)
+ var rowOffset = 0
+ for (y in 0 until height) {
+ for (x in 0 until width) {
+ val arrayIndex = x + rowOffset
+ colorBuffer[arrayIndex] = if (get(x, y)) foregroundColor else backgroundColor
+ }
+ rowOffset += width
+ }
+ return Bitmap.createBitmap(colorBuffer, width, height, Bitmap.Config.ARGB_8888)
+}
+
+@Composable
+fun QrCodeImage(
+ data: String,
+ forceMaxBrightness: Boolean = true,
+ modifier: Modifier = Modifier,
+) {
+ if (forceMaxBrightness) {
+ ForceMaxBrightness()
+ }
+ var size by remember { mutableStateOf(IntSize.Zero) }
+ Box(
+ modifier = modifier
+ .squareSize()
+ .onSizeChanged {
+ size = it
+ },
+ ) {
+ val image = remember(data, size) {
+ val sideSide = maxOf(size.width, size.height).coerceAtLeast(128)
+ data.toBitMatrix(sideSide).toBitmap().asImageBitmap()
+ }
+ Image(
+ contentDescription = null,
+ bitmap = image,
+ )
+ }
+}
+
+@Composable
+@Preview
+internal fun QrCodeViewPreview() {
+ QrCodeImage(
+ modifier = Modifier.fillMaxHeight(),
+ data = "RANDOM_QRCODE_DATA",
+ )
+}
diff --git a/libraries/ui-strings/src/main/res/values-et/translations.xml b/libraries/ui-strings/src/main/res/values-et/translations.xml
index e7444c442b..76a703f9e1 100644
--- a/libraries/ui-strings/src/main/res/values-et/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-et/translations.xml
@@ -434,32 +434,6 @@ Kas sa oled kindel, et soovid jätkata?"
"Valikud"
"Kustuta: %1$s"
"Seadistused"
- "Skaneeri QR-koodi"
- "Ava %1$s kas oma süle- või lauaarvutis"
- "Skaneeri QR-koodi selle seadmega"
- "Skaneerimiseks valmis"
- "QR-koodi laadimiseks ava %1$s süle- või lauaarvutis"
- "Numbrid ei klapi"
- "Sisesta kahekohaline kood"
- "Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline."
- "Sisesta teises seadmes kuvatud number"
- "Sinu teenusepakkuja ei toeta rakendust %1$s."
- "%1$s pole toetatud"
- "Sinu kasutajakonto teenusepakkuja ei toeta võimalust logida sisse QR-koodi abil."
- "QR-kood pole toetatud"
- "Sisselogimine katkestati teises seadmes."
- "Sisselogimispäring on tühistatud"
- "Sisselogimine aegus. Palun proovi uuesti."
- "Sisselogimine jäi etteantud aja jooksul tegemata"
- "Ava %1$s teises seadmes"
- "Vali %1$s"
- "„Logi sisse QR-koodiga“"
- "Skaneeri siin näidatud QR-koodi teise seadmega"
- "Ava %1$s teises seadmes"
- "Lauaarvuti"
- "Laadin QR-koodi…"
- "Nutiseade"
- "Mis tüüpi seadet soovid siduda?"
"Kogukonnad, milles on võimalik jututoaga liituda ilma kutseta."
"Halda kogukondi"
"(Tundmatu kogukond)"
diff --git a/libraries/ui-strings/src/main/res/values-hr/translations.xml b/libraries/ui-strings/src/main/res/values-hr/translations.xml
index 5c3290e187..1d41bf0593 100644
--- a/libraries/ui-strings/src/main/res/values-hr/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-hr/translations.xml
@@ -442,32 +442,6 @@ Jeste li sigurni da želite nastaviti?"
"Mogućnosti"
"Ukloni %1$s"
"Postavke"
- "Skeniraj QR kod"
- "Otvorite %1$s na prijenosnom ili stolnom računalu"
- "Skenirajte QR kod ovim uređajem"
- "Spremno za skeniranje"
- "Otvorite %1$s na stolnom računalu kako biste dobili QR kod"
- "Brojevi se ne podudaraju"
- "Unesite dvoznamenkasti kod"
- "Time ćete potvrditi da je veza s vašim drugim uređajem sigurna."
- "Unesite broj prikazan na vašem drugom uređaju"
- "Vaš davatelj usluga računa ne podržava %1$s."
- "%1$s nije podržan"
- "Vaš davatelj usluga računa ne podržava prijavu na novi uređaj pomoću QR koda."
- "QR kod nije podržan"
- "Prijava je otkazana na drugom uređaju."
- "Zahtjev za prijavu je otkazan"
- "Prijava je istekla. Pokušajte ponovno."
- "Prijava nije dovršena na vrijeme"
- "Otvorite %1$s na drugom uređaju"
- "Odaberi %1$s"
- "“Prijavi se pomoću QR koda”"
- "Skenirajte ovdje prikazani QR kod drugim uređajem"
- "Otvorite %1$s na drugom uređaju"
- "Stolno računalo"
- "Učitavanje QR koda…"
- "Mobilni uređaj"
- "Koju vrstu uređaja želite povezati?"
"Prostori u kojima se članovi mogu pridružiti sobi bez pozivnice."
"Upravljaj prostorima"
"(nepoznati prostor)"
diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml
index bf82feec26..9e56d09b16 100644
--- a/libraries/ui-strings/src/main/res/values-ro/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml
@@ -442,32 +442,6 @@ Sunteți sigur că doriți să continuați?"
"Opțiuni"
"Ștergeți %1$s"
"Setări"
- "Scanați codul QR"
- "Deschide %1$s pe un laptop sau un computer desktop"
- "Scanați codul QR cu acest dispozitiv"
- "Gata de scanare"
- "Deschide %1$s pe un computer desktop pentru a obține codul QR"
- "Numerele nu se potrivesc"
- "Introduceți codul de 2 cifre"
- "Aceasta va verifica dacă conexiunea cu celălalt dispozitiv este sigură."
- "Introduceți numărul afișat pe celălalt dispozitiv"
- "Furnizorul contului dumneavoastră nu acceptă %1$s."
- "%1$s nu este acceptat"
- "Furnizorul contului dumneavoastră nu acceptă conectarea la un dispozitiv nou cu un cod QR."
- "Codul QR nu este acceptat"
- "Autentificarea a fost anulată de pe celălalt dispozitiv."
- "Cererea de autentificare a fost anulată"
- "Conectarea a expirat. Vă rugăm să încercați din nou."
- "Conectarea nu a fost finalizată la timp"
- "Deschideți %1$s pe celălalt dispozitiv"
- "Selectați %1$s"
- "“Conectați-vă cu un cod QR”"
- "Scanați codul QR afișat aici cu celălalt dispozitiv."
- "Deschideți %1$s pe celălalt dispozitiv"
- "Calculator desktop"
- "Se încarcă codul QR…"
- "Dispozitiv mobil"
- "Ce tip de dispozitiv doriți să conectați?"
"Spațile din care membrii se pot alătura camerei fără invitație."
"Gestionați spațiile"
"(Spațiu necunoscut)"
diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml
index ac6157e5e2..690a53b343 100644
--- a/libraries/ui-strings/src/main/res/values/localazy.xml
+++ b/libraries/ui-strings/src/main/res/values/localazy.xml
@@ -434,32 +434,6 @@ Are you sure you want to continue?"
"Options"
"Remove %1$s"
"Settings"
- "Scan the QR code"
- "Open %1$s on a laptop or desktop computer"
- "Scan the QR code with this device"
- "Ready to scan"
- "Open %1$s on a desktop computer to get the QR code"
- "The numbers don’t match"
- "Enter 2-digit code"
- "This will verify that the connection to your other device is secure."
- "Enter the number shown on your other device"
- "Your account provider does not support %1$s."
- "%1$s not supported"
- "Your account provider doesn’t support signing into a new device with a QR code."
- "QR code not supported"
- "The sign in was cancelled on the other device."
- "Sign in request cancelled"
- "Sign in expired. Please try again."
- "The sign in was not completed in time"
- "Open %1$s on the other device"
- "Select %1$s"
- "“Sign in with QR code”"
- "Scan the QR code shown here with the other device"
- "Open %1$s on the other device"
- "Desktop computer"
- "Loading QR code…"
- "Mobile device"
- "What type of device do you want to link?"
"Spaces where members can join the room without an invitation."
"Manage spaces"
"(Unknown space)"
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Day_0_en.png
new file mode 100644
index 0000000000..a28be5b7c6
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e3b60fe3c4ca517486d0eec1435498311525b8f3b1698d04d9a41a948559332b
+size 43861
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Day_1_en.png
new file mode 100644
index 0000000000..282cada077
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Day_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dcab3501c7ec8e1bd100016736946a3462405ce90e75610967e71a01445322ed
+size 43207
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Night_0_en.png
new file mode 100644
index 0000000000..b8de2286e7
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f452019200e44097f9c4f7911fa144d4cc88fb63a58a8351a820d8e34b7d7a53
+size 42829
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Night_1_en.png
new file mode 100644
index 0000000000..2daeb9dca0
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Night_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6dfcfce70c52b8bdbc06154d0a206f25655f01ae7548f8d624a4bd4ad87330a6
+size 40871
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_0_en.png
new file mode 100644
index 0000000000..b2f95a9597
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:24fd98aa0c43fdf26b67ec4dffc72d7f2d48f5d19f0a68a79c2c2c53e6c5fd12
+size 20769
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_1_en.png
new file mode 100644
index 0000000000..b8fe60681d
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:58ddf6004bf14c10a0b65f271f6e95641891777e28ab12967e234363a10c33a5
+size 18411
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_2_en.png
new file mode 100644
index 0000000000..5b85ab50d6
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c7f5c9646571391933edc18c8a6b672b5208a6a4636b5bc15f77b8a06c6ec4a6
+size 21751
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_3_en.png
new file mode 100644
index 0000000000..ecd17390a7
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:157115c94aff169efbcb87343c4167334d074a4eb78ff4ddb075fa0cfff2c400
+size 33058
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_4_en.png
new file mode 100644
index 0000000000..74694ddb0a
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7077c7ed0c0656c538887db2e08e7c288e522261c160a13753e1573ed18606ca
+size 32697
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_5_en.png
new file mode 100644
index 0000000000..dc89ee8fc3
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_5_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6e0ae0d30fde1317c5b8f20569597cc6039eac3e8f70c80c075ef4259e3e74c4
+size 67039
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_6_en.png
new file mode 100644
index 0000000000..94ef0539ac
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_6_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9077a09b21bbdc7e2a22cf4fca9a6533ec11b1b6e898aa71d2b5393ef01f426c
+size 21058
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_7_en.png
new file mode 100644
index 0000000000..60b3be0d0f
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Day_7_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:330d0d44bb7638a4668fa33974e3b2cd31b3ce285776c993cced2b928b01bbb0
+size 20909
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_0_en.png
new file mode 100644
index 0000000000..733d9413a4
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fe2527eae9bc919a3994cd59e8f6d0c181aa3ab9209b1c34b2bf6e63c7a434af
+size 20505
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_1_en.png
new file mode 100644
index 0000000000..0199609ebf
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d015f4c5905ab38b4bd987aaf4bf0ac8f55795700de8ae19b5872d4acd5bfd8c
+size 18323
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_2_en.png
new file mode 100644
index 0000000000..c19826eb03
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:443ca45f4951fc0ef2d390cca6bb2c5717c0160a9df04fd3fe5bb7ede82a1f91
+size 21530
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_3_en.png
new file mode 100644
index 0000000000..17753af5d1
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f72a52b922d9a4b058cc5bf1475711b0d1adb6e4ebb1b3e75278a4b9117a9869
+size 32359
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_4_en.png
new file mode 100644
index 0000000000..e92d032dea
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3430a0cdbd3eb28989a5f9fb397fc082b012ea2a3c284787d27da24870ecd12c
+size 32244
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_5_en.png
new file mode 100644
index 0000000000..f550be4d00
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_5_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a90a5fb148455d4e4d9c4d8332222927d6cf3c606e27c695e4c44af13cedad9a
+size 65592
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_6_en.png
new file mode 100644
index 0000000000..a9c4133bfa
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_6_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f6481d57a4d3331e812b310a4a90963e30255ef894a9644df66de73eccccea7d
+size 20735
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_7_en.png
new file mode 100644
index 0000000000..a1fdb67640
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.error_ErrorView_Night_7_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:516a6c8873c80c96febb9edf8956bccb00b849301df4682b969d3979158316a0
+size 20632
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number.component_NumberTextField_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number.component_NumberTextField_Day_0_en.png
new file mode 100644
index 0000000000..b88e154565
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number.component_NumberTextField_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ce3903e4d279b4550ed6097eb030edb512ef7aee47392fcad6e48628afeed256
+size 6183
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number.component_NumberTextField_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number.component_NumberTextField_Night_0_en.png
new file mode 100644
index 0000000000..91d4e1760b
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number.component_NumberTextField_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a075fe842940349e4528aeefd1cfe833b682641b51f6eac51009a8f471be35e4
+size 6197
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_0_en.png
new file mode 100644
index 0000000000..d379df9f93
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8c7f784a38660b1d75c7608310d84d99747beac15085d315f0f769a6ff6efa6d
+size 30212
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_1_en.png
new file mode 100644
index 0000000000..09100c4e49
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:020f577d6a2504664933b4a6b642ba1f7ac41b8babbd5d118e714d2e9e0a08b3
+size 30197
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_2_en.png
new file mode 100644
index 0000000000..0857590460
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:39333077444bf27e5c21750a7ecad964f761b8d784324cb12ae31035c94a0a37
+size 30806
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_3_en.png
new file mode 100644
index 0000000000..d3e5554074
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ae4edadc0fda7c5c5f75866de9779da968036705965230dfcfc3a03e5f8e2757
+size 30936
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_4_en.png
new file mode 100644
index 0000000000..bbd3e09e23
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4c968f0c1c8bd1d21fda861861f1a7472479a97f7413a7ccdcefbb864d619a5e
+size 34248
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_5_en.png
new file mode 100644
index 0000000000..f17da429a2
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Day_5_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7812c0a76fb369670d3bf90828aa94df52902dc092da0d22c4dc76f437f22c9e
+size 33961
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_0_en.png
new file mode 100644
index 0000000000..701d1405f1
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:637e1029b3a8193c2dfa4d5511933ff75c54bb2a0e37afade1b550cbd0050279
+size 29203
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_1_en.png
new file mode 100644
index 0000000000..4bf546ba11
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f02d37ae2b82d50440539bb1299053c1e431f14a4d302fc2fd9fdacd5abe34ee
+size 29170
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_2_en.png
new file mode 100644
index 0000000000..8b454226be
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a55f9b20767eb0586d12639a589ab6119496939cb17d0fed6e58f3c2b22aa511
+size 29893
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_3_en.png
new file mode 100644
index 0000000000..12c06cbf70
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ca8b496d6d6eb08d78b3039b479e4742bfac568cf5c2d6f3217b0af63abca9d9
+size 29918
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_4_en.png
new file mode 100644
index 0000000000..7adeb74986
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:924e2289ca0d36b7504820ccc48d9eb943ebc19262d8fd3b8357138a0ffb50ce
+size 33170
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_5_en.png
new file mode 100644
index 0000000000..f342186acd
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.number_EnterNumberView_Night_5_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8025c4c03d58a10dd9cc7b174e6c825b2359777f442458071baacc533bba6b35
+size 32868
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_0_en.png
new file mode 100644
index 0000000000..083cc9d823
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bd8e91925396d1702df49a2e342730cda9a0318adb8a3d17dd906d8185d5c846
+size 32140
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_0_en.png
new file mode 100644
index 0000000000..474b47228a
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4ab17222e6d51768a8863ee4d282ef36340ca6fa47e8ddb597c1dbd409e213d3
+size 32680
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_0_en.png
new file mode 100644
index 0000000000..303f628e09
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ace7883ab223f7031d0eccdbbfe96b89f21481bdfa8c34a4470b5488b7db42ed
+size 18256
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_1_en.png
new file mode 100644
index 0000000000..361a2b5af9
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c59349060460c76f4b3db020ea5642e70bd21acc963ec32567eb6b5dae142c86
+size 24432
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_2_en.png
new file mode 100644
index 0000000000..2d02847167
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:37b0b44b213ca6daf129f096ca3d909031a4d79bdee79fe54595e4bf08f93977
+size 25775
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_3_en.png
new file mode 100644
index 0000000000..13180a6b8d
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1e764db89c970799b230e16a1f345d4f78b58ff00ea7ef03e94b863763485768
+size 21795
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_4_en.png
new file mode 100644
index 0000000000..e923896419
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:670582ea30ebb350af301f4ca8c15f6bcb4c199b67366e19bbdf30456789f470
+size 23962
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_5_en.png
new file mode 100644
index 0000000000..9816c6d1c5
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_5_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:096f01d87cf3414f18c67d24269d5ea5284a0652b5e6137ebb117645d8e02cb0
+size 34075
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_0_en.png
new file mode 100644
index 0000000000..19573907e6
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:623ceb15f4fa87d4674249693a94b89cd742ed93df9c36b2603a54bf18cb879c
+size 17626
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_1_en.png
new file mode 100644
index 0000000000..269aab3c89
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ddabca52e06c1bc184dc0deb71651b5d282adb55a8264fd588345937f4410f2f
+size 23511
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_2_en.png
new file mode 100644
index 0000000000..ed74d392ac
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:64b240c3bb9e40677dddfdaf5e8cfa09c1f2c1a19d9544bd74634a9a04262943
+size 25387
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_3_en.png
new file mode 100644
index 0000000000..bb7a6f3a24
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0f13958a45f7afe8f57478a6c2539ae26e8c470bf4a412f06cfa540448506b29
+size 21070
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_4_en.png
new file mode 100644
index 0000000000..3b7e50be12
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:96d9c8f4dde2d1330572e30014d6262f88575d5f012d7986c2a6a938b4a66db6
+size 22952
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_5_en.png
new file mode 100644
index 0000000000..100368ef3f
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_5_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b41a1f00e6bc7cca9af41e0d4b62a9e05370f191464e3d76cee878fad9dc230e
+size 31941
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_0_en.png
new file mode 100644
index 0000000000..f6a46dab95
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:76af36f8440efa9c2b2c3a98d34e8ed356390158a9f1b2cbe9e86c820553213b
+size 15956
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_1_en.png
new file mode 100644
index 0000000000..4e809b9b7e
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7c0239a08b541d07fb2d916624fe97dc997cbe0a05903b8aa93c05cc172b0640
+size 16602
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_2_en.png
new file mode 100644
index 0000000000..21294e5561
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bbd8bb22a902f03847c22b27afed456eb08e60656e6777803531cc9448b7a42b
+size 16760
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_3_en.png
new file mode 100644
index 0000000000..43adf5019f
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7284871dd335aa43efdcde312febfefbf0e44ce38e41943609faa3c3e2c3c1c8
+size 27442
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_0_en.png
new file mode 100644
index 0000000000..db5157c4d3
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c8f45fcfe13302231733a1d5d845f9eee9f5428381a312e021d1f34cad47e66f
+size 15312
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_1_en.png
new file mode 100644
index 0000000000..bf6cc48d4b
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:def2668a2168bb3f1c37994914fd2f32df3c900bf0d43fc5b6bca9f1d8183240
+size 15927
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_2_en.png
new file mode 100644
index 0000000000..d5b00ae510
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d7a4a1642295e2e8b6f7aba548b54ebc122f525a28394414aa7513d9c60bbc23
+size 16093
diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_3_en.png
new file mode 100644
index 0000000000..2967e646da
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:807e0cd53e97683966343e75079a287e17484cc3026ac328792da17cda023ee3
+size 26379
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_0_en.png
index 0a4ee279c8..4e809b9b7e 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:58217104c9a26ab0fee9cdcb24827800a61b00730e7c52a8ddb900d88f6aa55a
-size 14629
+oid sha256:7c0239a08b541d07fb2d916624fe97dc997cbe0a05903b8aa93c05cc172b0640
+size 16602
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_1_en.png
index 76cac663ab..5a2f32de64 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_1_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:47a1d2a56df0fd2ac662eaef18e35a0ea524cd2fe47e381adbddea6bd300bd8a
-size 19774
+oid sha256:5ebfd4977ff437718e99e23e982dd1b1aa74e8bea6b1f4ffed07d515807d840e
+size 21097
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_2_en.png
index e16c2b9ffd..43adf5019f 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_2_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_2_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:325dcaac7e37ba3977d1bf867464bafe09b52d8818024b2b0994976bf9374315
-size 26128
+oid sha256:7284871dd335aa43efdcde312febfefbf0e44ce38e41943609faa3c3e2c3c1c8
+size 27442
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_3_en.png
index bb9637fc2b..845385f33c 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_3_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_3_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2f8ccc661ad150a6c8ea19c3051ce68863d7add01b83d5b18d365ba0173786d7
-size 32284
+oid sha256:1d2fc6e9cdaff3313b30f506966c3b6c0cba5df0ffd6572b39b017c717837cd5
+size 33520
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_4_en.png
index 9c7478491b..618f027a77 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_4_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_4_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:82708f952ddc924e20d13a1d49725118b2e49b6e962e4a67ed3c136acd063a61
-size 31304
+oid sha256:c218a232169097b71be6a44a52fee46569fd2db49c374627fc24f4ebb492ee1d
+size 32458
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_5_en.png
index 2b2e86defd..84310320d0 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_5_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_5_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a3586a4cd910c491dfdd0fd516e05d830c877d940663dabab0b0a50a511a41fa
-size 30449
+oid sha256:308023d6e55d65f9091dd01dc7b27e12a1b62f8560288a1a6d2b28d23c2ea317
+size 31727
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_0_en.png
index b73f4d4be2..bf6cc48d4b 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:68751e4e93cfa457f753b1605637a8d1a4b9c5e600ec86097b05a5aa8e3ed345
-size 13955
+oid sha256:def2668a2168bb3f1c37994914fd2f32df3c900bf0d43fc5b6bca9f1d8183240
+size 15927
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_1_en.png
index 33e8d0cfce..816496a9ab 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_1_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:3205d7cc5b33732414c493ee3564a4f94669c01796d428d100d67212f6618c5c
-size 19014
+oid sha256:ad74d69f874563e3211c703a1b428972efcab4eed3672766ba441cf6ae8e8fa6
+size 20375
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_2_en.png
index dc1d1efd31..2967e646da 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_2_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_2_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:36b854e59ff8875a7132340ab2b4ef2dfea0e824d38663e95b1016fec20e064c
-size 25012
+oid sha256:807e0cd53e97683966343e75079a287e17484cc3026ac328792da17cda023ee3
+size 26379
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_3_en.png
index eab090d118..53ffb846dd 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_3_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_3_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4bf95bcff03d9cb1d70b29b507563e7fb131885548297a3ba8ca49e1dc8e1fe0
-size 31055
+oid sha256:cd71385b1d51e6e6175847766149ffafafec5337109ac18c615dad0251eedee3
+size 32438
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_4_en.png
index ebda0c7f9f..2920e464c6 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_4_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_4_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8c151e4afc239da27ef4a0510cce5b2ae7ac061a540c53e650333f0aba4560c3
-size 30107
+oid sha256:b0e2213b46c83b3256293a7b0638c7b9dd51d2ee9ccd3aa77d3dbce7da84d66f
+size 31363
diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_5_en.png
index 06075aaa85..4d8c26a700 100644
--- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_5_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_5_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:72a487fd1e35beaacc38b0fd059a8cee5d6e67abc908731b7820d78f0631d452
-size 29120
+oid sha256:d2a1545d8111c03a01923f8cdfc227adc5a87bb89074f45aa542bfcb9e18ccf4
+size 30489
diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png
index 4dfe012539..1ce2ca38bc 100644
--- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:defcbf960501cd0f7ad34a340c72834e317e662573019a72f26df3e83ac68393
-size 39318
+oid sha256:8276f281154b0efb30086a0a29dd2151ee877661bd777d4ef8a3b42fc997088a
+size 38904
diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png
index 32f87b189c..3bcecbb003 100644
--- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:3912aaca2e36211f4d799d9b8aa3fdebcae17ef1fa01c70de6a59e77561dd539
-size 39163
+oid sha256:8124cb5722fc04a67eed1c0c6752fae79e0c442e5e6195cfde84849cf9312756
+size 38732
diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png
index af13bb583f..2ab90ddeac 100644
--- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:871ce6a6581c7e7d5cbf4616055ea6bb3b27db28c6cb6f7ab4ed2f5a3b9dd588
-size 40276
+oid sha256:39115aa6c569076f4ffa7be324f10c9de9029e3c4c73cf1b9ba203872de27175
+size 39830
diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png
index 6c8c320105..3aa263b83e 100644
--- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6e8f8c4afe5ea44d4e2b54d453c4e3a671eb107d4056518a43cdadcbba13ae0b
-size 40324
+oid sha256:ae265a9ba682229a1c598e9bbeee515799e09853f62c5e6351f15b70b065aebb
+size 39876
diff --git a/tests/uitests/src/test/snapshots/images/libraries.qrcode_QrCodeView_en.png b/tests/uitests/src/test/snapshots/images/libraries.qrcode_QrCodeView_en.png
new file mode 100644
index 0000000000..bca09dbeb2
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.qrcode_QrCodeView_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6aea8452a990307184b9d3f3ceef60e3c09de4ac8128362cafd23ab64a1fe8d7
+size 10600
diff --git a/tools/localazy/config.json b/tools/localazy/config.json
index b7db5f05e6..a8572fb660 100644
--- a/tools/localazy/config.json
+++ b/tools/localazy/config.json
@@ -155,6 +155,17 @@
"troubleshoot_notifications_test_unified_push_.*"
]
},
+ {
+ "name" : ":features:linknewdevice:impl",
+ "includeRegex" : [
+ "screen\\.link_new_device\\..*",
+ "screen_qr_code_login_error_.*",
+ "screen_qr_code_login_connection_note_secure_state.*",
+ "screen_qr_code_login_unknown_error_description",
+ "screen_qr_code_login_invalid_scan_state_.*",
+ "screen_qr_code_login_no_camera_permission_state_.*"
+ ]
+ },
{
"name" : ":features:login:impl",
"includeRegex" : [