From fd446e98dd04b417039a5519d5ea0f4d0eb75f92 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 4 Dec 2025 10:52:26 +0100 Subject: [PATCH] Link new device using QrCode. --- appnav/build.gradle.kts | 1 + .../android/appnav/LoggedInFlowNode.kt | 17 ++ .../ChooseSelfVerificationModeView.kt | 13 +- features/linknewdevice/api/build.gradle.kts | 17 ++ .../api/LinkNewDeviceEntryPoint.kt | 25 ++ features/linknewdevice/impl/build.gradle.kts | 63 ++++ .../impl/src/main/AndroidManifest.xml | 17 ++ .../impl/DefaultLinkNewDeviceEntryPoint.kt | 31 ++ .../impl/LinkNewDesktopHandler.kt | 74 +++++ .../impl/LinkNewDeviceFlowNode.kt | 284 ++++++++++++++++++ .../impl/LinkNewMobileHandler.kt | 68 +++++ .../screens/desktop/DesktopNoticeEvent.kt | 12 + .../impl/screens/desktop/DesktopNoticeNode.kt | 45 +++ .../screens/desktop/DesktopNoticePresenter.kt | 57 ++++ .../screens/desktop/DesktopNoticeState.kt | 16 + .../desktop/DesktopNoticeStateProvider.kt | 35 +++ .../impl/screens/desktop/DesktopNoticeView.kt | 112 +++++++ .../impl/screens/error/ErrorNode.kt | 43 +++ .../impl/screens/error/ErrorScreenType.kt | 40 +++ .../screens/error/ErrorScreenTypeProvider.kt | 23 ++ .../impl/screens/error/ErrorView.kt | 138 +++++++++ .../impl/screens/number/Config.kt | 12 + .../impl/screens/number/EnterNumberEvent.kt | 13 + .../impl/screens/number/EnterNumberNode.kt | 54 ++++ .../screens/number/EnterNumberPresenter.kt | 108 +++++++ .../impl/screens/number/EnterNumberState.kt | 21 ++ .../number/EnterNumberStateProvider.kt | 34 +++ .../impl/screens/number/EnterNumberView.kt | 125 ++++++++ .../number/component/NumberTextField.kt | 169 +++++++++++ .../impl/screens/number/model/Digit.kt | 23 ++ .../impl/screens/number/model/Number.kt | 56 ++++ .../impl/screens/qrcode/ShowQrCodeNode.kt | 48 +++ .../impl/screens/qrcode/ShowQrCodeView.kt | 89 ++++++ .../screens/root/LinkNewDeviceRootEvent.kt | 13 + .../screens/root/LinkNewDeviceRootNode.kt | 45 +++ .../root/LinkNewDeviceRootPresenter.kt | 85 ++++++ .../screens/root/LinkNewDeviceRootState.kt | 16 + .../root/LinkNewDeviceRootStateProvider.kt | 40 +++ .../screens/root/LinkNewDeviceRootView.kt | 152 ++++++++++ .../impl/screens/scan/ScanQrCodeEvent.kt | 13 + .../impl/screens/scan/ScanQrCodeNode.kt | 43 +++ .../impl/screens/scan/ScanQrCodePresenter.kt | 73 +++++ .../impl/screens/scan/ScanQrCodeState.kt | 15 + .../screens/scan/ScanQrCodeStateProvider.kt | 29 ++ .../impl/screens/scan/ScanQrCodeView.kt | 174 +++++++++++ .../impl/src/main/res/values/localazy.xml | 55 ++++ .../desktop/DesktopNoticePresenterTest.kt | 58 ++++ .../screens/desktop/DesktopNoticeViewTest.kt | 86 ++++++ .../impl/screens/error/ErrorViewTest.kt | 59 ++++ .../number/EnterNumberPresenterTest.kt | 191 ++++++++++++ .../screens/number/EnterNumberStateTest.kt | 94 ++++++ .../screens/number/EnterNumberViewTest.kt | 92 ++++++ .../number/FakeEnterNumberNavigator.kt | 18 ++ .../impl/screens/qrcode/ShowQrCodeViewTest.kt | 47 +++ .../root/LinkNewDeviceRootPresenterTest.kt | 74 +++++ .../screens/root/LinkNewDeviceRootViewTest.kt | 102 +++++++ .../screens/scan/ScanQrCodePresenterTest.kt | 112 +++++++ .../impl/screens/scan/ScanQrCodeViewTest.kt | 70 +++++ features/linknewdevice/test/build.gradle.kts | 19 ++ .../preferences/api/PreferencesEntryPoint.kt | 1 + .../preferences/impl/PreferencesFlowNode.kt | 4 + .../impl/root/PreferencesRootNode.kt | 2 + .../impl/root/PreferencesRootPresenter.kt | 4 + .../impl/root/PreferencesRootState.kt | 1 + .../impl/root/PreferencesRootStateProvider.kt | 1 + .../impl/root/PreferencesRootView.kt | 11 + .../impl/DefaultPreferencesEntryPointTest.kt | 1 + .../impl/root/PreferencesRootPresenterTest.kt | 17 ++ gradle/libs.versions.toml | 3 + .../androidutils/system/Brightness.kt | 27 ++ .../atomic/atoms/LoadingButtonAtom.kt | 26 ++ .../designsystem/utils/ForceMaxBrightness.kt | 24 ++ .../libraries/featureflag/api/FeatureFlags.kt | 7 + .../libraries/matrix/api/MatrixClient.kt | 17 ++ .../api/linknewdevice/CheckCodeSender.kt | 22 ++ .../matrix/api/linknewdevice/ErrorType.kt | 45 +++ .../api/linknewdevice/LinkDesktopHandler.kt | 41 +++ .../api/linknewdevice/LinkMobileHandler.kt | 26 ++ .../libraries/matrix/api/logs/LoggerTags.kt | 14 + .../libraries/matrix/impl/RustMatrixClient.kt | 32 ++ .../HumanQrGrantLoginExceptionExtension.kt | 21 ++ .../impl/linknewdevice/RustCheckCodeSender.kt | 33 ++ .../linknewdevice/RustLinkDesktopHandler.kt | 82 +++++ .../linknewdevice/RustLinkMobileHandler.kt | 75 +++++ .../libraries/matrix/test/FakeMatrixClient.kt | 17 ++ .../android/libraries/matrix/test/TestData.kt | 28 ++ .../test/linknewdevice/FakeCheckCodeSender.kt | 25 ++ .../linknewdevice/FakeLinkDesktopHandler.kt | 31 ++ .../linknewdevice/FakeLinkMobileHandler.kt | 32 ++ libraries/qrcode/build.gradle.kts | 1 + .../libraries/qrcode/QrCodeCameraView.kt | 9 +- .../android/libraries/qrcode/QrCodeImage.kt | 92 ++++++ .../src/main/res/values/localazy.xml | 26 -- tools/localazy/config.json | 11 + 94 files changed, 4431 insertions(+), 36 deletions(-) create mode 100644 features/linknewdevice/api/build.gradle.kts create mode 100644 features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt create mode 100644 features/linknewdevice/impl/build.gradle.kts create mode 100644 features/linknewdevice/impl/src/main/AndroidManifest.xml create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt create mode 100644 features/linknewdevice/impl/src/main/res/values/localazy.xml create mode 100644 features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt create mode 100644 features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt create mode 100644 features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt create mode 100644 features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt create mode 100644 features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt create mode 100644 features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt create mode 100644 features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt create mode 100644 features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt create mode 100644 features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt create mode 100644 features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt create mode 100644 features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt create mode 100644 features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt create mode 100644 features/linknewdevice/test/build.gradle.kts create mode 100644 libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/Brightness.kt create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/LoadingButtonAtom.kt create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceMaxBrightness.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/CheckCodeSender.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ErrorType.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/logs/LoggerTags.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/HumanQrGrantLoginExceptionExtension.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSender.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeCheckCodeSender.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkDesktopHandler.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkMobileHandler.kt create mode 100644 libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeImage.kt diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index aa0bc04772..84d614b3d9 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(projects.features.announcement.api) implementation(projects.features.ftue.api) + implementation(projects.features.linknewdevice.api) implementation(projects.features.share.api) implementation(projects.services.apperror.impl) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 38dc39ee83..92c3cfe3dd 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -53,6 +53,7 @@ import io.element.android.features.ftue.api.FtueEntryPoint import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.home.api.HomeEntryPoint +import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer @@ -123,6 +124,7 @@ class LoggedInFlowNode( private val secureBackupEntryPoint: SecureBackupEntryPoint, private val userProfileEntryPoint: UserProfileEntryPoint, private val ftueEntryPoint: FtueEntryPoint, + private val linkNewDeviceEntryPoint: LinkNewDeviceEntryPoint, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val ftueService: FtueService, @@ -293,6 +295,9 @@ class LoggedInFlowNode( @Parcelize data object Ftue : NavTarget + @Parcelize + data object LinkNewDevice : NavTarget + @Parcelize data object RoomDirectory : NavTarget @@ -419,6 +424,10 @@ class LoggedInFlowNode( callback.navigateToAddAccount() } + override fun navigateToLinkNewDevice() { + backstack.push(NavTarget.LinkNewDevice) + } + override fun navigateToBugReport() { callback.navigateToBugReport() } @@ -475,6 +484,14 @@ class LoggedInFlowNode( NavTarget.Ftue -> { ftueEntryPoint.createNode(this, buildContext) } + NavTarget.LinkNewDevice -> { + val callback = object : LinkNewDeviceEntryPoint.Callback { + override fun onDone() { + backstack.pop() + } + } + linkNewDeviceEntryPoint.createNode(this, buildContext, callback) + } NavTarget.RoomDirectory -> { roomDirectoryEntryPoint.createNode( parentNode = this, diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt index e4e922f933..bd0cdead71 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt @@ -25,6 +25,7 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.ftue.impl.R import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.atomic.atoms.LoadingButtonAtom import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage @@ -88,8 +89,8 @@ fun ChooseSelfVerificationModeView( ) { Text( modifier = Modifier - .clickable(onClick = onLearnMore) - .padding(vertical = 4.dp, horizontal = 16.dp), + .clickable(onClick = onLearnMore) + .padding(vertical = 4.dp, horizontal = 16.dp), text = stringResource(CommonStrings.action_learn_more), style = ElementTheme.typography.fontBodyLgMedium ) @@ -111,13 +112,7 @@ private fun ChooseSelfVerificationModeButtons( AsyncData.Uninitialized, is AsyncData.Failure, is AsyncData.Loading -> { - Button( - modifier = Modifier.fillMaxWidth(), - enabled = false, - showProgress = true, - text = stringResource(CommonStrings.common_loading), - onClick = {}, - ) + LoadingButtonAtom() } is AsyncData.Success -> { if (state.buttonsState.data.canUseAnotherDevice) { diff --git a/features/linknewdevice/api/build.gradle.kts b/features/linknewdevice/api/build.gradle.kts new file mode 100644 index 0000000000..7d368f0a63 --- /dev/null +++ b/features/linknewdevice/api/build.gradle.kts @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.linknewdevice.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt b/features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt new file mode 100644 index 0000000000..061bbeb084 --- /dev/null +++ b/features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint + +interface LinkNewDeviceEntryPoint : FeatureEntryPoint { + interface Callback : Plugin { + fun onDone() + } + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node +} diff --git a/features/linknewdevice/impl/build.gradle.kts b/features/linknewdevice/impl/build.gradle.kts new file mode 100644 index 0000000000..9c1aa9e990 --- /dev/null +++ b/features/linknewdevice/impl/build.gradle.kts @@ -0,0 +1,63 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "io.element.android.features.linknewdevice.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + // TODO Cleanup + implementation(projects.appconfig) + implementation(projects.features.enterprise.api) + implementation(projects.features.rageshake.api) + implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.testtags) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.sessionStorage.api) + implementation(projects.libraries.qrcode) + implementation(projects.libraries.oidc.api) + implementation(projects.libraries.uiUtils) + implementation(projects.libraries.wellknown.api) + implementation(libs.androidx.browser) + implementation(libs.androidx.webkit) + implementation(libs.serialization.json) + api(projects.features.linknewdevice.api) + + testCommonDependencies(libs, true) + testImplementation(projects.features.linknewdevice.test) + testImplementation(projects.features.enterprise.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.oidc.test) + testImplementation(projects.libraries.permissions.test) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.libraries.wellknown.test) +} diff --git a/features/linknewdevice/impl/src/main/AndroidManifest.xml b/features/linknewdevice/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..d225716fc4 --- /dev/null +++ b/features/linknewdevice/impl/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt new file mode 100644 index 0000000000..5edc3cdfcd --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +class DefaultLinkNewDeviceEntryPoint : LinkNewDeviceEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: LinkNewDeviceEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf( + callback, + ) + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt new file mode 100644 index 0000000000..a8a8ff14b6 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl + +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep +import io.element.android.libraries.matrix.api.logs.LoggerTags +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber + +private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice) + +@Inject +@SingleIn(SessionScope::class) +class LinkNewDesktopHandler( + private val matrixClient: MatrixClient, +) { + private val sessionScope = matrixClient.sessionCoroutineScope + private val linkDesktopStepFlow = MutableStateFlow( + LinkDesktopStep.Uninitialized + ) + + val stepFlow: StateFlow + get() = linkDesktopStepFlow.asStateFlow() + + private var currentJob: Job? = null + private var handler: LinkDesktopHandler? = null + + fun createNewHandler() { + currentJob?.cancel() + currentJob = null + handler = matrixClient.createLinkDesktopHandler().getOrNull() + } + + fun reset() { + currentJob?.cancel() + currentJob = null + sessionScope.launch { + linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized) + } + } + + fun onScannedCode(data: ByteArray) { + currentJob?.cancel() + currentJob = null + val currentHandler = handler + if (currentHandler == null) { + Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.") + } else { + currentJob = matrixClient.sessionCoroutineScope.launch { + currentHandler.linkDesktopStep.onEach { + linkDesktopStepFlow.emit(it) + }.launchIn(this) + currentHandler.handleScannedQrCode(data) + } + } + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt new file mode 100644 index 0000000000..3294476c44 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt @@ -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, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + private val linkNewMobileHandler: LinkNewMobileHandler, + private val linkNewDesktopHandler: LinkNewDesktopHandler, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + private val callback: LinkNewDeviceEntryPoint.Callback = callback() + private var activity: Activity? = null + private var darkTheme: Boolean = false + + override fun onBuilt() { + super.onBuilt() + var 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(buildContext, listOf(callback)) + } + NavTarget.DesktopNotice -> { + val callback = object : DesktopNoticeNode.Callback { + override fun navigateBack() { + backstack.pop() + } + + override fun navigateToQrCodeScanner() { + backstack.push(NavTarget.DesktopScanQrCode) + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.DesktopScanQrCode -> { + val callback = object : ScanQrCodeNode.Callback { + override fun cancel() { + backstack.pop() + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.MobileEnterNumber -> { + val callback = object : EnterNumberNode.Callback { + override fun navigateToWrongNumberError() { + backstack.push(NavTarget.Error(ErrorScreenType.Mismatch2Digits)) + } + + override fun navigateBack() { + backstack.pop() + } + } + createNode(buildContext, listOf(callback)) + } + is NavTarget.MobileShowQrCode -> { + val callback = object : ShowQrCodeNode.Callback { + override fun navigateBack() { + backstack.pop() + } + } + val inputs = ShowQrCodeNode.Inputs( + data = navTarget.data, + ) + createNode(buildContext, listOf(inputs, callback)) + } + is NavTarget.Error -> { + val callback = object : ErrorNode.Callback { + override fun onRetry() { + backstack.newRoot(NavTarget.Root) + } + } + createNode(buildContext, listOf(callback, navTarget.errorScreenType)) + } + } + } + + private fun navigateToBrowser(url: String) { + activity?.openUrlInChromeCustomTab(null, darkTheme, url) + } + + @Composable + override fun View(modifier: Modifier) { + activity = requireNotNull(LocalActivity.current) + darkTheme = !ElementTheme.isLightTheme + DisposableEffect(Unit) { + onDispose { + activity = null + } + } + BackstackView() + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt new file mode 100644 index 0000000000..157d946eaa --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl + +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep +import io.element.android.libraries.matrix.api.logs.LoggerTags +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber + +private val loggerTag = LoggerTag("LinkNewMobileHandler", LoggerTags.linkNewDevice) + +@Inject +@SingleIn(SessionScope::class) +class LinkNewMobileHandler( + private val matrixClient: MatrixClient, +) { + private val sessionScope = matrixClient.sessionCoroutineScope + private var currentJob: Job? = null + private var handler: LinkMobileHandler? = null + + private val linkMobileStepFlow = MutableStateFlow( + LinkMobileStep.Uninitialized + ) + + val stepFlow: StateFlow + get() = linkMobileStepFlow.asStateFlow() + + fun createAndStartNewHandler() { + Timber.tag(loggerTag.value).d("createAndStartNewHandler()") + currentJob?.cancel() + handler = matrixClient.createLinkMobileHandler().getOrNull() + handler?.let { h -> + currentJob = sessionScope.launch { + h.linkMobileStep + .onEach { + linkMobileStepFlow.emit(it) + } + .launchIn(this) + h.start() + } + } + } + + fun reset() { + currentJob?.cancel() + currentJob = null + sessionScope.launch { + linkMobileStepFlow.emit(LinkMobileStep.Uninitialized) + } + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt new file mode 100644 index 0000000000..3d94da3fc6 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.desktop + +sealed interface DesktopNoticeEvent { + data object Continue : DesktopNoticeEvent +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt new file mode 100644 index 0000000000..895d02731a --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.desktop + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class DesktopNoticeNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: DesktopNoticePresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun navigateBack() + fun navigateToQrCodeScanner() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + DesktopNoticeView( + state = state, + modifier = modifier, + onBackClick = callback::navigateBack, + onReadyToScanClick = callback::navigateToQrCodeScanner, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt new file mode 100644 index 0000000000..3b01725fe1 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.desktop + +import android.Manifest +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.permissions.api.PermissionsEvent +import io.element.android.libraries.permissions.api.PermissionsPresenter + +@Inject +class DesktopNoticePresenter( + permissionsPresenterFactory: PermissionsPresenter.Factory, +) : Presenter { + private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) + private var pendingPermissionRequest by mutableStateOf(false) + + @Composable + override fun present(): DesktopNoticeState { + val cameraPermissionState = cameraPermissionPresenter.present() + var canContinue by remember { mutableStateOf(false) } + LaunchedEffect(cameraPermissionState.permissionGranted) { + if (cameraPermissionState.permissionGranted && pendingPermissionRequest) { + pendingPermissionRequest = false + canContinue = true + } + } + + fun handleEvent(event: DesktopNoticeEvent) { + when (event) { + DesktopNoticeEvent.Continue -> if (cameraPermissionState.permissionGranted) { + canContinue = true + } else { + pendingPermissionRequest = true + cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions) + } + } + } + + return DesktopNoticeState( + cameraPermissionState = cameraPermissionState, + canContinue = canContinue, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt new file mode 100644 index 0000000000..81991b5ab2 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.desktop + +import io.element.android.libraries.permissions.api.PermissionsState + +data class DesktopNoticeState( + val cameraPermissionState: PermissionsState, + val canContinue: Boolean, + val eventSink: (DesktopNoticeEvent) -> Unit, +) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt new file mode 100644 index 0000000000..194bd6fafc --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.desktop + +import android.Manifest +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.permissions.api.PermissionsState +import io.element.android.libraries.permissions.api.aPermissionsState + +open class DesktopNoticeStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aDesktopNoticeState(), + aDesktopNoticeState(cameraPermissionState = aPermissionsState(showDialog = true, permission = Manifest.permission.CAMERA)), + ) +} + +fun aDesktopNoticeState( + cameraPermissionState: PermissionsState = aPermissionsState( + showDialog = false, + permission = Manifest.permission.CAMERA, + ), + canContinue: Boolean = false, + eventSink: (DesktopNoticeEvent) -> Unit = {}, +) = DesktopNoticeState( + cameraPermissionState = cameraPermissionState, + canContinue = canContinue, + eventSink = eventSink +) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt new file mode 100644 index 0000000000..c7f0bb61e7 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.features.linknewdevice.impl.screens.desktop + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.linknewdevice.impl.R +import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.LocalBuildMeta +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.utils.annotatedTextWithBold +import io.element.android.libraries.permissions.api.PermissionsView +import kotlinx.collections.immutable.persistentListOf + +/** + * Desktop notice screen: + * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23618 + */ +@Composable +fun DesktopNoticeView( + state: DesktopNoticeState, + onBackClick: () -> Unit, + onReadyToScanClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val latestOnReadyToScanClick by rememberUpdatedState(onReadyToScanClick) + LaunchedEffect(state.canContinue) { + if (state.canContinue) { + latestOnReadyToScanClick() + } + } + + val appName = LocalBuildMeta.current.applicationName + FlowStepPage( + onBackClick = onBackClick, + title = stringResource(R.string.screen_link_new_device_desktop_title, appName), + iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()), + modifier = modifier, + buttons = { + Button( + text = stringResource(R.string.screen_link_new_device_desktop_submit), + onClick = { state.eventSink(DesktopNoticeEvent.Continue) }, + modifier = Modifier.fillMaxWidth(), + ) + } + ) { + Column( + Modifier.fillMaxWidth() + ) { + Spacer(modifier = Modifier.height(40.dp)) + NumberedListOrganism( + modifier = Modifier.fillMaxSize(), + items = persistentListOf( + AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step1, appName)), + annotatedTextWithBold( + text = stringResource( + id = R.string.screen_link_new_device_mobile_step2, + stringResource(R.string.screen_link_new_device_mobile_step2_action), + ), + boldText = stringResource(R.string.screen_link_new_device_mobile_step2_action) + ), + AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step3)), + ) + ) + } + } + + PermissionsView( + title = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_title), + content = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_description, appName), + icon = { Icon(imageVector = CompoundIcons.TakePhotoSolid(), contentDescription = null) }, + state = state.cameraPermissionState, + ) +} + +@PreviewsDayNight +@Composable +internal fun DesktopNoticeViewPreview( + @PreviewParameter(DesktopNoticeStateProvider::class) state: DesktopNoticeState, +) = ElementPreview { + DesktopNoticeView( + state = state, + onBackClick = { }, + onReadyToScanClick = { }, + ) +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt new file mode 100644 index 0000000000..70fd3b49a4 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.error + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class ErrorNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : Node(buildContext = buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onRetry() + } + + private val callback: Callback = callback() + private val errorScreenType = inputs() + + @Composable + override fun View(modifier: Modifier) { + ErrorView( + modifier = modifier, + errorScreenType = errorScreenType, + onRetry = callback::onRetry, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt new file mode 100644 index 0000000000..b92a19ef8a --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.error + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import io.element.android.libraries.architecture.NodeInputs +import kotlinx.parcelize.Parcelize + +@Immutable +sealed interface ErrorScreenType : NodeInputs, Parcelable { + @Parcelize + data object Cancelled : ErrorScreenType + + @Parcelize + data object Expired : ErrorScreenType + + @Parcelize + data object Mismatch2Digits : ErrorScreenType + + @Parcelize + data object InsecureChannelDetected : ErrorScreenType + + @Parcelize + data object Declined : ErrorScreenType + + @Parcelize + data object ProtocolNotSupported : ErrorScreenType + + @Parcelize + data object SlidingSyncNotAvailable : ErrorScreenType + + @Parcelize + data object UnknownError : ErrorScreenType +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt new file mode 100644 index 0000000000..7fd699101b --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.error + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class ErrorScreenTypeProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + ErrorScreenType.Cancelled, + ErrorScreenType.Declined, + ErrorScreenType.Expired, + ErrorScreenType.ProtocolNotSupported, + ErrorScreenType.Mismatch2Digits, + ErrorScreenType.InsecureChannelDetected, + ErrorScreenType.SlidingSyncNotAvailable, + ErrorScreenType.UnknownError, + ) +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt new file mode 100644 index 0000000000..3a77f19f49 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.error + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.linknewdevice.impl.R +import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.LocalBuildMeta +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun ErrorView( + errorScreenType: ErrorScreenType, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + val appName = LocalBuildMeta.current.applicationName + BackHandler(onBack = onRetry) + FlowStepPage( + modifier = modifier, + iconStyle = BigIcon.Style.AlertSolid, + title = titleText(errorScreenType, appName), + subTitle = subtitleText(errorScreenType, appName), + content = { Content(errorScreenType) }, + buttons = { Buttons(onRetry) }, + ) +} + +@Composable +private fun titleText(errorScreenType: ErrorScreenType, appName: String) = when (errorScreenType) { + ErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_title) + ErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_title) + ErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_title) + ErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_title) + ErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_title) + ErrorScreenType.Mismatch2Digits -> stringResource(id = R.string.screen_link_new_device_wrong_number_title) + ErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_title, appName) + is ErrorScreenType.UnknownError -> stringResource(CommonStrings.common_something_went_wrong) +} + +@Composable +private fun subtitleText(errorScreenType: ErrorScreenType, appName: String) = when (errorScreenType) { + ErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_subtitle) + ErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_subtitle) + ErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_subtitle) + ErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_subtitle, appName) + ErrorScreenType.Mismatch2Digits -> stringResource(id = R.string.screen_link_new_device_wrong_number_subtitle) + ErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_description) + ErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_subtitle, appName) + is ErrorScreenType.UnknownError -> stringResource(R.string.screen_qr_code_login_unknown_error_description) +} + +@Composable +private fun ColumnScope.InsecureChannelDetectedError() { + Text( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + text = stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_header), + style = ElementTheme.typography.fontBodyLgMedium, + textAlign = TextAlign.Center, + ) + NumberedListOrganism( + modifier = Modifier.fillMaxSize(), + items = persistentListOf( + AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_1)), + AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_2)), + AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_3)), + ) + ) +} + +@Composable +private fun Content(errorScreenType: ErrorScreenType) { + when (errorScreenType) { + ErrorScreenType.InsecureChannelDetected -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + InsecureChannelDetectedError() + } + } + else -> Unit + } +} + +@Composable +private fun Buttons(onRetry: () -> Unit) { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_start_over), + onClick = onRetry + ) +} + +@PreviewsDayNight +@Composable +internal fun ErrorViewPreview(@PreviewParameter(ErrorScreenTypeProvider::class) errorScreenType: ErrorScreenType) { + ElementPreview { + ErrorView( + errorScreenType = errorScreenType, + onRetry = {}, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt new file mode 100644 index 0000000000..b61cc82a22 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.number + +object Config { + const val VERIFICATION_CODE_LENGTH = 2 +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt new file mode 100644 index 0000000000..267b508669 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.number + +sealed interface EnterNumberEvent { + data class UpdateNumber(val number: String) : EnterNumberEvent + data object Continue : EnterNumberEvent +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt new file mode 100644 index 0000000000..08d8cd02bd --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.number + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +interface EnterNumberNavigator { + fun navigateToWrongNumberError() +} + +@ContributesNode(SessionScope::class) +@AssistedInject +class EnterNumberNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: EnterNumberPresenter.Factory, +) : Node(buildContext, plugins = plugins), EnterNumberNavigator { + private val presenter = presenterFactory.create(this) + + interface Callback : Plugin { + fun navigateToWrongNumberError() + fun navigateBack() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + EnterNumberView( + state = state, + modifier = modifier, + onBackClick = callback::navigateBack, + ) + } + + override fun navigateToWrongNumberError() { + callback.navigateToWrongNumberError() + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt new file mode 100644 index 0000000000..74b5b6b294 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.number + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.linknewdevice.CheckCodeSender +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep +import io.element.android.libraries.matrix.api.logs.LoggerTags +import kotlinx.coroutines.launch +import timber.log.Timber + +private val tag = LoggerTag("EnterNumberPresenter", LoggerTags.linkNewDevice) + +@AssistedInject +class EnterNumberPresenter( + @Assisted private val navigator: EnterNumberNavigator, + private val linkNewMobileHandler: LinkNewMobileHandler, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(navigator: EnterNumberNavigator): EnterNumberPresenter + } + + @Composable + override fun present(): EnterNumberState { + val coroutineScope = rememberCoroutineScope() + var number by remember { mutableStateOf("") } + var sendingCode by remember>> { mutableStateOf(AsyncAction.Uninitialized) } + + // Observe the flow to react on ErrorType.InvalidCheckCode + val linkMobileStep by linkNewMobileHandler.stepFlow.collectAsState() + + var checkCodeSender: CheckCodeSender? by remember { mutableStateOf(null) } + + LaunchedEffect(linkMobileStep) { + when (val step = linkMobileStep) { + is LinkMobileStep.QrScanned -> { + checkCodeSender = step.checkCodeSender + } + else -> Unit + } + } + + fun handleEvent(event: EnterNumberEvent) { + when (event) { + is EnterNumberEvent.UpdateNumber -> { + sendingCode = AsyncAction.Uninitialized + // Keep only digits as a safety measure + number = event.number.filter { it.isDigit() } + } + EnterNumberEvent.Continue -> coroutineScope.launch { + // Get the current code sender + val sender = checkCodeSender + if (sender == null) { + Timber.tag(tag.value).e("No check code sender available") + sendingCode = AsyncAction.Failure(IllegalStateException("No check code sender available")) + } else { + sendingCode = AsyncAction.Loading + val uByte = number.toUByte() + val isValid = sender.validate(uByte) + if (isValid) { + sender.send(uByte) + .fold( + onSuccess = { + Timber.tag(tag.value).d("Code sent successfully") + // Keep loading, do not set sendingCode to AsyncAction.Success(Unit) + }, + onFailure = { + Timber.tag(tag.value).e(it, "Failed to send number code") + sendingCode = AsyncAction.Failure(it) + } + ) + } else { + // Navigate to the error state + navigator.navigateToWrongNumberError() + } + } + } + } + } + + return EnterNumberState( + number = number, + sendingCode = sendingCode, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt new file mode 100644 index 0000000000..b8f66018dd --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.number + +import io.element.android.features.linknewdevice.impl.screens.number.model.Number +import io.element.android.libraries.architecture.AsyncAction + +data class EnterNumberState( + val number: String, + val sendingCode: AsyncAction, + val eventSink: (EnterNumberEvent) -> Unit, +) { + val numberEntry = Number.createEmpty(Config.VERIFICATION_CODE_LENGTH).fillWith(number) + val isContinueButtonEnabled: Boolean + get() = numberEntry.isComplete() && !sendingCode.isLoading() +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt new file mode 100644 index 0000000000..126bfeee52 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.number + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.linknewdevice.ErrorType + +open class EnterNumberStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aEnterNumberState(), + aEnterNumberState(number = "1"), + aEnterNumberState(number = "12"), + aEnterNumberState(number = "12", sendingCode = AsyncAction.Loading), + aEnterNumberState(number = "12", sendingCode = AsyncAction.Failure(ErrorType.InvalidCheckCode("Invalid"))), + aEnterNumberState(number = "12", sendingCode = AsyncAction.Failure(Exception("Failed to send code"))), + ) +} + +fun aEnterNumberState( + number: String = "", + sendingCode: AsyncAction = AsyncAction.Uninitialized, + eventSink: (EnterNumberEvent) -> Unit = {}, +) = EnterNumberState( + number = number, + sendingCode = sendingCode, + eventSink = eventSink, +) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt new file mode 100644 index 0000000000..92b3447615 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.features.linknewdevice.impl.screens.number + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.linknewdevice.impl.R +import io.element.android.features.linknewdevice.impl.screens.number.component.NumberTextField +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.linknewdevice.ErrorType +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * Form to enter number: + * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2076-81604 + */ +@Composable +fun EnterNumberView( + state: EnterNumberState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + FlowStepPage( + onBackClick = onBackClick, + title = stringResource(R.string.screen_link_new_device_enter_number_title), + subTitle = stringResource(R.string.screen_link_new_device_enter_number_subtitle), + iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()), + modifier = modifier, + buttons = { + Button( + text = stringResource(CommonStrings.action_continue), + onClick = { state.eventSink(EnterNumberEvent.Continue) }, + enabled = state.isContinueButtonEnabled, + showProgress = state.sendingCode.isLoading(), + modifier = Modifier.fillMaxWidth(), + ) + } + ) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.screen_link_new_device_enter_number_notice), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(8.dp)) + NumberTextField( + number = state.numberEntry, + onValueChange = { state.eventSink(EnterNumberEvent.UpdateNumber(it)) }, + onDone = { + if (state.isContinueButtonEnabled) { + state.eventSink(EnterNumberEvent.Continue) + } + }, + ) + val failure = state.sendingCode.errorOrNull() + if (failure != null) { + Spacer(modifier = Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + modifier = Modifier.size(14.dp), + imageVector = CompoundIcons.ErrorSolid(), + contentDescription = null, + tint = ElementTheme.colors.iconCriticalPrimary, + ) + val errorMessage = when (failure) { + is ErrorType.InvalidCheckCode -> stringResource(R.string.screen_link_new_device_enter_number_error_numbers_do_not_match) + else -> failure.message ?: stringResource(CommonStrings.error_unknown) + } + Text( + text = errorMessage, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textCriticalPrimary, + ) + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun EnterNumberViewPreview( + @PreviewParameter(EnterNumberStateProvider::class) state: EnterNumberState, +) = ElementPreview { + EnterNumberView( + state = state, + onBackClick = { }, + ) +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt new file mode 100644 index 0000000000..568a729ed6 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.number.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.linknewdevice.impl.screens.number.model.Digit +import io.element.android.features.linknewdevice.impl.screens.number.model.Number +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import kotlinx.coroutines.delay + +@Composable +fun NumberTextField( + number: Number, + onValueChange: (String) -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused = LocalInspectionMode.current || interactionSource.collectIsFocusedAsState().value + BasicTextField( + modifier = modifier, + value = number.toText(), + onValueChange = { + onValueChange(it) + }, + interactionSource = interactionSource, + maxLines = 1, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + onDone() + } + ), + decorationBox = { + NumberRow( + number = number, + hasFocus = isFocused, + ) + } + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun NumberRow( + number: Number, + hasFocus: Boolean, +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val length = number.length() + number.digits.forEachIndexed { index, digit -> + DigitView( + digit = digit, + isCurrent = index == length, + drawCursor = hasFocus, + ) + } + } +} + +@Composable +private fun DigitView( + digit: Digit, + isCurrent: Boolean, + drawCursor: Boolean, +) { + val shape = RoundedCornerShape(4.dp) + val appearanceModifier = when (digit) { + Digit.Empty -> { + val color = if (isCurrent) { + ElementTheme.colors.textPrimary + } else { + ElementTheme.colors.borderInteractiveSecondary + } + Modifier.border(1.dp, color, shape) + } + is Digit.Filled -> { + Modifier.background(ElementTheme.colors.bgActionSecondaryPressed, shape) + } + } + Box( + modifier = Modifier + .size(42.dp, 56.dp) + .then(appearanceModifier), + contentAlignment = Alignment.Center, + ) { + if (digit is Digit.Filled) { + Text( + text = digit.value.toString(), + style = ElementTheme.typography.fontHeadingLgBold, + color = ElementTheme.colors.textPrimary, + ) + } else if (drawCursor && isCurrent) { + // Draw a blinking cursor + BlinkingCursor() + } + } +} + +@Composable +private fun BlinkingCursor() { + var isCursorVisible by remember { mutableStateOf(true) } + LaunchedEffect(isCursorVisible) { + delay(500) + // Toggle cursor visibility + isCursorVisible = !isCursorVisible + } + if (isCursorVisible) { + Spacer( + modifier = Modifier + .size(2.dp, 24.dp) + .offset(x = (-5).dp) + .background(ElementTheme.colors.textPrimary, RoundedCornerShape(1.dp)) + ) + } +} + +@PreviewsDayNight +@Composable +internal fun NumberTextFieldPreview() { + ElementPreview { + val number = Number.createEmpty(4).fillWith("12") + NumberTextField( + number = number, + onValueChange = {}, + onDone = {}, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt new file mode 100644 index 0000000000..b8565ea6ca --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.number.model + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface Digit { + data object Empty : Digit + data class Filled(val value: Char) : Digit + + fun toText(): String { + return when (this) { + is Empty -> "" + is Filled -> value.toString() + } + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt new file mode 100644 index 0000000000..be60f27f76 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.number.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +data class Number( + val digits: ImmutableList, +) { + companion object { + fun createEmpty(size: Int): Number { + val digits = List(size) { Digit.Empty } + return Number( + digits = digits.toImmutableList() + ) + } + } + + val size = digits.size + + /** + * Fill the first digits with the given text. + * Can't be more than the size of the NumberEntry + * Keep the Empty digits at the end + * @return the new NumberEntry + */ + fun fillWith(text: String): Number { + val newDigits = MutableList(size) { Digit.Empty } + text.forEachIndexed { index, char -> + if (index < size && char.isDigit()) { + newDigits[index] = Digit.Filled(char) + } + } + return copy(digits = newDigits.toImmutableList()) + } + + fun length(): Int { + return digits.count { it is Digit.Filled } + } + + fun toText(): String { + return digits.joinToString("") { + it.toText() + } + } + + fun isComplete(): Boolean { + return digits.all { it is Digit.Filled } + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt new file mode 100644 index 0000000000..a884c3e97f --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.qrcode + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class ShowQrCodeNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : Node(buildContext, plugins = plugins) { + class Inputs( + val data: String, + ) : NodeInputs + + interface Callback : Plugin { + fun navigateBack() + } + + private val inputs: Inputs = inputs() + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + ShowQrCodeView( + data = inputs.data, + modifier = modifier, + onBackClick = callback::navigateBack, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt new file mode 100644 index 0000000000..501415f621 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.features.linknewdevice.impl.screens.qrcode + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.linknewdevice.impl.R +import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.LocalBuildMeta +import io.element.android.libraries.designsystem.utils.annotatedTextWithBold +import io.element.android.libraries.qrcode.QrCodeImage +import kotlinx.collections.immutable.persistentListOf + +/** + * QrCode display screen: + * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23617 + */ +@Composable +fun ShowQrCodeView( + data: String, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val appName = LocalBuildMeta.current.applicationName + FlowStepPage( + onBackClick = onBackClick, + title = stringResource(R.string.screen_link_new_device_mobile_title, appName), + iconStyle = BigIcon.Style.Default(CompoundIcons.TakePhotoSolid()), + modifier = modifier, + ) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + QrCodeImage( + data = data, + modifier = Modifier + .size(220.dp) + ) + Spacer(modifier = Modifier.height(32.dp)) + NumberedListOrganism( + modifier = Modifier.fillMaxSize(), + items = persistentListOf( + AnnotatedString(stringResource(R.string.screen_link_new_device_mobile_step1, appName)), + annotatedTextWithBold( + text = stringResource( + id = R.string.screen_link_new_device_mobile_step2, + stringResource(R.string.screen_link_new_device_mobile_step2_action), + ), + boldText = stringResource(R.string.screen_link_new_device_mobile_step2_action) + ), + AnnotatedString(stringResource(R.string.screen_link_new_device_mobile_step3)), + ) + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun ShowQrCodeViewPreview() = ElementPreview { + ShowQrCodeView( + data = "DATA", + onBackClick = { }, + ) +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt new file mode 100644 index 0000000000..8ce6af90b0 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.root + +sealed interface LinkNewDeviceRootEvent { + data object LinkMobileDevice : LinkNewDeviceRootEvent + data object CloseDialog : LinkNewDeviceRootEvent +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt new file mode 100644 index 0000000000..f43ffa64df --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.root + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class LinkNewDeviceRootNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: LinkNewDeviceRootPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onDone() + fun linkDesktopDevice() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + LinkNewDeviceRootView( + state = state, + modifier = modifier, + onBackClick = callback::onDone, + onLinkDesktopDeviceClick = callback::linkDesktopDevice, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt new file mode 100644 index 0000000000..e39179c463 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep +import kotlinx.coroutines.launch + +@Inject +class LinkNewDeviceRootPresenter( + private val matrixClient: MatrixClient, + private val linkNewMobileHandler: LinkNewMobileHandler, +) : Presenter { + @Composable + override fun present(): LinkNewDeviceRootState { + val coroutineScope = rememberCoroutineScope() + var isSupported by remember { mutableStateOf>(AsyncData.Uninitialized) } + var qrCodeData by remember { mutableStateOf>(AsyncData.Uninitialized) } + + LaunchedEffect(Unit) { + matrixClient.canLinkNewDevice().fold( + onSuccess = { supported -> + isSupported = AsyncData.Success(supported) + }, + onFailure = { + isSupported = AsyncData.Failure(it) + } + ) + } + + val step by linkNewMobileHandler.stepFlow.collectAsState() + + LaunchedEffect(step) { + when (val finalStep = step) { + is LinkMobileStep.Uninitialized -> { + qrCodeData = AsyncData.Uninitialized + } + is LinkMobileStep.QrReady -> { + qrCodeData = AsyncData.Success(Unit) + } + is LinkMobileStep.Error -> { + qrCodeData = AsyncData.Failure(finalStep.errorType) + } + else -> Unit + } + } + + fun handleEvent(event: LinkNewDeviceRootEvent) { + when (event) { + 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, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt new file mode 100644 index 0000000000..6cf6694b2a --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.root + +import io.element.android.libraries.architecture.AsyncData + +data class LinkNewDeviceRootState( + val isSupported: AsyncData, + val qrCodeData: AsyncData, + val eventSink: (LinkNewDeviceRootEvent) -> Unit, +) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt new file mode 100644 index 0000000000..f1bb7ad455 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.root + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.linknewdevice.ErrorType + +open class LinkNewDeviceRootStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLinkNewDeviceRootState(), + aLinkNewDeviceRootState(isSupported = AsyncData.Success(true)), + aLinkNewDeviceRootState(isSupported = AsyncData.Success(false)), + aLinkNewDeviceRootState(isSupported = AsyncData.Failure(Exception("Should not happen"))), + aLinkNewDeviceRootState( + isSupported = AsyncData.Success(true), + qrCodeData = AsyncData.Loading(), + ), + aLinkNewDeviceRootState( + isSupported = AsyncData.Success(true), + qrCodeData = AsyncData.Failure(ErrorType.NotFound("The rendezvous session was not found and might have expired")), + ), + ) +} + +fun aLinkNewDeviceRootState( + isSupported: AsyncData = AsyncData.Uninitialized, + qrCodeData: AsyncData = AsyncData.Uninitialized, + eventSink: (LinkNewDeviceRootEvent) -> Unit = { }, +) = LinkNewDeviceRootState( + isSupported = isSupported, + qrCodeData = qrCodeData, + eventSink = eventSink, +) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt new file mode 100644 index 0000000000..d9249d3e89 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.features.linknewdevice.impl.screens.root + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.linknewdevice.impl.R +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.atomic.atoms.LoadingButtonAtom +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * Device selection screen: + * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23616 + * Not supported screen: + * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2186-70004 + */ +@Composable +fun LinkNewDeviceRootView( + state: LinkNewDeviceRootState, + onBackClick: () -> Unit, + onLinkDesktopDeviceClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val (title, subtitle, iconStyle) = if (state.isSupported.dataOrNull() == false) { + Triple( + stringResource(R.string.screen_link_new_device_error_not_supported_title), + stringResource(R.string.screen_link_new_device_error_not_supported_subtitle), + BigIcon.Style.AlertSolid + ) + } else { + Triple( + stringResource(R.string.screen_link_new_device_root_title), + null, + BigIcon.Style.Default(CompoundIcons.Devices()) + ) + } + FlowStepPage( + onBackClick = onBackClick, + title = title, + subTitle = subtitle, + iconStyle = iconStyle, + buttons = { + when (state.isSupported) { + is AsyncData.Uninitialized, + is AsyncData.Loading -> { + LoadingButtonAtom() + } + is AsyncData.Failure -> { + Text( + text = stringResource(id = CommonStrings.error_unknown), + color = ElementTheme.colors.textCriticalPrimary, + style = ElementTheme.typography.fontBodyMdRegular, + textAlign = TextAlign.Center, + ) + Button( + onClick = onBackClick, + text = stringResource(CommonStrings.action_dismiss), + modifier = Modifier.fillMaxWidth(), + ) + } + is AsyncData.Success -> { + if (state.isSupported.data) { + when (state.qrCodeData) { + AsyncData.Uninitialized, + is AsyncData.Failure -> { + Button( + onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) }, + text = stringResource(id = R.string.screen_link_new_device_root_mobile_device), + modifier = Modifier.fillMaxWidth(), + leadingIcon = IconSource.Vector(CompoundIcons.Mobile()), + ) + Button( + onClick = onLinkDesktopDeviceClick, + text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer), + modifier = Modifier.fillMaxWidth(), + leadingIcon = IconSource.Vector(CompoundIcons.Computer()), + ) + } + is AsyncData.Loading, + is AsyncData.Success -> { + Button( + onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) }, + text = stringResource(id = R.string.screen_link_new_device_root_loading_qr_code), + showProgress = true, + enabled = false, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = onLinkDesktopDeviceClick, + text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer), + modifier = Modifier.fillMaxWidth(), + enabled = false, + leadingIcon = IconSource.Vector(CompoundIcons.Computer()), + ) + } + } + } else { + Button( + onClick = onBackClick, + text = stringResource(CommonStrings.action_dismiss), + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + }, + modifier = modifier, + ) + + val failure = state.qrCodeData.errorOrNull() + if (failure != null) { + ErrorDialog( + content = failure.message ?: stringResource(CommonStrings.error_unknown), + onSubmit = { state.eventSink(LinkNewDeviceRootEvent.CloseDialog) }, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun LinkNewDeviceRootViewPreview( + @PreviewParameter(LinkNewDeviceRootStateProvider::class) state: LinkNewDeviceRootState +) = ElementPreview { + LinkNewDeviceRootView( + state = state, + onBackClick = { }, + onLinkDesktopDeviceClick = { }, + ) +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt new file mode 100644 index 0000000000..c17a28649c --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.scan + +sealed interface ScanQrCodeEvent { + data class QrCodeScanned(val data: ByteArray) : ScanQrCodeEvent + data object TryAgain : ScanQrCodeEvent +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt new file mode 100644 index 0000000000..ff4f88f9ae --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.scan + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class ScanQrCodeNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: ScanQrCodePresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun cancel() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ScanQrCodeView( + state = state, + onBackClick = callback::cancel, + modifier = modifier + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt new file mode 100644 index 0000000000..5f0131a8df --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.scan + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.linknewdevice.impl.LinkNewDesktopHandler +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep +import kotlinx.coroutines.launch + +@Inject +class ScanQrCodePresenter( + private val linkNewDesktopHandler: LinkNewDesktopHandler, +) : Presenter { + @Composable + override fun present(): ScanQrCodeState { + val coroutineScope = rememberCoroutineScope() + var scanAction: AsyncAction by remember { mutableStateOf(AsyncAction.Loading) } + + // Observe the flow to react on LinkDesktopStep.InvalidQrCode + val linkDesktopStep by linkNewDesktopHandler.stepFlow.collectAsState() + + LaunchedEffect(Unit) { + linkNewDesktopHandler.createNewHandler() + } + + LaunchedEffect(linkDesktopStep) { + when (val step = linkDesktopStep) { + is LinkDesktopStep.InvalidQrCode -> { + scanAction = AsyncAction.Failure(Exception(step.error)) + } + else -> Unit + } + } + + fun handleEvent(event: ScanQrCodeEvent) { + when (event) { + ScanQrCodeEvent.TryAgain -> { + scanAction = AsyncAction.Loading + } + is ScanQrCodeEvent.QrCodeScanned -> coroutineScope.launch { + // In this case the scanning will stop and a loader will be shown + scanAction = AsyncAction.Success(Unit) + try { + linkNewDesktopHandler.onScannedCode(event.data) + } catch (e: Exception) { + // Should not happen as errors are handled through the LinkDesktopStep flow + scanAction = AsyncAction.Failure(e) + } + } + } + } + + return ScanQrCodeState( + scanAction = scanAction, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt new file mode 100644 index 0000000000..6cb0363836 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.scan + +import io.element.android.libraries.architecture.AsyncAction + +data class ScanQrCodeState( + val scanAction: AsyncAction, + val eventSink: (ScanQrCodeEvent) -> Unit, +) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt new file mode 100644 index 0000000000..40e7df8884 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.scan + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction + +open class ScanQrCodeStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aScanQrCodeState(), + aScanQrCodeState(scanAction = AsyncAction.Loading), + aScanQrCodeState(scanAction = AsyncAction.Success(Unit)), + aScanQrCodeState(scanAction = AsyncAction.Failure(Exception("Scan failed"))), + ) +} + +fun aScanQrCodeState( + scanAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (ScanQrCodeEvent) -> Unit = {}, +) = ScanQrCodeState( + scanAction = scanAction, + eventSink = eventSink +) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt new file mode 100644 index 0000000000..937aee08b4 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.scan + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.progressSemantics +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.linknewdevice.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.modifiers.cornerBorder +import io.element.android.libraries.designsystem.modifiers.squareSize +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.qrcode.QrCodeCameraView +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun ScanQrCodeView( + state: ScanQrCodeState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + FlowStepPage( + modifier = modifier, + onBackClick = onBackClick, + iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()), + title = stringResource(R.string.screen_link_new_device_desktop_scanning_title), + content = { Content(state = state) }, + buttons = { Buttons(state = state) } + ) +} + +@Composable +private fun Content( + state: ScanQrCodeState, +) { + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val modifier = if (constraints.maxWidth > constraints.maxHeight) { + Modifier.fillMaxHeight() + } else { + Modifier.fillMaxWidth() + }.then( + Modifier + .padding(start = 20.dp, end = 20.dp, top = 50.dp, bottom = 32.dp) + .squareSize() + .cornerBorder( + strokeWidth = 4.dp, + color = ElementTheme.colors.textPrimary, + cornerSizeDp = 42.dp, + ) + ) + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + QrCodeCameraView( + modifier = Modifier.fillMaxSize(), + onScanQrCode = { state.eventSink.invoke(ScanQrCodeEvent.QrCodeScanned(it)) }, + isScanning = state.scanAction.isLoading(), + ) + } + } +} + +@Composable +private fun ColumnScope.Buttons( + state: ScanQrCodeState, +) { + Column(Modifier.heightIn(min = 130.dp)) { + when (state.scanAction) { + is AsyncAction.Failure -> { + Button( + text = stringResource(id = CommonStrings.action_try_again), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + onClick = { + state.eventSink.invoke(ScanQrCodeEvent.TryAgain) + } + ) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = CompoundIcons.ErrorSolid(), + tint = ElementTheme.colors.iconCriticalPrimary, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.screen_qr_code_login_invalid_scan_state_subtitle), + textAlign = TextAlign.Center, + color = ElementTheme.colors.textCriticalPrimary, + style = ElementTheme.typography.fontBodySmMedium, + ) + } + Text( + text = stringResource(R.string.screen_qr_code_login_invalid_scan_state_description), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + is AsyncAction.Success -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + } + } + AsyncAction.Loading, + AsyncAction.Uninitialized, + is AsyncAction.Confirming -> Unit + } + } +} + +@PreviewsDayNight +@Composable +internal fun ScanQrCodeViewPreview(@PreviewParameter(ScanQrCodeStateProvider::class) state: ScanQrCodeState) = ElementPreview { + ScanQrCodeView( + state = state, + onBackClick = {}, + ) +} diff --git a/features/linknewdevice/impl/src/main/res/values/localazy.xml b/features/linknewdevice/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..9bf7ac2132 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values/localazy.xml @@ -0,0 +1,55 @@ + + + "Scan the QR code" + "Open %1$s on a laptop or desktop computer" + "Scan the QR code with this device" + "Ready to scan" + "Open %1$s on a desktop computer to get the QR code" + "The numbers don’t match" + "Enter 2-digit code" + "This will verify that the connection to your other device is secure." + "Enter the number shown on your other device" + "Your account provider does not support %1$s." + "%1$s not supported" + "Your account provider doesn’t support signing into a new device with a QR code." + "QR code not supported" + "The sign in was cancelled on the other device." + "Sign in request cancelled" + "Sign in expired. Please try again." + "The sign in was not completed in time" + "Open %1$s on the other device" + "Select %1$s" + "“Sign in with QR code”" + "Scan the QR code shown here with the other device" + "Open %1$s on the other device" + "Desktop computer" + "Loading QR code…" + "Mobile device" + "What type of device do you want to link?" + "Please try again and make sure that you’ve entered the 2-digit code correctly. If the numbers still don’t match then contact your account provider." + "The numbers don’t match" + "A secure connection could not be made to the new device. Your existing devices are still safe and you don\'t need to worry about them." + "What now?" + "Try signing in again with a QR code in case this was a network problem" + "If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi" + "If that doesn’t work, sign in manually" + "Connection not secure" + "The sign in was cancelled on the other device." + "Sign in request cancelled" + "The sign in was declined on the other device." + "Sign in declined" + "Sign in expired. Please try again." + "The sign in was not completed in time" + "Your other device does not support signing in to %s with a QR code. + +Try signing in manually, or scan the QR code with another device." + "QR code not supported" + "Your account provider does not support %1$s." + "%1$s not supported" + "Use the QR code shown on the other device." + "Try again" + "Wrong QR code" + "You need to give permission for %1$s to use your device’s camera in order to continue." + "Allow camera access to scan the QR code" + "An unexpected error occurred. Please try again." + diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt new file mode 100644 index 0000000000..ba3892c965 --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt @@ -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, + ) + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt new file mode 100644 index 0000000000..ac0a129f49 --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.desktop + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.linknewdevice.impl.R +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DesktopNoticeViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setView( + state = aDesktopNoticeState(), + onBackClicked = callback, + ) + rule.pressBackKey() + } + } + + @Test + fun `on back button clicked - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setView( + state = aDesktopNoticeState(), + onBackClicked = callback, + ) + rule.pressBack() + } + } + + @Test + fun `when can continue - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setView( + state = aDesktopNoticeState(canContinue = true), + onReadyToScanClick = callback, + ) + } + } + + @Test + fun `on submit button clicked - emits the Continue event`() { + val eventRecorder = EventsRecorder() + rule.setView( + state = aDesktopNoticeState(eventSink = eventRecorder), + ) + rule.clickOn(R.string.screen_link_new_device_desktop_submit) + eventRecorder.assertSingle(DesktopNoticeEvent.Continue) + } + + private fun AndroidComposeTestRule.setView( + state: DesktopNoticeState, + onBackClicked: () -> Unit = EnsureNeverCalled(), + onReadyToScanClick: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + DesktopNoticeView( + state = state, + onBackClick = onBackClicked, + onReadyToScanClick = onReadyToScanClick, + ) + } + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt new file mode 100644 index 0000000000..8f44182dd4 --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.error + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ErrorViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the onRetry callback`() { + ensureCalledOnce { callback -> + rule.setErrorView( + onRetry = callback + ) + rule.pressBackKey() + } + } + + @Test + fun `on start over button clicked - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setErrorView( + onRetry = callback + ) + rule.clickOn(CommonStrings.action_start_over) + } + } + + private fun AndroidComposeTestRule.setErrorView( + onRetry: () -> Unit, + errorScreenType: ErrorScreenType = ErrorScreenType.UnknownError, + ) { + setContent { + ErrorView( + errorScreenType = errorScreenType, + onRetry = onRetry, + ) + } + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt new file mode 100644 index 0000000000..fe2e11e95c --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.linknewdevice.impl.screens.number + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.linknewdevice.FakeCheckCodeSender +import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkMobileHandler +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class EnterNumberPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + createPresenter().test { + val initialState = awaitItem() + assertThat(initialState.number).isEmpty() + assertThat(initialState.sendingCode.isUninitialized()).isTrue() + } + } + + @Test + fun `present - enter numbers`() = runTest { + createPresenter().test { + val initialState = awaitItem() + assertThat(initialState.number).isEmpty() + initialState.eventSink(EnterNumberEvent.UpdateNumber("12")) + val state2 = awaitItem() + assertThat(state2.number).isEqualTo("12") + // Non numeric characters are ignored + state2.eventSink(EnterNumberEvent.UpdateNumber("1a")) + val state3 = awaitItem() + assertThat(state3.number).isEqualTo("1") + } + } + + @Test + fun `present - continue in wrong state generates an error`() = runTest { + createPresenter().test { + val initialState = awaitItem() + initialState.eventSink(EnterNumberEvent.Continue) + val state2 = awaitItem() + assertThat(state2.sendingCode.isFailure()).isTrue() + } + } + + @Test + fun `present - continue when number is not valid invokes the navigator`() = runTest { + val linkMobileHandler = FakeLinkMobileHandler( + startResult = {}, + ) + val validateResult = lambdaRecorder { false } + val checkCodeSender = FakeCheckCodeSender( + validateResult = validateResult, + ) + val matrixClient = FakeMatrixClient( + sessionCoroutineScope = backgroundScope, + createLinkMobileHandlerResult = { Result.success(linkMobileHandler) } + ) + val linkNewMobileHandler = LinkNewMobileHandler(matrixClient) + linkNewMobileHandler.createAndStartNewHandler() + val navigateToWrongNumberErrorLambda = lambdaRecorder { } + val navigator = FakeEnterNumberNavigator( + navigateToWrongNumberErrorLambda = navigateToWrongNumberErrorLambda, + ) + createPresenter( + navigator = navigator, + linkNewMobileHandler = linkNewMobileHandler, + ).test { + val initialState = awaitItem() + linkMobileHandler.emitStep( + LinkMobileStep.QrScanned(checkCodeSender) + ) + runCurrent() + initialState.eventSink(EnterNumberEvent.UpdateNumber("88")) + skipItems(1) + initialState.eventSink(EnterNumberEvent.Continue) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.sendingCode.isLoading()).isTrue() + advanceUntilIdle() + validateResult.assertions().isCalledOnce().with(value(88.toUByte())) + navigateToWrongNumberErrorLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - continue when the number is valid but sending fails`() = runTest { + val linkMobileHandler = FakeLinkMobileHandler( + startResult = {}, + ) + val validateResult = lambdaRecorder { true } + val sendResult = lambdaRecorder> { Result.failure(AN_EXCEPTION) } + val checkCodeSender = FakeCheckCodeSender( + validateResult = validateResult, + sendResult = sendResult, + ) + val matrixClient = FakeMatrixClient( + sessionCoroutineScope = backgroundScope, + createLinkMobileHandlerResult = { Result.success(linkMobileHandler) } + ) + val linkNewMobileHandler = LinkNewMobileHandler(matrixClient) + linkNewMobileHandler.createAndStartNewHandler() + createPresenter( + linkNewMobileHandler = linkNewMobileHandler, + ).test { + val initialState = awaitItem() + linkMobileHandler.emitStep( + LinkMobileStep.QrScanned(checkCodeSender) + ) + runCurrent() + initialState.eventSink(EnterNumberEvent.UpdateNumber("88")) + skipItems(1) + initialState.eventSink(EnterNumberEvent.Continue) + skipItems(1) + val loadingState = awaitItem() + assertThat(loadingState.sendingCode.isLoading()).isTrue() + val finalState = awaitItem() + assertThat(finalState.sendingCode.isFailure()).isTrue() + validateResult.assertions().isCalledOnce().with(value(88.toUByte())) + sendResult.assertions().isCalledOnce().with(value(88.toUByte())) + } + } + + @Test + fun `present - continue when the number is valid and sending is successful`() = runTest { + val linkMobileHandler = FakeLinkMobileHandler( + startResult = {}, + ) + val validateResult = lambdaRecorder { true } + val sendResult = lambdaRecorder> { Result.success(Unit) } + val checkCodeSender = FakeCheckCodeSender( + validateResult = validateResult, + sendResult = sendResult, + ) + val matrixClient = FakeMatrixClient( + sessionCoroutineScope = backgroundScope, + createLinkMobileHandlerResult = { Result.success(linkMobileHandler) } + ) + val linkNewMobileHandler = LinkNewMobileHandler(matrixClient) + linkNewMobileHandler.createAndStartNewHandler() + createPresenter( + linkNewMobileHandler = linkNewMobileHandler, + ).test { + val initialState = awaitItem() + linkMobileHandler.emitStep( + LinkMobileStep.QrScanned(checkCodeSender) + ) + runCurrent() + initialState.eventSink(EnterNumberEvent.UpdateNumber("88")) + skipItems(1) + initialState.eventSink(EnterNumberEvent.Continue) + skipItems(1) + val loadingState = awaitItem() + assertThat(loadingState.sendingCode.isLoading()).isTrue() + expectNoEvents() + advanceUntilIdle() + validateResult.assertions().isCalledOnce().with(value(88.toUByte())) + sendResult.assertions().isCalledOnce().with(value(88.toUByte())) + } + } + + private fun createPresenter( + navigator: EnterNumberNavigator = FakeEnterNumberNavigator(), + linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()), + ) = EnterNumberPresenter( + navigator = navigator, + linkNewMobileHandler = linkNewMobileHandler, + ) +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt new file mode 100644 index 0000000000..e7466a1b21 --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.number + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.linknewdevice.impl.screens.number.model.Digit +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import org.junit.Test + +class EnterNumberStateTest { + @Test + fun `isContinueButtonEnabled is false if number is not complete`() { + val sut = aEnterNumberState( + number = "", + sendingCode = AsyncAction.Uninitialized, + ) + assertThat(sut.copy(number = "1").isContinueButtonEnabled).isFalse() + } + + @Test + fun `isContinueButtonEnabled is true if number is complete`() { + val sut = aEnterNumberState( + number = "12", + sendingCode = AsyncAction.Uninitialized, + ) + assertThat(sut.isContinueButtonEnabled).isTrue() + } + + @Test + fun `isContinueButtonEnabled is false if number is complete and sending is loading`() { + val sut = aEnterNumberState( + number = "12", + sendingCode = AsyncAction.Loading, + ) + assertThat(sut.isContinueButtonEnabled).isFalse() + } + + @Test + fun `isContinueButtonEnabled is true if number is complete and sending is not loading`() { + listOf( + AsyncAction.Uninitialized, + AsyncAction.Failure(AN_EXCEPTION), + AsyncAction.Success(Unit), + ).forEach { action -> + val sut = aEnterNumberState( + number = "12", + sendingCode = action, + ) + assertThat(sut.isContinueButtonEnabled).isTrue() + } + } + + @Test + fun `numberEntry is computed from number - case empty`() { + val sut = aEnterNumberState( + number = "", + ) + assertThat(sut.numberEntry.size).isEqualTo(2) + assertThat(sut.numberEntry.digits).containsExactly( + Digit.Empty, + Digit.Empty, + ) + } + + @Test + fun `numberEntry is computed from number - case half filled`() { + val sut = aEnterNumberState( + number = "1", + ) + assertThat(sut.numberEntry.size).isEqualTo(2) + assertThat(sut.numberEntry.digits).containsExactly( + Digit.Filled('1'), + Digit.Empty, + ) + } + + @Test + fun `numberEntry is computed from number - case filled`() { + val sut = aEnterNumberState( + number = "12", + ) + assertThat(sut.numberEntry.size).isEqualTo(2) + assertThat(sut.numberEntry.digits).containsExactly( + Digit.Filled('1'), + Digit.Filled('2'), + ) + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt new file mode 100644 index 0000000000..20e1d898dd --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.number + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EnterNumberViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setView( + state = aEnterNumberState(), + onBackClicked = callback, + ) + rule.pressBackKey() + } + } + + @Test + fun `on back button clicked - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setView( + state = aEnterNumberState(), + onBackClicked = callback, + ) + rule.pressBack() + } + } + + @Test + fun `on continue button clicked - emits the Continue event`() { + val eventRecorder = EventsRecorder() + rule.setView( + state = aEnterNumberState( + number = "12", + eventSink = eventRecorder, + ), + ) + rule.clickOn(CommonStrings.action_continue) + eventRecorder.assertSingle(EnterNumberEvent.Continue) + } + + @Test + fun `when the number is not complete, continue button is disabled`() { + val eventRecorder = EventsRecorder(expectEvents = false) + rule.setView( + state = aEnterNumberState( + number = "1", + eventSink = eventRecorder, + ), + ) + val continueStr = rule.activity.getString(CommonStrings.action_continue) + rule.onNodeWithText(continueStr).assertIsNotEnabled() + } + + private fun AndroidComposeTestRule.setView( + state: EnterNumberState, + onBackClicked: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + EnterNumberView( + state = state, + onBackClick = onBackClicked, + ) + } + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt new file mode 100644 index 0000000000..a96ab7fe30 --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.number + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeEnterNumberNavigator( + private val navigateToWrongNumberErrorLambda: () -> Unit = { lambdaError() }, +) : EnterNumberNavigator { + override fun navigateToWrongNumberError() { + navigateToWrongNumberErrorLambda() + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt new file mode 100644 index 0000000000..c6c89ba818 --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.qrcode + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShowQrCodeViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setView( + onBackClick = callback + ) + rule.pressBackKey() + } + } + + private fun AndroidComposeTestRule.setView( + onBackClick: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + ShowQrCodeView( + data = "DATA", + onBackClick = onBackClick, + ) + } + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt new file mode 100644 index 0000000000..5d945e9605 --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.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, + ) +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt new file mode 100644 index 0000000000..e352debfb0 --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.root + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.linknewdevice.impl.R +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LinkNewDeviceRootViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the onRetry callback`() { + val eventRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setLinkNewDeviceRootView( + state = aLinkNewDeviceRootState( + eventSink = eventRecorder, + ), + onBackClick = callback + ) + rule.pressBackKey() + } + } + + @Test + fun `link desktop button clicked - calls the expected callback`() { + val eventRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setLinkNewDeviceRootView( + state = aLinkNewDeviceRootState( + isSupported = AsyncData.Success(true), + eventSink = eventRecorder, + ), + onLinkDesktopDeviceClick = callback, + ) + rule.clickOn(R.string.screen_link_new_device_root_desktop_computer) + } + } + + @Test + fun `link mobile button clicked - emits the expected event`() { + val eventRecorder = EventsRecorder() + rule.setLinkNewDeviceRootView( + state = aLinkNewDeviceRootState( + isSupported = AsyncData.Success(true), + eventSink = eventRecorder, + ) + ) + rule.clickOn(R.string.screen_link_new_device_root_mobile_device) + eventRecorder.assertSingle(LinkNewDeviceRootEvent.LinkMobileDevice) + } + + @Test + fun `not supported - dismiss click - invokes the expected callback`() { + val eventRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setLinkNewDeviceRootView( + state = aLinkNewDeviceRootState( + isSupported = AsyncData.Success(false), + eventSink = eventRecorder, + ), + onBackClick = callback, + ) + rule.clickOn(CommonStrings.action_dismiss) + } + } + + private fun AndroidComposeTestRule.setLinkNewDeviceRootView( + state: LinkNewDeviceRootState = aLinkNewDeviceRootState(), + onBackClick: () -> Unit = EnsureNeverCalled(), + onLinkDesktopDeviceClick: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + LinkNewDeviceRootView( + state = state, + onBackClick = onBackClick, + onLinkDesktopDeviceClick = onLinkDesktopDeviceClick, + ) + } + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt new file mode 100644 index 0000000000..a258e19fb9 --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.linknewdevice.impl.screens.scan + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.linknewdevice.impl.LinkNewDesktopHandler +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE +import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkDesktopHandler +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ScanQrCodePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val matrixClient = FakeMatrixClient( + createLinkDesktopHandlerResult = { Result.success(FakeLinkDesktopHandler()) } + ) + createPresenter( + matrixClient = matrixClient, + ).test { + val initialState = awaitItem() + assertThat(initialState.scanAction.isLoading()).isTrue() + } + } + + @Test + fun `present - handle scanned event - success`() = runTest { + val handleScannedQrCodeResult = lambdaRecorder { } + val matrixClient = FakeMatrixClient( + sessionCoroutineScope = backgroundScope, + createLinkDesktopHandlerResult = { + Result.success( + FakeLinkDesktopHandler( + handleScannedQrCodeResult = handleScannedQrCodeResult, + ) + ) + } + ) + createPresenter( + matrixClient = matrixClient, + ).test { + val initialState = awaitItem() + assertThat(initialState.scanAction.isLoading()).isTrue() + initialState.eventSink(ScanQrCodeEvent.QrCodeScanned(QR_CODE_DATA_RECIPROCATE)) + val scannedState = awaitItem() + assertThat(scannedState.scanAction.isSuccess()).isTrue() + runCurrent() + handleScannedQrCodeResult.assertions().isCalledOnce().with(value(QR_CODE_DATA_RECIPROCATE)) + } + } + + @Test + fun `present - handle scanned event - failure`() = runTest { + val handleScannedQrCodeResult = lambdaRecorder { } + val handler = FakeLinkDesktopHandler( + handleScannedQrCodeResult = handleScannedQrCodeResult, + ) + val matrixClient = FakeMatrixClient( + sessionCoroutineScope = backgroundScope, + createLinkDesktopHandlerResult = { + Result.success(handler) + } + ) + createPresenter( + matrixClient = matrixClient, + ).test { + val initialState = awaitItem() + assertThat(initialState.scanAction.isLoading()).isTrue() + initialState.eventSink(ScanQrCodeEvent.QrCodeScanned(QR_CODE_DATA_RECIPROCATE)) + val scannedState = awaitItem() + assertThat(scannedState.scanAction.isSuccess()).isTrue() + handler.emitStep(LinkDesktopStep.InvalidQrCode(QrCodeDecodeException.Crypto("Invalid QR Code"))) + skipItems(1) + val errorState = awaitItem() + assertThat(errorState.scanAction.isFailure()).isTrue() + handleScannedQrCodeResult.assertions().isCalledOnce().with(value(QR_CODE_DATA_RECIPROCATE)) + // Reset by trying again + errorState.eventSink(ScanQrCodeEvent.TryAgain) + val resetState = awaitItem() + assertThat(resetState.scanAction.isLoading()).isTrue() + } + } +} + +private fun createPresenter( + matrixClient: MatrixClient, +): ScanQrCodePresenter { + return ScanQrCodePresenter( + linkNewDesktopHandler = LinkNewDesktopHandler(matrixClient), + ) +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt new file mode 100644 index 0000000000..fcc3afeb7d --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.scan + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ScanQrCodeViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the expected callback`() { + val eventRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + state = aScanQrCodeState( + eventSink = eventRecorder, + ), + onBackClick = callback + ) + rule.pressBackKey() + } + } + + @Test + fun `try again button clicked - emits the expected event`() { + val eventRecorder = EventsRecorder() + rule.setView( + state = aScanQrCodeState( + scanAction = AsyncAction.Failure(AN_EXCEPTION), + eventSink = eventRecorder, + ) + ) + rule.clickOn(CommonStrings.action_try_again) + eventRecorder.assertSingle(ScanQrCodeEvent.TryAgain) + } + + private fun AndroidComposeTestRule.setView( + state: ScanQrCodeState = aScanQrCodeState(), + onBackClick: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + ScanQrCodeView( + state = state, + onBackClick = onBackClick, + ) + } + } +} diff --git a/features/linknewdevice/test/build.gradle.kts b/features/linknewdevice/test/build.gradle.kts new file mode 100644 index 0000000000..388612f920 --- /dev/null +++ b/features/linknewdevice/test/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.linknewdevice.test" +} + +dependencies { + implementation(projects.features.linknewdevice.api) + implementation(projects.tests.testutils) +} diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index 82ff1e7edd..5a59d9be8a 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -41,6 +41,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun navigateToAddAccount() + fun navigateToLinkNewDevice() fun navigateToBugReport() fun navigateToSecureBackup() fun navigateToRoomNotificationSettings(roomId: RoomId) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index c7328fb6ed..c646923c77 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -163,6 +163,10 @@ class PreferencesFlowNode( backstack.push(NavTarget.Labs) } + override fun navigateToLinkNewDevice() { + callback.navigateToLinkNewDevice() + } + override fun navigateToUserProfile(matrixUser: MatrixUser) { backstack.push(NavTarget.UserProfile(matrixUser)) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index 7dafcfae81..6b54a763af 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -45,6 +45,7 @@ class PreferencesRootNode( fun navigateToLockScreenSettings() fun navigateToAdvancedSettings() fun navigateToLabs() + fun navigateToLinkNewDevice() fun navigateToUserProfile(matrixUser: MatrixUser) fun navigateToBlockedUsers() fun startSignOutFlow() @@ -84,6 +85,7 @@ class PreferencesRootNode( onOpenDeveloperSettings = callback::navigateToDeveloperSettings, onOpenAdvancedSettings = callback::navigateToAdvancedSettings, onOpenLabs = callback::navigateToLabs, + onLinkNewDeviceClick = callback::navigateToLinkNewDevice, onManageAccountClick = { onManageAccountClick(activity, it, isDark) }, onOpenNotificationSettings = callback::navigateToNotificationSettings, onOpenLockScreenSettings = callback::navigateToLockScreenSettings, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 4e83f7c6b2..1e056c0bf0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -69,6 +69,9 @@ class PreferencesRootPresenter( val isMultiAccountEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount) }.collectAsState(initial = false) + val showLinkNewDevice by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin) + }.collectAsState(initial = false) val otherSessions by remember { sessionStore.sessionsFlow().map { list -> @@ -146,6 +149,7 @@ class PreferencesRootPresenter( devicesManagementUrl = devicesManagementUrl.value, showAnalyticsSettings = hasAnalyticsProviders, canReportBug = canReportBug, + showLinkNewDevice = showLinkNewDevice, showDeveloperSettings = showDeveloperSettings, canDeactivateAccount = canDeactivateAccount, showBlockedUsersItem = showBlockedUsersItem, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index dd03b3e775..d637ae6c87 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -25,6 +25,7 @@ data class PreferencesRootState( val accountManagementUrl: String?, val devicesManagementUrl: String?, val canReportBug: Boolean, + val showLinkNewDevice: Boolean, val showAnalyticsSettings: Boolean, val showDeveloperSettings: Boolean, val canDeactivateAccount: Boolean, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index c979bd2580..b8d1f1c2b6 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -31,6 +31,7 @@ fun aPreferencesRootState( accountManagementUrl = "aUrl", devicesManagementUrl = "anOtherUrl", showAnalyticsSettings = true, + showLinkNewDevice = true, canReportBug = true, showDeveloperSettings = true, showBlockedUsersItem = true, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 66398d9dbc..5e3c9d6759 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -54,6 +54,7 @@ fun PreferencesRootView( onAddAccountClick: () -> Unit, onSecureBackupClick: () -> Unit, onManageAccountClick: (url: String) -> Unit, + onLinkNewDeviceClick: () -> Unit, onOpenAnalytics: () -> Unit, onOpenRageShake: () -> Unit, onOpenLockScreenSettings: () -> Unit, @@ -101,6 +102,7 @@ fun PreferencesRootView( ManageAccountSection( state = state, onManageAccountClick = onManageAccountClick, + onLinkNewDeviceClick = onLinkNewDeviceClick, onOpenBlockedUsers = onOpenBlockedUsers ) @@ -193,8 +195,16 @@ private fun ColumnScope.ManageAppSection( private fun ColumnScope.ManageAccountSection( state: PreferencesRootState, onManageAccountClick: (url: String) -> Unit, + onLinkNewDeviceClick: () -> Unit, onOpenBlockedUsers: () -> Unit, ) { + if (state.showLinkNewDevice) { + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.common_link_new_device)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())), + onClick = onLinkNewDeviceClick, + ) + } state.accountManagementUrl?.let { url -> ListItem( headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) }, @@ -353,6 +363,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) { onOpenAbout = {}, onSecureBackupClick = {}, onManageAccountClick = {}, + onLinkNewDeviceClick = {}, onOpenNotificationSettings = {}, onOpenLockScreenSettings = {}, onOpenUserProfile = {}, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt index 7a950629be..963d7846b4 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt @@ -50,6 +50,7 @@ class DefaultPreferencesEntryPointTest { } val callback = object : PreferencesEntryPoint.Callback { override fun navigateToAddAccount() = lambdaError() + override fun navigateToLinkNewDevice() = lambdaError() override fun navigateToBugReport() = lambdaError() override fun navigateToSecureBackup() = lambdaError() override fun navigateToRoomNotificationSettings(roomId: RoomId) = lambdaError() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 1fa141b297..d10f860f0d 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -87,6 +87,7 @@ class PreferencesRootPresenterTest { assertThat(loadedState.accountManagementUrl).isNull() assertThat(loadedState.devicesManagementUrl).isNull() assertThat(loadedState.showAnalyticsSettings).isFalse() + assertThat(loadedState.showLinkNewDevice).isFalse() assertThat(loadedState.showDeveloperSettings).isTrue() assertThat(loadedState.canDeactivateAccount).isTrue() assertThat(loadedState.canReportBug).isTrue() @@ -258,6 +259,22 @@ class PreferencesRootPresenterTest { } } + @Test + fun `present - link new device`() = runTest { + createPresenter( + matrixClient = FakeMatrixClient( + sessionId = A_SESSION_ID, + canDeactivateAccountResult = { true }, + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.QrCodeLogin.key to true) + ), + ).test { + val state = awaitFirstItem() + assertThat(state.showLinkNewDevice).isTrue() + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { skipItems(1) return awaitItem() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b5a2607c01..96378f0eb7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -211,6 +211,9 @@ maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" opusencoder = "io.element.android:opusencoder:1.2.0" zxing_cpp = "io.github.zxing-cpp:android:2.3.0" +# Stick to 3.3.3 because of https://github.com/zxing/zxing/issues/1170 +google_zxing = "com.google.zxing:core:3.3.3" + haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } color_picker = "io.mhssn:colorpicker:1.0.0" diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/Brightness.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/Brightness.kt new file mode 100644 index 0000000000..8cf1cc6827 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/Brightness.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.system + +import android.app.Activity +import android.view.WindowManager + +/** + * Set the screen brightness for the given activity. + * + * @receiver current Activity. + * @param full If true, override brightness to full; otherwise, set to none (default). + */ +fun Activity.setFullBrightness(full: Boolean) { + window.attributes = window.attributes.apply { + screenBrightness = if (full) { + WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL + } else { + WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/LoadingButtonAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/LoadingButtonAtom.kt new file mode 100644 index 0000000000..666d48d457 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/LoadingButtonAtom.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.atoms + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun LoadingButtonAtom( + modifier: Modifier = Modifier, +) = Button( + modifier = modifier.fillMaxWidth(), + enabled = false, + showProgress = true, + text = stringResource(CommonStrings.common_loading), + onClick = {}, +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceMaxBrightness.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceMaxBrightness.kt new file mode 100644 index 0000000000..b0c9e8cea7 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceMaxBrightness.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import io.element.android.libraries.androidutils.system.setFullBrightness + +@Composable +fun ForceMaxBrightness() { + val activity = LocalActivity.current ?: return + DisposableEffect(Unit) { + activity.setFullBrightness(true) + onDispose { + activity.setFullBrightness(false) + } + } +} diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 2cbfb50944..f7227a9ac9 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -125,4 +125,11 @@ enum class FeatureFlags( defaultValue = { true }, isFinished = false, ), + QrCodeLogin( + key = "feature.qr_code_login", + title = "QR Code Login", + description = "Allow logging in on other devices using a QR code.", + defaultValue = { false }, + isFinished = false, + ), } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index ae803110d5..6220349756 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -19,6 +19,8 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaPreviewService import io.element.android.libraries.matrix.api.notification.NotificationService @@ -195,6 +197,21 @@ interface MatrixClient { */ suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result + /** + * Check if linking a new device using QrCode is supported by the server. + */ + suspend fun canLinkNewDevice(): Result + + /** + * Create a handler to link a new mobile device, i.e. a device capable of scanning QrCodes. + */ + fun createLinkMobileHandler(): Result + + /** + * Create a handler to link a new desktop device, i.e. a device not capable of scanning QrCodes. + */ + fun createLinkDesktopHandler(): Result + suspend fun performDatabaseVacuum(): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/CheckCodeSender.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/CheckCodeSender.kt new file mode 100644 index 0000000000..ecb440de83 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/CheckCodeSender.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.linknewdevice + +interface CheckCodeSender { + /** + * Validates the given [code]. Returns true if the code is valid, false otherwise. + * This method can be called multiple times to validate different codes. + */ + suspend fun validate(code: UByte): Boolean + + /** + * Sends the given [code]. + * This method can be called only once. + */ + suspend fun send(code: UByte): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ErrorType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ErrorType.kt new file mode 100644 index 0000000000..0f61007d47 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ErrorType.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.linknewdevice + +sealed class ErrorType(message: String) : Exception(message) { + /** + * The requested device ID is already in use. + */ + class DeviceIdAlreadyInUse(message: String) : ErrorType(message) + + /** + * The check code was incorrect. + */ + class InvalidCheckCode(message: String) : ErrorType(message) + + /** + * The other client proposed an unsupported protocol. + */ + class UnsupportedProtocol(message: String) : ErrorType(message) + + /** + * Secrets backup not set up properly. + */ + class MissingSecretsBackup(message: String) : ErrorType(message) + + /** + * The rendezvous session was not found and might have expired. + */ + class NotFound(message: String) : ErrorType(message) + + /** + * The device could not be created. + */ + class UnableToCreateDevice(message: String) : ErrorType(message) + + /** + * An unknown error has happened. + */ + class Unknown(message: String) : ErrorType(message) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt new file mode 100644 index 0000000000..d600c1a8bf --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.linknewdevice + +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException +import kotlinx.coroutines.flow.StateFlow + +interface LinkDesktopHandler { + val linkDesktopStep: StateFlow + suspend fun handleScannedQrCode(data: ByteArray) +} + +sealed interface LinkDesktopStep { + data object Uninitialized : LinkDesktopStep + data object Starting : LinkDesktopStep + data class WaitingForAuth( + val verificationUri: String, + ) : LinkDesktopStep + + data class EstablishingSecureChannel( + val checkCode: UByte, + val checkCodeString: String, + ) : LinkDesktopStep + + data class InvalidQrCode( + val error: QrCodeDecodeException, + ) : LinkDesktopStep + + data class Error( + val errorType: ErrorType, + ) : LinkDesktopStep + + data object SyncingSecrets : LinkDesktopStep + + data object Done : LinkDesktopStep +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt new file mode 100644 index 0000000000..0c261cdd1a --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.linknewdevice + +import kotlinx.coroutines.flow.Flow + +interface LinkMobileHandler { + val linkMobileStep: Flow + suspend fun start() +} + +sealed interface LinkMobileStep { + data object Uninitialized : LinkMobileStep + data object Starting : LinkMobileStep + data class QrReady(val data: String) : LinkMobileStep + data class WaitingForAuth(val verificationUri: String) : LinkMobileStep + data class QrScanned(val checkCodeSender: CheckCodeSender) : LinkMobileStep + data class Error(val errorType: ErrorType) : LinkMobileStep + data object SyncingSecrets : LinkMobileStep + data object Done : LinkMobileStep +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/logs/LoggerTags.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/logs/LoggerTags.kt new file mode 100644 index 0000000000..7f19b2d3db --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/logs/LoggerTags.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.logs + +import io.element.android.libraries.core.log.logger.LoggerTag + +object LoggerTags { + val linkNewDevice = LoggerTag("LinkNewDevice") +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 8784a69d7c..1de606637a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -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.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.createroom.RoomPreset +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.oidc.AccountManagementAction import io.element.android.libraries.matrix.api.room.BaseRoom @@ -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.impl.encryption.RustEncryptionService import io.element.android.libraries.matrix.impl.exception.mapClientException +import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler +import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkMobileHandler import io.element.android.libraries.matrix.impl.mapper.map import io.element.android.libraries.matrix.impl.media.RustMediaLoader import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService @@ -726,6 +730,34 @@ class RustMatrixClient( } } + override suspend fun canLinkNewDevice(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.isLoginWithQrCodeSupported() + } + } + + override fun createLinkMobileHandler(): Result { + return runCatchingExceptions { + val handler = innerClient.newGrantLoginWithQrCodeHandler() + RustLinkMobileHandler( + inner = handler, + sessionCoroutineScope = sessionCoroutineScope, + sessionDispatcher = sessionDispatcher, + ) + } + } + + override fun createLinkDesktopHandler(): Result { + return runCatchingExceptions { + val handler = innerClient.newGrantLoginWithQrCodeHandler() + RustLinkDesktopHandler( + inner = handler, + sessionCoroutineScope = sessionCoroutineScope, + sessionDispatcher = sessionDispatcher, + ) + } + } + override suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result = withContext(sessionDispatcher) { runCatchingExceptions { val room = innerClient.getRoom(roomId.value) ?: error("Could not fetch associated room") diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/HumanQrGrantLoginExceptionExtension.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/HumanQrGrantLoginExceptionExtension.kt new file mode 100644 index 0000000000..2d47b60def --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/HumanQrGrantLoginExceptionExtension.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.linknewdevice + +import io.element.android.libraries.matrix.api.linknewdevice.ErrorType +import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException + +internal fun HumanQrGrantLoginException.map() = when (this) { + is HumanQrGrantLoginException.DeviceIdAlreadyInUse -> ErrorType.DeviceIdAlreadyInUse(message.orEmpty()) + is HumanQrGrantLoginException.InvalidCheckCode -> ErrorType.InvalidCheckCode(message.orEmpty()) + is HumanQrGrantLoginException.MissingSecretsBackup -> ErrorType.MissingSecretsBackup(message.orEmpty()) + is HumanQrGrantLoginException.NotFound -> ErrorType.NotFound(message.orEmpty()) + is HumanQrGrantLoginException.UnableToCreateDevice -> ErrorType.UnableToCreateDevice(message.orEmpty()) + is HumanQrGrantLoginException.Unknown -> ErrorType.Unknown(message.orEmpty()) + is HumanQrGrantLoginException.UnsupportedProtocol -> ErrorType.UnsupportedProtocol(message.orEmpty()) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSender.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSender.kt new file mode 100644 index 0000000000..9919d1fafc --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSender.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.linknewdevice + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.linknewdevice.CheckCodeSender +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.CheckCodeSender as FfiCheckCodeSender + +class RustCheckCodeSender( + private val inner: FfiCheckCodeSender, + private val sessionDispatcher: CoroutineDispatcher, +) : CheckCodeSender { + override suspend fun validate(code: UByte): Boolean = withContext(sessionDispatcher) { + runCatchingExceptions { + // TODO https://github.com/matrix-org/matrix-rust-sdk/pull/5957 + // inner.validate(code) + true + }.getOrNull() ?: true + } + + override suspend fun send(code: UByte): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + inner.send(code) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt new file mode 100644 index 0000000000..fd37b73ad8 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.linknewdevice + +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep +import io.element.android.libraries.matrix.api.logs.LoggerTags +import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeHandler +import org.matrix.rustcomponents.sdk.GrantQrLoginProgress +import org.matrix.rustcomponents.sdk.GrantQrLoginProgressListener +import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException +import org.matrix.rustcomponents.sdk.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.Uninitialized) + override val linkDesktopStep: StateFlow = _linkDesktopStep.asStateFlow() + + override suspend fun handleScannedQrCode(data: ByteArray) = withContext(sessionDispatcher) { + Timber.tag(tag.value).d("Emit Uninitialized") + _linkDesktopStep.emit(LinkDesktopStep.Uninitialized) + try { + val qrCodeData = 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, + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt new file mode 100644 index 0000000000..0189987d96 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.linknewdevice + +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep +import io.element.android.libraries.matrix.api.logs.LoggerTags +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgress +import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgressListener +import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeHandler +import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException +import timber.log.Timber + +private val tag = LoggerTag("RustLinkMobileHandler", LoggerTags.linkNewDevice) + +class RustLinkMobileHandler( + private val inner: GrantLoginWithQrCodeHandler, + private val sessionCoroutineScope: CoroutineScope, + private val sessionDispatcher: CoroutineDispatcher, +) : LinkMobileHandler { + private val _linkMobileStep = MutableStateFlow(LinkMobileStep.Uninitialized) + override val linkMobileStep: Flow = _linkMobileStep.asStateFlow() + + override suspend fun start() = withContext(sessionDispatcher) { + Timber.tag(tag.value).d("Emit Uninitialized") + _linkMobileStep.emit(LinkMobileStep.Uninitialized) + try { + inner.generate( + progressListener = object : GrantGeneratedQrLoginProgressListener { + override fun onUpdate(state: GrantGeneratedQrLoginProgress) { + sessionCoroutineScope.launch { + val mappedState = state.map() + Timber.tag(tag.value).d("Emit ${mappedState::class.java.simpleName}") + _linkMobileStep.emit(mappedState) + } + } + } + ) + } catch (e: HumanQrGrantLoginException) { + Timber.tag(tag.value).w(e, "Error during QR login grant") + _linkMobileStep.emit(LinkMobileStep.Error(e.map())) + } + } + + private fun GrantGeneratedQrLoginProgress.map(): LinkMobileStep { + return when (this) { + GrantGeneratedQrLoginProgress.Done -> LinkMobileStep.Done + is GrantGeneratedQrLoginProgress.QrReady -> { + LinkMobileStep.QrReady(String(qrCode.toBytes(), Charsets.ISO_8859_1)) + } + is GrantGeneratedQrLoginProgress.QrScanned -> LinkMobileStep.QrScanned( + RustCheckCodeSender( + inner = checkCodeSender, + sessionDispatcher = sessionDispatcher, + ) + ) + GrantGeneratedQrLoginProgress.Starting -> LinkMobileStep.Starting + GrantGeneratedQrLoginProgress.SyncingSecrets -> LinkMobileStep.SyncingSecrets + is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth(verificationUri) + } + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 38d4d1aefe..1e66c838c4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -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.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaPreviewService import io.element.android.libraries.matrix.api.notification.NotificationService @@ -95,6 +97,9 @@ class FakeMatrixClient( private val deactivateAccountResult: (String, Boolean) -> Result = { _, _ -> lambdaError() }, private val currentSlidingSyncVersionLambda: () -> Result = { lambdaError() }, private val ignoreUserResult: (UserId) -> Result = { lambdaError() }, + private val canLinkNewDeviceResult: () -> Result = { lambdaError() }, + private val createLinkMobileHandlerResult: () -> Result = { lambdaError() }, + private val createLinkDesktopHandlerResult: () -> Result = { lambdaError() }, private var unIgnoreUserResult: (UserId) -> Result = { Result.success(Unit) }, private val canReportRoomLambda: () -> Boolean = { false }, private val isLivekitRtcSupportedLambda: () -> Boolean = { false }, @@ -356,4 +361,16 @@ class FakeMatrixClient( override suspend fun performDatabaseVacuum(): Result { return performDatabaseVacuumLambda() } + + override suspend fun canLinkNewDevice(): Result = simulateLongTask { + return canLinkNewDeviceResult() + } + + override fun createLinkDesktopHandler(): Result { + return createLinkDesktopHandlerResult() + } + + override fun createLinkMobileHandler(): Result { + return createLinkMobileHandlerResult() + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index dbbf2f2fae..dcacadd975 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -100,3 +100,31 @@ const val A_LOGIN_HINT = "mxid:@alice:example.org" @ColorInt const val A_COLOR_INT: Int = 0xFFFF0000.toInt() + +// From https://github.com/matrix-org/matrix-rust-sdk/blob/3a63838cdb50cde3d74da920186fbae0a2e6db37/crates/matrix-sdk-crypto/src/types/qr_login.rs#L275 +// Test vector for the QR code data, copied from the MSC. +@Suppress("ktlint:standard:argument-list-wrapping") +val QR_CODE_DATA = listOf( + 0x4D, 0x41, 0x54, 0x52, 0x49, 0x58, 0x02, 0x03, 0xd8, 0x86, 0x68, 0x6a, 0xb2, 0x19, 0x7b, + 0x78, 0x0e, 0x30, 0x0a, 0x9d, 0x4a, 0x21, 0x47, 0x48, 0x07, 0x00, 0xd7, 0x92, 0x9f, 0x39, + 0xab, 0x31, 0xb9, 0xe5, 0x14, 0x37, 0x02, 0x48, 0xed, 0x6b, 0x00, 0x47, 0x68, 0x74, 0x74, + 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x72, 0x65, 0x6e, 0x64, 0x65, 0x7a, 0x76, 0x6f, 0x75, 0x73, + 0x2e, 0x6c, 0x61, 0x62, 0x2e, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x64, 0x65, + 0x76, 0x2f, 0x65, 0x38, 0x64, 0x61, 0x36, 0x33, 0x35, 0x35, 0x2d, 0x35, 0x35, 0x30, 0x62, + 0x2d, 0x34, 0x61, 0x33, 0x32, 0x2d, 0x61, 0x31, 0x39, 0x33, 0x2d, 0x31, 0x36, 0x31, 0x39, + 0x64, 0x39, 0x38, 0x33, 0x30, 0x36, 0x36, 0x38, +).map { it.toByte() }.toByteArray() + +// Test vector for the QR code data, copied from the MSC, with the mode set to reciprocate. +@Suppress("ktlint:standard:argument-list-wrapping") +val QR_CODE_DATA_RECIPROCATE = listOf( + 0x4D, 0x41, 0x54, 0x52, 0x49, 0x58, 0x02, 0x04, 0xd8, 0x86, 0x68, 0x6a, 0xb2, 0x19, 0x7b, + 0x78, 0x0e, 0x30, 0x0a, 0x9d, 0x4a, 0x21, 0x47, 0x48, 0x07, 0x00, 0xd7, 0x92, 0x9f, 0x39, + 0xab, 0x31, 0xb9, 0xe5, 0x14, 0x37, 0x02, 0x48, 0xed, 0x6b, 0x00, 0x47, 0x68, 0x74, 0x74, + 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x72, 0x65, 0x6e, 0x64, 0x65, 0x7a, 0x76, 0x6f, 0x75, 0x73, + 0x2e, 0x6c, 0x61, 0x62, 0x2e, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x64, 0x65, + 0x76, 0x2f, 0x65, 0x38, 0x64, 0x61, 0x36, 0x33, 0x35, 0x35, 0x2d, 0x35, 0x35, 0x30, 0x62, + 0x2d, 0x34, 0x61, 0x33, 0x32, 0x2d, 0x61, 0x31, 0x39, 0x33, 0x2d, 0x31, 0x36, 0x31, 0x39, + 0x64, 0x39, 0x38, 0x33, 0x30, 0x36, 0x36, 0x38, 0x00, 0x0A, 0x6d, 0x61, 0x74, 0x72, 0x69, + 0x78, 0x2e, 0x6f, 0x72, 0x67, +).map { it.toByte() }.toByteArray() diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeCheckCodeSender.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeCheckCodeSender.kt new file mode 100644 index 0000000000..a0692b6f5c --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeCheckCodeSender.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.linknewdevice + +import io.element.android.libraries.matrix.api.linknewdevice.CheckCodeSender +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask + +class FakeCheckCodeSender( + private val validateResult: (UByte) -> Boolean = { lambdaError() }, + private val sendResult: (UByte) -> Result = { lambdaError() }, +) : CheckCodeSender { + override suspend fun validate(code: UByte): Boolean = simulateLongTask { + validateResult(code) + } + + override suspend fun send(code: UByte): Result = simulateLongTask { + sendResult(code) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkDesktopHandler.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkDesktopHandler.kt new file mode 100644 index 0000000000..0bf9dafd01 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkDesktopHandler.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.linknewdevice + +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeLinkDesktopHandler( + private val handleScannedQrCodeResult: (ByteArray) -> Unit = { lambdaError() }, +) : LinkDesktopHandler { + private val mutableLinkDesktopStep: MutableStateFlow = MutableStateFlow(LinkDesktopStep.Uninitialized) + override val linkDesktopStep: StateFlow + get() = mutableLinkDesktopStep.asStateFlow() + + override suspend fun handleScannedQrCode(data: ByteArray) { + handleScannedQrCodeResult(data) + } + + suspend fun emitStep(step: LinkDesktopStep) { + mutableLinkDesktopStep.emit(step) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkMobileHandler.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkMobileHandler.kt new file mode 100644 index 0000000000..de704ea2dd --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkMobileHandler.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.linknewdevice + +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeLinkMobileHandler( + private val startResult: () -> Unit = { lambdaError() }, +) : LinkMobileHandler { + private val mutableLinkMobileStep: MutableStateFlow = MutableStateFlow(LinkMobileStep.Uninitialized) + override val linkMobileStep: StateFlow + get() = mutableLinkMobileStep.asStateFlow() + + override suspend fun start() = simulateLongTask { + startResult() + } + + suspend fun emitStep(step: LinkMobileStep) { + mutableLinkMobileStep.emit(step) + } +} diff --git a/libraries/qrcode/build.gradle.kts b/libraries/qrcode/build.gradle.kts index cbf4c2de78..cf76e117c0 100644 --- a/libraries/qrcode/build.gradle.kts +++ b/libraries/qrcode/build.gradle.kts @@ -19,4 +19,5 @@ dependencies { implementation(libs.androidx.camera.view) implementation(libs.androidx.camera.camera2) implementation(libs.zxing.cpp) + implementation(libs.google.zxing) } diff --git a/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt index 7f089818d0..18bd1cff10 100644 --- a/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt +++ b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LocalLifecycleOwner @@ -117,7 +118,13 @@ fun QrCodeCameraView( .background(color = ElementTheme.colors.bgSubtlePrimary), contentAlignment = Alignment.Center, ) { - Text("CameraView") + Text( + text = buildString { + append("CameraView\n") + append(if (isScanning) "scanning" else "frozen") + }, + textAlign = TextAlign.Center, + ) } } else { AndroidView( diff --git a/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeImage.kt b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeImage.kt new file mode 100644 index 0000000000..e045e42f17 --- /dev/null +++ b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeImage.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.qrcode + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntSize +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter +import io.element.android.libraries.designsystem.modifiers.squareSize +import io.element.android.libraries.designsystem.utils.ForceMaxBrightness + +private fun String.toBitMatrix(size: Int): BitMatrix { + return QRCodeWriter().encode( + this, + BarcodeFormat.QR_CODE, + size, + size, + ) +} + +private fun BitMatrix.toBitmap( + @ColorInt backgroundColor: Int = Color.WHITE, + @ColorInt foregroundColor: Int = Color.BLACK, +): Bitmap { + val colorBuffer = IntArray(width * height) + var rowOffset = 0 + for (y in 0 until height) { + for (x in 0 until width) { + val arrayIndex = x + rowOffset + colorBuffer[arrayIndex] = if (get(x, y)) foregroundColor else backgroundColor + } + rowOffset += width + } + return Bitmap.createBitmap(colorBuffer, width, height, Bitmap.Config.ARGB_8888) +} + +@Composable +fun QrCodeImage( + data: String, + forceMaxBrightness: Boolean = true, + modifier: Modifier = Modifier, +) { + if (forceMaxBrightness) { + ForceMaxBrightness() + } + var size by remember { mutableStateOf(IntSize.Zero) } + Box( + modifier = modifier + .squareSize() + .onSizeChanged { + size = it + }, + ) { + val image = remember(data, size) { + val sideSide = maxOf(size.width, size.height).coerceAtLeast(128) + data.toBitMatrix(sideSide).toBitmap().asImageBitmap() + } + Image( + contentDescription = null, + bitmap = image, + ) + } +} + +@Composable +@Preview +internal fun QrCodeViewPreview() { + QrCodeImage( + modifier = Modifier.fillMaxHeight(), + data = "RANDOM_QRCODE_DATA", + ) +} diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 58fbc36794..8c15fb354a 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -434,32 +434,6 @@ Are you sure you want to continue?" "Options" "Remove %1$s" "Settings" - "Scan the QR code" - "Open %1$s on a laptop or desktop computer" - "Scan the QR code with this device" - "Ready to scan" - "Open %1$s on a desktop computer to get the QR code" - "The numbers don’t match" - "Enter 2-digit code" - "This will verify that the connection to your other device is secure." - "Enter the number shown on your other device" - "Your account provider does not support %1$s." - "%1$s not supported" - "Your account provider doesn’t support signing into a new device with a QR code." - "QR code not supported" - "The sign in was cancelled on the other device." - "Sign in request cancelled" - "Sign in expired. Please try again." - "The sign in was not completed in time" - "Open %1$s on the other device" - "Select %1$s" - "“Sign in with QR code”" - "Scan the QR code shown here with the other device" - "Open %1$s on the other device" - "Desktop computer" - "Loading QR code…" - "Mobile device" - "What type of device do you want to link?" "Spaces where members can join the room without an invitation." "Manage spaces" "(Unknown space)" diff --git a/tools/localazy/config.json b/tools/localazy/config.json index f7463a6396..7afda5e5ad 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -155,6 +155,17 @@ "troubleshoot_notifications_test_unified_push_.*" ] }, + { + "name" : ":features:linknewdevice:impl", + "includeRegex" : [ + "screen\\.link_new_device\\..*", + "screen_qr_code_login_error_.*", + "screen_qr_code_login_connection_note_secure_state.*", + "screen_qr_code_login_unknown_error_description", + "screen_qr_code_login_invalid_scan_state_.*", + "screen_qr_code_login_no_camera_permission_state_.*" + ] + }, { "name" : ":features:login:impl", "includeRegex" : [