Merge pull request #5909 from element-hq/feature/bma/qrCodeLogin

Link new device using QrCode - First version
This commit is contained in:
Benoit Marty
2025-12-18 16:08:21 +01:00
committed by GitHub
180 changed files with 5052 additions and 144 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<!-- To open URL in CustomTab (prefetch, etc.). It makes CustomTabsClient.getPackageName() work
see https://developer.android.com/training/package-visibility/use-cases#open-urls-custom-tabs -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
</manifest>

View File

@@ -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<LinkNewDeviceFlowNode>(
buildContext = buildContext,
plugins = listOf(
callback,
)
)
}
}

View File

@@ -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>(
LinkDesktopStep.Uninitialized
)
val stepFlow: StateFlow<LinkDesktopStep>
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)
}
}
}
}

View File

@@ -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<Plugin>,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val linkNewMobileHandler: LinkNewMobileHandler,
private val linkNewDesktopHandler: LinkNewDesktopHandler,
) : BaseFlowNode<LinkNewDeviceFlowNode.NavTarget>(
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<LinkNewDeviceRootNode>(buildContext, listOf(callback))
}
NavTarget.DesktopNotice -> {
val callback = object : DesktopNoticeNode.Callback {
override fun navigateBack() {
backstack.pop()
}
override fun navigateToQrCodeScanner() {
backstack.push(NavTarget.DesktopScanQrCode)
}
}
createNode<DesktopNoticeNode>(buildContext, listOf(callback))
}
NavTarget.DesktopScanQrCode -> {
val callback = object : ScanQrCodeNode.Callback {
override fun cancel() {
backstack.pop()
}
}
createNode<ScanQrCodeNode>(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<EnterNumberNode>(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<ShowQrCodeNode>(buildContext, listOf(inputs, callback))
}
is NavTarget.Error -> {
val callback = object : ErrorNode.Callback {
override fun onRetry() {
linkNewMobileHandler.reset()
linkNewDesktopHandler.reset()
backstack.newRoot(NavTarget.Root)
}
}
createNode<ErrorNode>(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()
}
}

View File

@@ -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>(
LinkMobileStep.Uninitialized
)
val stepFlow: StateFlow<LinkMobileStep>
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)
}
}
}

View File

@@ -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
}

View File

@@ -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<Plugin>,
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,
)
}
}

View File

@@ -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<DesktopNoticeState> {
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,
)
}
}

View File

@@ -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,
)

View File

@@ -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<DesktopNoticeState> {
override val values: Sequence<DesktopNoticeState>
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
)

View File

@@ -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 = { },
)
}

View File

@@ -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<Plugin>,
) : Node(buildContext = buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onRetry()
}
private val callback: Callback = callback()
private val errorScreenType = inputs<ErrorScreenType>()
@Composable
override fun View(modifier: Modifier) {
ErrorView(
modifier = modifier,
errorScreenType = errorScreenType,
onRetry = callback::onRetry,
)
}
}

View File

@@ -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
}

View File

@@ -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<ErrorScreenType> {
override val values: Sequence<ErrorScreenType> = sequenceOf(
ErrorScreenType.Cancelled,
ErrorScreenType.Declined,
ErrorScreenType.Expired,
ErrorScreenType.ProtocolNotSupported,
ErrorScreenType.Mismatch2Digits,
ErrorScreenType.InsecureChannelDetected,
ErrorScreenType.SlidingSyncNotAvailable,
ErrorScreenType.UnknownError,
)
}

View File

@@ -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 = {},
)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<Plugin>,
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()
}
}

View File

@@ -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<EnterNumberState> {
@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<MutableState<AsyncAction<Unit>>> { 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,
)
}
}

View File

@@ -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<Unit>,
val eventSink: (EnterNumberEvent) -> Unit,
) {
val numberEntry = Number.createEmpty(Config.VERIFICATION_CODE_LENGTH).fillWith(number)
val isContinueButtonEnabled: Boolean
get() = numberEntry.isComplete() && !sendingCode.isLoading()
}

View File

@@ -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<EnterNumberState> {
override val values: Sequence<EnterNumberState>
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<Unit> = AsyncAction.Uninitialized,
eventSink: (EnterNumberEvent) -> Unit = {},
) = EnterNumberState(
number = number,
sendingCode = sendingCode,
eventSink = eventSink,
)

View File

@@ -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 = { },
)
}

View File

@@ -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 = {},
)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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<Digit>,
) {
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<Digit>(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 }
}
}

View File

@@ -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<Plugin>,
) : Node(buildContext, plugins = plugins) {
class Inputs(
val data: String,
) : NodeInputs
interface Callback : Plugin {
fun navigateBack()
}
private val inputs: Inputs = inputs<Inputs>()
private val callback: Callback = callback()
@Composable
override fun View(modifier: Modifier) {
ShowQrCodeView(
data = inputs.data,
modifier = modifier,
onBackClick = callback::navigateBack,
)
}
}

View File

@@ -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 = { },
)
}

View File

@@ -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
}

View File

@@ -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<Plugin>,
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,
)
}
}

View File

@@ -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<LinkNewDeviceRootState> {
@Composable
override fun present(): LinkNewDeviceRootState {
val coroutineScope = rememberCoroutineScope()
var isSupported by remember { mutableStateOf<AsyncData<Boolean>>(AsyncData.Uninitialized) }
var qrCodeData by remember { mutableStateOf<AsyncData<Unit>>(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,
)
}
}

View File

@@ -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<Boolean>,
val qrCodeData: AsyncData<Unit>,
val eventSink: (LinkNewDeviceRootEvent) -> Unit,
)

View File

@@ -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<LinkNewDeviceRootState> {
override val values: Sequence<LinkNewDeviceRootState>
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<Boolean> = AsyncData.Uninitialized,
qrCodeData: AsyncData<Unit> = AsyncData.Uninitialized,
eventSink: (LinkNewDeviceRootEvent) -> Unit = { },
) = LinkNewDeviceRootState(
isSupported = isSupported,
qrCodeData = qrCodeData,
eventSink = eventSink,
)

View File

@@ -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 = { },
)
}

View File

@@ -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
}

View File

@@ -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<Plugin>,
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
)
}
}

View File

@@ -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<ScanQrCodeState> {
@Composable
override fun present(): ScanQrCodeState {
val coroutineScope = rememberCoroutineScope()
var scanAction: AsyncAction<Unit> 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,
)
}
}

View File

@@ -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<Unit>,
val eventSink: (ScanQrCodeEvent) -> Unit,
)

View File

@@ -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<ScanQrCodeState> {
override val values: Sequence<ScanQrCodeState>
get() = sequenceOf(
aScanQrCodeState(),
aScanQrCodeState(scanAction = AsyncAction.Loading),
aScanQrCodeState(scanAction = AsyncAction.Success(Unit)),
aScanQrCodeState(scanAction = AsyncAction.Failure(Exception("Scan failed"))),
)
}
fun aScanQrCodeState(
scanAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (ScanQrCodeEvent) -> Unit = {},
) = ScanQrCodeState(
scanAction = scanAction,
eventSink = eventSink
)

View File

@@ -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 = {},
)
}

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_link_new_device_desktop_scanning_title">"Scan the QR code"</string>
<string name="screen_link_new_device_desktop_step1">"Open %1$s on a laptop or desktop computer"</string>
<string name="screen_link_new_device_desktop_step3">"Scan the QR code with this device"</string>
<string name="screen_link_new_device_desktop_submit">"Ready to scan"</string>
<string name="screen_link_new_device_desktop_title">"Open %1$s on a desktop computer to get the QR code"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"The numbers dont match"</string>
<string name="screen_link_new_device_enter_number_notice">"Enter 2-digit code"</string>
<string name="screen_link_new_device_enter_number_subtitle">"This will verify that the connection to your other device is secure."</string>
<string name="screen_link_new_device_enter_number_title">"Enter the number shown on your other device"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"Your account provider does not support %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s not supported"</string>
<string name="screen_link_new_device_error_not_supported_subtitle">"Your account provider doesnt support signing into a new device with a QR code."</string>
<string name="screen_link_new_device_error_not_supported_title">"QR code not supported"</string>
<string name="screen_link_new_device_error_request_cancelled_subtitle">"The sign in was cancelled on the other device."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Sign in request cancelled"</string>
<string name="screen_link_new_device_error_request_timeout_subtitle">"Sign in expired. Please try again."</string>
<string name="screen_link_new_device_error_request_timeout_title">"The sign in was not completed in time"</string>
<string name="screen_link_new_device_mobile_step1">"Open %1$s on the other device"</string>
<string name="screen_link_new_device_mobile_step2">"Select %1$s"</string>
<string name="screen_link_new_device_mobile_step2_action">"“Sign in with QR code”"</string>
<string name="screen_link_new_device_mobile_step3">"Scan the QR code shown here with the other device"</string>
<string name="screen_link_new_device_mobile_title">"Open %1$s on the other device"</string>
<string name="screen_link_new_device_root_desktop_computer">"Desktop computer"</string>
<string name="screen_link_new_device_root_loading_qr_code">"Loading QR code…"</string>
<string name="screen_link_new_device_root_mobile_device">"Mobile device"</string>
<string name="screen_link_new_device_root_title">"What type of device do you want to link?"</string>
<string name="screen_link_new_device_wrong_number_subtitle">"Please try again and make sure that youve entered the 2-digit code correctly. If the numbers still dont match then contact your account provider."</string>
<string name="screen_link_new_device_wrong_number_title">"The numbers dont match"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"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."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"What now?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Try signing in again with a QR code in case this was a network problem"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"If that doesnt work, sign in manually"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Connection not secure"</string>
<string name="screen_qr_code_login_error_cancelled_subtitle">"The sign in was cancelled on the other device."</string>
<string name="screen_qr_code_login_error_cancelled_title">"Sign in request cancelled"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"The sign in was declined on the other device."</string>
<string name="screen_qr_code_login_error_declined_title">"Sign in declined"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"You dont need to do anything else."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Your other device is already signed in"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Sign in expired. Please try again."</string>
<string name="screen_qr_code_login_error_expired_title">"The sign in was not completed in time"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"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."</string>
<string name="screen_qr_code_login_error_linking_not_suported_title">"QR code not supported"</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"Your account provider does not support %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s not supported"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Use the QR code shown on the other device."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Try again"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Wrong QR code"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"You need to give permission for %1$s to use your devices camera in order to continue."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Allow camera access to scan the QR code"</string>
<string name="screen_qr_code_login_unknown_error_description">"An unexpected error occurred. Please try again."</string>
</resources>

View File

@@ -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)
}
}

View File

@@ -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,
)

View File

@@ -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<ComponentActivity>()
@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<DesktopNoticeEvent>()
rule.setView(
state = aDesktopNoticeState(eventSink = eventRecorder),
)
rule.clickOn(R.string.screen_link_new_device_desktop_submit)
eventRecorder.assertSingle(DesktopNoticeEvent.Continue)
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setView(
state: DesktopNoticeState,
onBackClicked: () -> Unit = EnsureNeverCalled(),
onReadyToScanClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
DesktopNoticeView(
state = state,
onBackClick = onBackClicked,
onReadyToScanClick = onReadyToScanClick,
)
}
}
}

View File

@@ -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<ComponentActivity>()
@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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setErrorView(
onRetry: () -> Unit,
errorScreenType: ErrorScreenType = ErrorScreenType.UnknownError,
) {
setContent {
ErrorView(
errorScreenType = errorScreenType,
onRetry = onRetry,
)
}
}
}

View File

@@ -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<UByte, Boolean> { false }
val checkCodeSender = FakeCheckCodeSender(
validateResult = validateResult,
)
val matrixClient = FakeMatrixClient(
sessionCoroutineScope = backgroundScope,
createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
)
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
linkNewMobileHandler.createAndStartNewHandler()
val navigateToWrongNumberErrorLambda = lambdaRecorder<Unit> { }
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<UByte, Boolean> { true }
val sendResult = lambdaRecorder<UByte, Result<Unit>> { 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<UByte, Boolean> { true }
val sendResult = lambdaRecorder<UByte, Result<Unit>> { 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,
)
}

View File

@@ -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'),
)
}
}

View File

@@ -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<ComponentActivity>()
@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<EnterNumberEvent>()
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<EnterNumberEvent>(expectEvents = false)
rule.setView(
state = aEnterNumberState(
number = "1",
eventSink = eventRecorder,
),
)
val continueStr = rule.activity.getString(CommonStrings.action_continue)
rule.onNodeWithText(continueStr).assertIsNotEnabled()
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setView(
state: EnterNumberState,
onBackClicked: () -> Unit = EnsureNeverCalled(),
) {
setContent {
EnterNumberView(
state = state,
onBackClick = onBackClicked,
)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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<ComponentActivity>()
@Test
fun `on back pressed - calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setView(
onBackClick = callback
)
rule.pressBackKey()
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setView(
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
ShowQrCodeView(
data = "DATA",
onBackClick = onBackClick,
)
}
}
}

View File

@@ -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,
)
}

View File

@@ -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<ComponentActivity>()
@Test
fun `on back pressed - calls the onRetry callback`() {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(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<LinkNewDeviceRootEvent>(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<LinkNewDeviceRootEvent>()
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<LinkNewDeviceRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(false),
eventSink = eventRecorder,
),
onBackClick = callback,
)
rule.clickOn(CommonStrings.action_dismiss)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setLinkNewDeviceRootView(
state: LinkNewDeviceRootState = aLinkNewDeviceRootState(),
onBackClick: () -> Unit = EnsureNeverCalled(),
onLinkDesktopDeviceClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
LinkNewDeviceRootView(
state = state,
onBackClick = onBackClick,
onLinkDesktopDeviceClick = onLinkDesktopDeviceClick,
)
}
}
}

View File

@@ -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<ByteArray, Unit> { }
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<ByteArray, Unit> { }
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),
)

View File

@@ -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<ComponentActivity>()
@Test
fun `on back pressed - calls the expected callback`() {
val eventRecorder = EventsRecorder<ScanQrCodeEvent>(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<ScanQrCodeEvent>()
rule.setView(
state = aScanQrCodeState(
scanAction = AsyncAction.Failure(AN_EXCEPTION),
eventSink = eventRecorder,
)
)
rule.clickOn(CommonStrings.action_try_again)
eventRecorder.assertSingle(ScanQrCodeEvent.TryAgain)
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setView(
state: ScanQrCodeState = aScanQrCodeState(),
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
ScanQrCodeView(
state = state,
onBackClick = onBackClick,
)
}
}
}

View File

@@ -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)
}

View File

@@ -60,6 +60,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Sign in request cancelled"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"The sign in was declined on the other device."</string>
<string name="screen_qr_code_login_error_declined_title">"Sign in declined"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"You dont need to do anything else."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Your other device is already signed in"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Sign in expired. Please try again."</string>
<string name="screen_qr_code_login_error_expired_title">"The sign in was not completed in time"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Your other device does not support signing in to %s with a QR code.

View File

@@ -41,6 +41,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun navigateToAddAccount()
fun navigateToLinkNewDevice()
fun navigateToBugReport()
fun navigateToSecureBackup()
fun navigateToRoomNotificationSettings(roomId: RoomId)

View File

@@ -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))
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -31,6 +31,7 @@ fun aPreferencesRootState(
accountManagementUrl = "aUrl",
devicesManagementUrl = "anOtherUrl",
showAnalyticsSettings = true,
showLinkNewDevice = true,
canReportBug = true,
showDeveloperSettings = true,
showBlockedUsersItem = true,

View File

@@ -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 = {},

View File

@@ -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()

View File

@@ -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 <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem()

View File

@@ -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"

View File

@@ -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
}
}
}

View File

@@ -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 = {},
)

View File

@@ -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)
}
}
}

View File

@@ -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,
),
}

View File

@@ -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<Unit>
/**
* Check if linking a new device using QrCode is supported by the server.
*/
suspend fun canLinkNewDevice(): Result<Boolean>
/**
* Create a handler to link a new mobile device, i.e. a device capable of scanning QrCodes.
*/
fun createLinkMobileHandler(): Result<LinkMobileHandler>
/**
* Create a handler to link a new desktop device, i.e. a device not capable of scanning QrCodes.
*/
fun createLinkDesktopHandler(): Result<LinkDesktopHandler>
suspend fun performDatabaseVacuum(): Result<Unit>
}

View File

@@ -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<Unit>
}

View File

@@ -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)
}

View File

@@ -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<LinkDesktopStep>
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
}

View File

@@ -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<LinkMobileStep>
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
}

View File

@@ -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")
}

View File

@@ -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<Boolean> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.isLoginWithQrCodeSupported()
}
}
override fun createLinkMobileHandler(): Result<LinkMobileHandler> {
return runCatchingExceptions {
val handler = innerClient.newGrantLoginWithQrCodeHandler()
RustLinkMobileHandler(
inner = handler,
sessionCoroutineScope = sessionCoroutineScope,
sessionDispatcher = sessionDispatcher,
)
}
}
override fun createLinkDesktopHandler(): Result<LinkDesktopHandler> {
return runCatchingExceptions {
val handler = innerClient.newGrantLoginWithQrCodeHandler()
RustLinkDesktopHandler(
inner = handler,
sessionCoroutineScope = sessionCoroutineScope,
sessionDispatcher = sessionDispatcher,
qrCodeDataParser = RustQrCodeDataParser(),
)
}
}
override suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
val room = innerClient.getRoom(roomId.value) ?: error("Could not fetch associated room")

View File

@@ -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())
}

View File

@@ -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)
}
}

View File

@@ -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<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
inner.send(code)
}
}
}

View File

@@ -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>(LinkDesktopStep.Uninitialized)
override val linkDesktopStep: StateFlow<LinkDesktopStep> = _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,
)
}
}

View File

@@ -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>(LinkMobileStep.Uninitialized)
override val linkMobileStep: Flow<LinkMobileStep> = _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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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<UByte, Unit> { }
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()
}
}

View File

@@ -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(),
)
}

View File

@@ -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),
)
}

View File

@@ -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<Unit> = { _, _ -> lambdaError() },
private val currentSlidingSyncVersionLambda: () -> Result<SlidingSyncVersion> = { lambdaError() },
private val ignoreUserResult: (UserId) -> Result<Unit> = { lambdaError() },
private val canLinkNewDeviceResult: () -> Result<Boolean> = { lambdaError() },
private val createLinkMobileHandlerResult: () -> Result<LinkMobileHandler> = { lambdaError() },
private val createLinkDesktopHandlerResult: () -> Result<LinkDesktopHandler> = { lambdaError() },
private var unIgnoreUserResult: (UserId) -> Result<Unit> = { Result.success(Unit) },
private val canReportRoomLambda: () -> Boolean = { false },
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
@@ -362,4 +367,16 @@ class FakeMatrixClient(
override suspend fun performDatabaseVacuum(): Result<Unit> {
return performDatabaseVacuumLambda()
}
override suspend fun canLinkNewDevice(): Result<Boolean> = simulateLongTask {
return canLinkNewDeviceResult()
}
override fun createLinkDesktopHandler(): Result<LinkDesktopHandler> {
return createLinkDesktopHandlerResult()
}
override fun createLinkMobileHandler(): Result<LinkMobileHandler> {
return createLinkMobileHandlerResult()
}
}

View File

@@ -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()

View File

@@ -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<Unit> = { lambdaError() },
) : CheckCodeSender {
override suspend fun validate(code: UByte): Boolean = simulateLongTask {
validateResult(code)
}
override suspend fun send(code: UByte): Result<Unit> = simulateLongTask {
sendResult(code)
}
}

View File

@@ -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<LinkDesktopStep> = MutableStateFlow(LinkDesktopStep.Uninitialized)
override val linkDesktopStep: StateFlow<LinkDesktopStep>
get() = mutableLinkDesktopStep.asStateFlow()
override suspend fun handleScannedQrCode(data: ByteArray) {
handleScannedQrCodeResult(data)
}
suspend fun emitStep(step: LinkDesktopStep) {
mutableLinkDesktopStep.emit(step)
}
}

View File

@@ -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<LinkMobileStep> = MutableStateFlow(LinkMobileStep.Uninitialized)
override val linkMobileStep: StateFlow<LinkMobileStep>
get() = mutableLinkMobileStep.asStateFlow()
override suspend fun start() = simulateLongTask {
startResult()
}
suspend fun emitStep(step: LinkMobileStep) {
mutableLinkMobileStep.emit(step)
}
}

View File

@@ -19,4 +19,5 @@ dependencies {
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.camera2)
implementation(libs.zxing.cpp)
implementation(libs.google.zxing)
}

Some files were not shown because too many files have changed in this diff Show More