Link new device using QrCode.
This commit is contained in:
parent
5ebb615751
commit
a073117d62
94 changed files with 4431 additions and 36 deletions
|
|
@ -48,6 +48,7 @@ dependencies {
|
||||||
|
|
||||||
implementation(projects.features.announcement.api)
|
implementation(projects.features.announcement.api)
|
||||||
implementation(projects.features.ftue.api)
|
implementation(projects.features.ftue.api)
|
||||||
|
implementation(projects.features.linknewdevice.api)
|
||||||
implementation(projects.features.share.api)
|
implementation(projects.features.share.api)
|
||||||
|
|
||||||
implementation(projects.services.apperror.impl)
|
implementation(projects.services.apperror.impl)
|
||||||
|
|
|
||||||
|
|
@ -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.FtueService
|
||||||
import io.element.android.features.ftue.api.state.FtueState
|
import io.element.android.features.ftue.api.state.FtueState
|
||||||
import io.element.android.features.home.api.HomeEntryPoint
|
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.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
|
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
|
||||||
|
|
@ -123,6 +124,7 @@ class LoggedInFlowNode(
|
||||||
private val secureBackupEntryPoint: SecureBackupEntryPoint,
|
private val secureBackupEntryPoint: SecureBackupEntryPoint,
|
||||||
private val userProfileEntryPoint: UserProfileEntryPoint,
|
private val userProfileEntryPoint: UserProfileEntryPoint,
|
||||||
private val ftueEntryPoint: FtueEntryPoint,
|
private val ftueEntryPoint: FtueEntryPoint,
|
||||||
|
private val linkNewDeviceEntryPoint: LinkNewDeviceEntryPoint,
|
||||||
@SessionCoroutineScope
|
@SessionCoroutineScope
|
||||||
private val sessionCoroutineScope: CoroutineScope,
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val ftueService: FtueService,
|
private val ftueService: FtueService,
|
||||||
|
|
@ -293,6 +295,9 @@ class LoggedInFlowNode(
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object Ftue : NavTarget
|
data object Ftue : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data object LinkNewDevice : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object RoomDirectory : NavTarget
|
data object RoomDirectory : NavTarget
|
||||||
|
|
||||||
|
|
@ -419,6 +424,10 @@ class LoggedInFlowNode(
|
||||||
callback.navigateToAddAccount()
|
callback.navigateToAddAccount()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToLinkNewDevice() {
|
||||||
|
backstack.push(NavTarget.LinkNewDevice)
|
||||||
|
}
|
||||||
|
|
||||||
override fun navigateToBugReport() {
|
override fun navigateToBugReport() {
|
||||||
callback.navigateToBugReport()
|
callback.navigateToBugReport()
|
||||||
}
|
}
|
||||||
|
|
@ -475,6 +484,14 @@ class LoggedInFlowNode(
|
||||||
NavTarget.Ftue -> {
|
NavTarget.Ftue -> {
|
||||||
ftueEntryPoint.createNode(this, buildContext)
|
ftueEntryPoint.createNode(this, buildContext)
|
||||||
}
|
}
|
||||||
|
NavTarget.LinkNewDevice -> {
|
||||||
|
val callback = object : LinkNewDeviceEntryPoint.Callback {
|
||||||
|
override fun onDone() {
|
||||||
|
backstack.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
linkNewDeviceEntryPoint.createNode(this, buildContext, callback)
|
||||||
|
}
|
||||||
NavTarget.RoomDirectory -> {
|
NavTarget.RoomDirectory -> {
|
||||||
roomDirectoryEntryPoint.createNode(
|
roomDirectoryEntryPoint.createNode(
|
||||||
parentNode = this,
|
parentNode = this,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.ftue.impl.R
|
import io.element.android.features.ftue.impl.R
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
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.ButtonColumnMolecule
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||||
|
|
@ -111,13 +112,7 @@ private fun ChooseSelfVerificationModeButtons(
|
||||||
AsyncData.Uninitialized,
|
AsyncData.Uninitialized,
|
||||||
is AsyncData.Failure,
|
is AsyncData.Failure,
|
||||||
is AsyncData.Loading -> {
|
is AsyncData.Loading -> {
|
||||||
Button(
|
LoadingButtonAtom()
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
enabled = false,
|
|
||||||
showProgress = true,
|
|
||||||
text = stringResource(CommonStrings.common_loading),
|
|
||||||
onClick = {},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
is AsyncData.Success -> {
|
is AsyncData.Success -> {
|
||||||
if (state.buttonsState.data.canUseAnotherDevice) {
|
if (state.buttonsState.data.canUseAnotherDevice) {
|
||||||
|
|
|
||||||
17
features/linknewdevice/api/build.gradle.kts
Normal file
17
features/linknewdevice/api/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
63
features/linknewdevice/impl/build.gradle.kts
Normal file
63
features/linknewdevice/impl/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
17
features/linknewdevice/impl/src/main/AndroidManifest.xml
Normal file
17
features/linknewdevice/impl/src/main/AndroidManifest.xml
Normal 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>
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,284 @@
|
||||||
|
/*
|
||||||
|
* 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 job1: Job? = null
|
||||||
|
var job2: Job? = null
|
||||||
|
|
||||||
|
lifecycle.subscribe(
|
||||||
|
onCreate = {
|
||||||
|
linkNewMobileHandler.reset()
|
||||||
|
linkNewDesktopHandler.reset()
|
||||||
|
@Suppress("AssignedValueIsNeverRead")
|
||||||
|
job1 = observeLinkNewMobileHandler()
|
||||||
|
@Suppress("AssignedValueIsNeverRead")
|
||||||
|
job2 = observeLinkNewDesktopHandler()
|
||||||
|
},
|
||||||
|
onDestroy = {
|
||||||
|
job1?.cancel()
|
||||||
|
job2?.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() {
|
||||||
|
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() {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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 = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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 = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
is 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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 = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
55
features/linknewdevice/impl/src/main/res/values/localazy.xml
Normal file
55
features/linknewdevice/impl/src/main/res/values/localazy.xml
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?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 don’t 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 doesn’t 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 you’ve entered the 2-digit code correctly. If the numbers still don’t match then contact your account provider."</string>
|
||||||
|
<string name="screen_link_new_device_wrong_number_title">"The numbers don’t 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 doesn’t 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_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 device’s 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>
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
/*
|
||||||
|
* 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 {
|
||||||
|
return DesktopNoticePresenter(
|
||||||
|
permissionsPresenterFactory = permissionsPresenterFactory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.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.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPresenter(
|
||||||
|
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||||
|
linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(matrixClient),
|
||||||
|
) = LinkNewDeviceRootPresenter(
|
||||||
|
matrixClient = matrixClient,
|
||||||
|
linkNewMobileHandler = linkNewMobileHandler,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(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 {
|
||||||
|
return ScanQrCodePresenter(
|
||||||
|
linkNewDesktopHandler = LinkNewDesktopHandler(matrixClient),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
features/linknewdevice/test/build.gradle.kts
Normal file
19
features/linknewdevice/test/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun navigateToAddAccount()
|
fun navigateToAddAccount()
|
||||||
|
fun navigateToLinkNewDevice()
|
||||||
fun navigateToBugReport()
|
fun navigateToBugReport()
|
||||||
fun navigateToSecureBackup()
|
fun navigateToSecureBackup()
|
||||||
fun navigateToRoomNotificationSettings(roomId: RoomId)
|
fun navigateToRoomNotificationSettings(roomId: RoomId)
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,10 @@ class PreferencesFlowNode(
|
||||||
backstack.push(NavTarget.Labs)
|
backstack.push(NavTarget.Labs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToLinkNewDevice() {
|
||||||
|
callback.navigateToLinkNewDevice()
|
||||||
|
}
|
||||||
|
|
||||||
override fun navigateToUserProfile(matrixUser: MatrixUser) {
|
override fun navigateToUserProfile(matrixUser: MatrixUser) {
|
||||||
backstack.push(NavTarget.UserProfile(matrixUser))
|
backstack.push(NavTarget.UserProfile(matrixUser))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ class PreferencesRootNode(
|
||||||
fun navigateToLockScreenSettings()
|
fun navigateToLockScreenSettings()
|
||||||
fun navigateToAdvancedSettings()
|
fun navigateToAdvancedSettings()
|
||||||
fun navigateToLabs()
|
fun navigateToLabs()
|
||||||
|
fun navigateToLinkNewDevice()
|
||||||
fun navigateToUserProfile(matrixUser: MatrixUser)
|
fun navigateToUserProfile(matrixUser: MatrixUser)
|
||||||
fun navigateToBlockedUsers()
|
fun navigateToBlockedUsers()
|
||||||
fun startSignOutFlow()
|
fun startSignOutFlow()
|
||||||
|
|
@ -84,6 +85,7 @@ class PreferencesRootNode(
|
||||||
onOpenDeveloperSettings = callback::navigateToDeveloperSettings,
|
onOpenDeveloperSettings = callback::navigateToDeveloperSettings,
|
||||||
onOpenAdvancedSettings = callback::navigateToAdvancedSettings,
|
onOpenAdvancedSettings = callback::navigateToAdvancedSettings,
|
||||||
onOpenLabs = callback::navigateToLabs,
|
onOpenLabs = callback::navigateToLabs,
|
||||||
|
onLinkNewDeviceClick = callback::navigateToLinkNewDevice,
|
||||||
onManageAccountClick = { onManageAccountClick(activity, it, isDark) },
|
onManageAccountClick = { onManageAccountClick(activity, it, isDark) },
|
||||||
onOpenNotificationSettings = callback::navigateToNotificationSettings,
|
onOpenNotificationSettings = callback::navigateToNotificationSettings,
|
||||||
onOpenLockScreenSettings = callback::navigateToLockScreenSettings,
|
onOpenLockScreenSettings = callback::navigateToLockScreenSettings,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ class PreferencesRootPresenter(
|
||||||
val isMultiAccountEnabled by remember {
|
val isMultiAccountEnabled by remember {
|
||||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
|
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
|
||||||
}.collectAsState(initial = false)
|
}.collectAsState(initial = false)
|
||||||
|
val showLinkNewDevice by remember {
|
||||||
|
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
|
||||||
|
}.collectAsState(initial = false)
|
||||||
|
|
||||||
val otherSessions by remember {
|
val otherSessions by remember {
|
||||||
sessionStore.sessionsFlow().map { list ->
|
sessionStore.sessionsFlow().map { list ->
|
||||||
|
|
@ -146,6 +149,7 @@ class PreferencesRootPresenter(
|
||||||
devicesManagementUrl = devicesManagementUrl.value,
|
devicesManagementUrl = devicesManagementUrl.value,
|
||||||
showAnalyticsSettings = hasAnalyticsProviders,
|
showAnalyticsSettings = hasAnalyticsProviders,
|
||||||
canReportBug = canReportBug,
|
canReportBug = canReportBug,
|
||||||
|
showLinkNewDevice = showLinkNewDevice,
|
||||||
showDeveloperSettings = showDeveloperSettings,
|
showDeveloperSettings = showDeveloperSettings,
|
||||||
canDeactivateAccount = canDeactivateAccount,
|
canDeactivateAccount = canDeactivateAccount,
|
||||||
showBlockedUsersItem = showBlockedUsersItem,
|
showBlockedUsersItem = showBlockedUsersItem,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ data class PreferencesRootState(
|
||||||
val accountManagementUrl: String?,
|
val accountManagementUrl: String?,
|
||||||
val devicesManagementUrl: String?,
|
val devicesManagementUrl: String?,
|
||||||
val canReportBug: Boolean,
|
val canReportBug: Boolean,
|
||||||
|
val showLinkNewDevice: Boolean,
|
||||||
val showAnalyticsSettings: Boolean,
|
val showAnalyticsSettings: Boolean,
|
||||||
val showDeveloperSettings: Boolean,
|
val showDeveloperSettings: Boolean,
|
||||||
val canDeactivateAccount: Boolean,
|
val canDeactivateAccount: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ fun aPreferencesRootState(
|
||||||
accountManagementUrl = "aUrl",
|
accountManagementUrl = "aUrl",
|
||||||
devicesManagementUrl = "anOtherUrl",
|
devicesManagementUrl = "anOtherUrl",
|
||||||
showAnalyticsSettings = true,
|
showAnalyticsSettings = true,
|
||||||
|
showLinkNewDevice = true,
|
||||||
canReportBug = true,
|
canReportBug = true,
|
||||||
showDeveloperSettings = true,
|
showDeveloperSettings = true,
|
||||||
showBlockedUsersItem = true,
|
showBlockedUsersItem = true,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ fun PreferencesRootView(
|
||||||
onAddAccountClick: () -> Unit,
|
onAddAccountClick: () -> Unit,
|
||||||
onSecureBackupClick: () -> Unit,
|
onSecureBackupClick: () -> Unit,
|
||||||
onManageAccountClick: (url: String) -> Unit,
|
onManageAccountClick: (url: String) -> Unit,
|
||||||
|
onLinkNewDeviceClick: () -> Unit,
|
||||||
onOpenAnalytics: () -> Unit,
|
onOpenAnalytics: () -> Unit,
|
||||||
onOpenRageShake: () -> Unit,
|
onOpenRageShake: () -> Unit,
|
||||||
onOpenLockScreenSettings: () -> Unit,
|
onOpenLockScreenSettings: () -> Unit,
|
||||||
|
|
@ -101,6 +102,7 @@ fun PreferencesRootView(
|
||||||
ManageAccountSection(
|
ManageAccountSection(
|
||||||
state = state,
|
state = state,
|
||||||
onManageAccountClick = onManageAccountClick,
|
onManageAccountClick = onManageAccountClick,
|
||||||
|
onLinkNewDeviceClick = onLinkNewDeviceClick,
|
||||||
onOpenBlockedUsers = onOpenBlockedUsers
|
onOpenBlockedUsers = onOpenBlockedUsers
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -193,8 +195,16 @@ private fun ColumnScope.ManageAppSection(
|
||||||
private fun ColumnScope.ManageAccountSection(
|
private fun ColumnScope.ManageAccountSection(
|
||||||
state: PreferencesRootState,
|
state: PreferencesRootState,
|
||||||
onManageAccountClick: (url: String) -> Unit,
|
onManageAccountClick: (url: String) -> Unit,
|
||||||
|
onLinkNewDeviceClick: () -> Unit,
|
||||||
onOpenBlockedUsers: () -> 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 ->
|
state.accountManagementUrl?.let { url ->
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) },
|
headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) },
|
||||||
|
|
@ -353,6 +363,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
|
||||||
onOpenAbout = {},
|
onOpenAbout = {},
|
||||||
onSecureBackupClick = {},
|
onSecureBackupClick = {},
|
||||||
onManageAccountClick = {},
|
onManageAccountClick = {},
|
||||||
|
onLinkNewDeviceClick = {},
|
||||||
onOpenNotificationSettings = {},
|
onOpenNotificationSettings = {},
|
||||||
onOpenLockScreenSettings = {},
|
onOpenLockScreenSettings = {},
|
||||||
onOpenUserProfile = {},
|
onOpenUserProfile = {},
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ class DefaultPreferencesEntryPointTest {
|
||||||
}
|
}
|
||||||
val callback = object : PreferencesEntryPoint.Callback {
|
val callback = object : PreferencesEntryPoint.Callback {
|
||||||
override fun navigateToAddAccount() = lambdaError()
|
override fun navigateToAddAccount() = lambdaError()
|
||||||
|
override fun navigateToLinkNewDevice() = lambdaError()
|
||||||
override fun navigateToBugReport() = lambdaError()
|
override fun navigateToBugReport() = lambdaError()
|
||||||
override fun navigateToSecureBackup() = lambdaError()
|
override fun navigateToSecureBackup() = lambdaError()
|
||||||
override fun navigateToRoomNotificationSettings(roomId: RoomId) = lambdaError()
|
override fun navigateToRoomNotificationSettings(roomId: RoomId) = lambdaError()
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ class PreferencesRootPresenterTest {
|
||||||
assertThat(loadedState.accountManagementUrl).isNull()
|
assertThat(loadedState.accountManagementUrl).isNull()
|
||||||
assertThat(loadedState.devicesManagementUrl).isNull()
|
assertThat(loadedState.devicesManagementUrl).isNull()
|
||||||
assertThat(loadedState.showAnalyticsSettings).isFalse()
|
assertThat(loadedState.showAnalyticsSettings).isFalse()
|
||||||
|
assertThat(loadedState.showLinkNewDevice).isFalse()
|
||||||
assertThat(loadedState.showDeveloperSettings).isTrue()
|
assertThat(loadedState.showDeveloperSettings).isTrue()
|
||||||
assertThat(loadedState.canDeactivateAccount).isTrue()
|
assertThat(loadedState.canDeactivateAccount).isTrue()
|
||||||
assertThat(loadedState.canReportBug).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 {
|
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
return awaitItem()
|
return awaitItem()
|
||||||
|
|
|
||||||
|
|
@ -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"
|
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
|
||||||
opusencoder = "io.element.android:opusencoder:1.2.0"
|
opusencoder = "io.element.android:opusencoder:1.2.0"
|
||||||
zxing_cpp = "io.github.zxing-cpp:android:2.3.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 = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
||||||
haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
|
haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
|
||||||
color_picker = "io.mhssn:colorpicker:1.0.0"
|
color_picker = "io.mhssn:colorpicker:1.0.0"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = {},
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -125,4 +125,11 @@ enum class FeatureFlags(
|
||||||
defaultValue = { true },
|
defaultValue = { true },
|
||||||
isFinished = false,
|
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,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
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.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.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||||
|
|
@ -195,6 +197,21 @@ interface MatrixClient {
|
||||||
*/
|
*/
|
||||||
suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result<Unit>
|
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>
|
suspend fun performDatabaseVacuum(): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
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.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.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||||
|
|
@ -45,6 +47,8 @@ import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
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.encryption.RustEncryptionService
|
||||||
import io.element.android.libraries.matrix.impl.exception.mapClientException
|
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.mapper.map
|
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.RustMediaLoader
|
||||||
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
|
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
|
||||||
|
|
@ -726,6 +730,34 @@ 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result<Unit> = withContext(sessionDispatcher) {
|
override suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result<Unit> = withContext(sessionDispatcher) {
|
||||||
runCatchingExceptions {
|
runCatchingExceptions {
|
||||||
val room = innerClient.getRoom(roomId.value) ?: error("Could not fetch associated room")
|
val room = innerClient.getRoom(roomId.value) ?: error("Could not fetch associated room")
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.QrCodeData
|
||||||
|
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,
|
||||||
|
) : 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 = QrCodeData.fromBytes(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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
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.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.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||||
|
|
@ -95,6 +97,9 @@ class FakeMatrixClient(
|
||||||
private val deactivateAccountResult: (String, Boolean) -> Result<Unit> = { _, _ -> lambdaError() },
|
private val deactivateAccountResult: (String, Boolean) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||||
private val currentSlidingSyncVersionLambda: () -> Result<SlidingSyncVersion> = { lambdaError() },
|
private val currentSlidingSyncVersionLambda: () -> Result<SlidingSyncVersion> = { lambdaError() },
|
||||||
private val ignoreUserResult: (UserId) -> Result<Unit> = { 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 var unIgnoreUserResult: (UserId) -> Result<Unit> = { Result.success(Unit) },
|
||||||
private val canReportRoomLambda: () -> Boolean = { false },
|
private val canReportRoomLambda: () -> Boolean = { false },
|
||||||
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
|
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
|
||||||
|
|
@ -356,4 +361,16 @@ class FakeMatrixClient(
|
||||||
override suspend fun performDatabaseVacuum(): Result<Unit> {
|
override suspend fun performDatabaseVacuum(): Result<Unit> {
|
||||||
return performDatabaseVacuumLambda()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,3 +100,31 @@ const val A_LOGIN_HINT = "mxid:@alice:example.org"
|
||||||
|
|
||||||
@ColorInt
|
@ColorInt
|
||||||
const val A_COLOR_INT: Int = 0xFFFF0000.toInt()
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,4 +19,5 @@ dependencies {
|
||||||
implementation(libs.androidx.camera.view)
|
implementation(libs.androidx.camera.view)
|
||||||
implementation(libs.androidx.camera.camera2)
|
implementation(libs.androidx.camera.camera2)
|
||||||
implementation(libs.zxing.cpp)
|
implementation(libs.zxing.cpp)
|
||||||
|
implementation(libs.google.zxing)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
|
@ -117,7 +118,13 @@ fun QrCodeCameraView(
|
||||||
.background(color = ElementTheme.colors.bgSubtlePrimary),
|
.background(color = ElementTheme.colors.bgSubtlePrimary),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text("CameraView")
|
Text(
|
||||||
|
text = buildString {
|
||||||
|
append("CameraView\n")
|
||||||
|
append(if (isScanning) "scanning" else "frozen")
|
||||||
|
},
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.qrcode
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.common.BitMatrix
|
||||||
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
import io.element.android.libraries.designsystem.modifiers.squareSize
|
||||||
|
import io.element.android.libraries.designsystem.utils.ForceMaxBrightness
|
||||||
|
|
||||||
|
private fun String.toBitMatrix(size: Int): BitMatrix {
|
||||||
|
return QRCodeWriter().encode(
|
||||||
|
this,
|
||||||
|
BarcodeFormat.QR_CODE,
|
||||||
|
size,
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BitMatrix.toBitmap(
|
||||||
|
@ColorInt backgroundColor: Int = Color.WHITE,
|
||||||
|
@ColorInt foregroundColor: Int = Color.BLACK,
|
||||||
|
): Bitmap {
|
||||||
|
val colorBuffer = IntArray(width * height)
|
||||||
|
var rowOffset = 0
|
||||||
|
for (y in 0 until height) {
|
||||||
|
for (x in 0 until width) {
|
||||||
|
val arrayIndex = x + rowOffset
|
||||||
|
colorBuffer[arrayIndex] = if (get(x, y)) foregroundColor else backgroundColor
|
||||||
|
}
|
||||||
|
rowOffset += width
|
||||||
|
}
|
||||||
|
return Bitmap.createBitmap(colorBuffer, width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QrCodeImage(
|
||||||
|
data: String,
|
||||||
|
forceMaxBrightness: Boolean = true,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (forceMaxBrightness) {
|
||||||
|
ForceMaxBrightness()
|
||||||
|
}
|
||||||
|
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.squareSize()
|
||||||
|
.onSizeChanged {
|
||||||
|
size = it
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val image = remember(data, size) {
|
||||||
|
val sideSide = maxOf(size.width, size.height).coerceAtLeast(128)
|
||||||
|
data.toBitMatrix(sideSide).toBitmap().asImageBitmap()
|
||||||
|
}
|
||||||
|
Image(
|
||||||
|
contentDescription = null,
|
||||||
|
bitmap = image,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Preview
|
||||||
|
internal fun QrCodeViewPreview() {
|
||||||
|
QrCodeImage(
|
||||||
|
modifier = Modifier.fillMaxHeight(),
|
||||||
|
data = "RANDOM_QRCODE_DATA",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -434,32 +434,6 @@ Are you sure you want to continue?"</string>
|
||||||
<string name="screen_create_poll_options_section_title">"Options"</string>
|
<string name="screen_create_poll_options_section_title">"Options"</string>
|
||||||
<string name="screen_create_poll_remove_accessibility_label">"Remove %1$s"</string>
|
<string name="screen_create_poll_remove_accessibility_label">"Remove %1$s"</string>
|
||||||
<string name="screen_create_poll_settings_section_title">"Settings"</string>
|
<string name="screen_create_poll_settings_section_title">"Settings"</string>
|
||||||
<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 don’t 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 doesn’t 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_manage_authorized_spaces_header">"Spaces where members can join the room without an invitation."</string>
|
<string name="screen_manage_authorized_spaces_header">"Spaces where members can join the room without an invitation."</string>
|
||||||
<string name="screen_manage_authorized_spaces_title">"Manage spaces"</string>
|
<string name="screen_manage_authorized_spaces_title">"Manage spaces"</string>
|
||||||
<string name="screen_manage_authorized_spaces_unknown_space">"(Unknown space)"</string>
|
<string name="screen_manage_authorized_spaces_unknown_space">"(Unknown space)"</string>
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,17 @@
|
||||||
"troubleshoot_notifications_test_unified_push_.*"
|
"troubleshoot_notifications_test_unified_push_.*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name" : ":features:linknewdevice:impl",
|
||||||
|
"includeRegex" : [
|
||||||
|
"screen\\.link_new_device\\..*",
|
||||||
|
"screen_qr_code_login_error_.*",
|
||||||
|
"screen_qr_code_login_connection_note_secure_state.*",
|
||||||
|
"screen_qr_code_login_unknown_error_description",
|
||||||
|
"screen_qr_code_login_invalid_scan_state_.*",
|
||||||
|
"screen_qr_code_login_no_camera_permission_state_.*"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name" : ":features:login:impl",
|
"name" : ":features:login:impl",
|
||||||
"includeRegex" : [
|
"includeRegex" : [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue