From c59b96de667b114181de316acdd6d7c2f534fde1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 19 May 2026 09:00:11 +0200 Subject: [PATCH 01/10] [Link new device] Automatically rotate the QR Code --- .../impl/LinkNewDeviceFlowNode.kt | 10 +++- .../impl/LinkNewMobileHandler.kt | 4 ++ .../impl/screens/qrcode/ShowQrCodeNode.kt | 7 ++- .../screens/qrcode/ShowQrCodePresenter.kt | 58 +++++++++++++++++++ .../impl/screens/qrcode/ShowQrCodeState.kt | 14 +++++ .../screens/qrcode/ShowQrCodeStateProvider.kt | 27 +++++++++ .../impl/screens/qrcode/ShowQrCodeView.kt | 34 ++++++++--- .../api/linknewdevice/LinkMobileHandler.kt | 1 + .../linknewdevice/RustLinkMobileHandler.kt | 9 ++- 9 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeState.kt create mode 100644 features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt 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 index e90d318267b..61645ead9db 100644 --- 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 @@ -144,8 +144,14 @@ class LinkNewDeviceFlowNode( navigateToError(linkMobileStep.errorType) } is LinkMobileStep.QrReady -> { - // The QrCode is ready, navigate to its display - backstack.push(NavTarget.MobileShowQrCode(linkMobileStep.data)) + // The QrCode is ready, navigate to its display, if not already there + val navTarget = backstack.elements.value.last().key.navTarget + if (navTarget !is NavTarget.MobileShowQrCode) { + backstack.push(NavTarget.MobileShowQrCode(linkMobileStep.data)) + } + } + LinkMobileStep.QrRotating -> { + // This step is handled in ShowQrCodePresenter } is LinkMobileStep.QrScanned -> { backstack.replace(NavTarget.MobileEnterNumber) 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 index 157d946eaa3..12cf3af3b9e 100644 --- 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 @@ -65,4 +65,8 @@ class LinkNewMobileHandler( linkMobileStepFlow.emit(LinkMobileStep.Uninitialized) } } + + fun rotateQrCode() { + createAndStartNewHandler() + } } 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 index a884c3e97f5..20bd50f4881 100644 --- 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 @@ -25,6 +25,7 @@ import io.element.android.libraries.di.SessionScope class ShowQrCodeNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, + showQrCodePresenterFactory: ShowQrCodePresenter.Factory, ) : Node(buildContext, plugins = plugins) { class Inputs( val data: String, @@ -36,11 +37,15 @@ class ShowQrCodeNode( private val inputs: Inputs = inputs() private val callback: Callback = callback() + private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create( + initialData = inputs.data, + ) @Composable override fun View(modifier: Modifier) { + val state = showQrCodePresenter.present() ShowQrCodeView( - data = inputs.data, + state = state, modifier = modifier, onBackClick = callback::navigateBack, ) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt new file mode 100644 index 00000000000..1688a6469d9 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.qrcode + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +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.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep +import io.element.android.libraries.matrix.api.logs.LoggerTags +import timber.log.Timber + +private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice) + +@AssistedInject +class ShowQrCodePresenter( + @Assisted private val initialData: String, + private val linkNewMobileHandler: LinkNewMobileHandler, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(initialData: String): ShowQrCodePresenter + } + + @Composable + override fun present(): ShowQrCodeState { + val data by produceState>(AsyncData.Success(initialData)) { + linkNewMobileHandler.stepFlow.collect { step -> + when (step) { + is LinkMobileStep.QrReady -> { + value = AsyncData.Success(step.data) + } + is LinkMobileStep.QrRotating -> { + Timber.tag(tag.value).d("Rotating QrCode") + linkNewMobileHandler.rotateQrCode() + value = AsyncData.Loading() + } + else -> Unit + } + } + } + + return ShowQrCodeState( + data = data, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeState.kt new file mode 100644 index 00000000000..e69dde82648 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeState.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.qrcode + +import io.element.android.libraries.architecture.AsyncData + +data class ShowQrCodeState( + val data: AsyncData, +) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt new file mode 100644 index 00000000000..0c987bc5e74 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.linknewdevice.impl.screens.qrcode + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData + +class ShowQrCodeStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aShowQrCodeState(), + ShowQrCodeState( + data = AsyncData.Loading(), + ), + ) +} + +private fun aShowQrCodeState( + data: AsyncData.Success = AsyncData.Success("DATA"), +) = ShowQrCodeState( + data = data, +) 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 index 501415f621c..cc55ed374dc 100644 --- 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 @@ -9,6 +9,7 @@ package io.element.android.features.linknewdevice.impl.screens.qrcode +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -21,6 +22,7 @@ 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.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 @@ -30,6 +32,7 @@ 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.CircularProgressIndicator import io.element.android.libraries.designsystem.utils.annotatedTextWithBold import io.element.android.libraries.qrcode.QrCodeImage import kotlinx.collections.immutable.persistentListOf @@ -40,7 +43,7 @@ import kotlinx.collections.immutable.persistentListOf */ @Composable fun ShowQrCodeView( - data: String, + state: ShowQrCodeState, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -55,11 +58,24 @@ fun ShowQrCodeView( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - QrCodeImage( - data = data, - modifier = Modifier - .size(220.dp) - ) + when (val str = state.data.dataOrNull()) { + null -> { + Box( + modifier = Modifier + .size(220.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + else -> { + QrCodeImage( + data = str, + modifier = Modifier + .size(220.dp) + ) + } + } Spacer(modifier = Modifier.height(32.dp)) NumberedListOrganism( modifier = Modifier.fillMaxSize(), @@ -81,9 +97,11 @@ fun ShowQrCodeView( @PreviewsDayNight @Composable -internal fun ShowQrCodeViewPreview() = ElementPreview { +internal fun ShowQrCodeViewPreview( + @PreviewParameter(ShowQrCodeStateProvider::class) state: ShowQrCodeState, +) = ElementPreview { ShowQrCodeView( - data = "DATA", + state = state, onBackClick = { }, ) } 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 index 0c261cdd1aa..1947729c7f5 100644 --- 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 @@ -18,6 +18,7 @@ sealed interface LinkMobileStep { data object Uninitialized : LinkMobileStep data object Starting : LinkMobileStep data class QrReady(val data: String) : LinkMobileStep + data object QrRotating : LinkMobileStep data class WaitingForAuth(val verificationUri: String) : LinkMobileStep data class QrScanned(val checkCodeSender: CheckCodeSender) : LinkMobileStep data class Error(val errorType: ErrorType) : LinkMobileStep 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 index 6d212d47840..77ae28acea1 100644 --- 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 @@ -53,7 +53,14 @@ class RustLinkMobileHandler( _linkMobileStep.emit(LinkMobileStep.Done) } catch (e: HumanQrGrantLoginException) { Timber.tag(tag.value).w(e, "Error during QR login grant") - _linkMobileStep.emit(LinkMobileStep.Error(e.map())) + // Catch timeout here? + if (_linkMobileStep.value is LinkMobileStep.QrReady + && e is HumanQrGrantLoginException.NotFound) { + Timber.tag(tag.value).d("Emit QrRotating due to HumanQrGrantLoginException.NotFound") + _linkMobileStep.emit(LinkMobileStep.QrRotating) + } else { + _linkMobileStep.emit(LinkMobileStep.Error(e.map())) + } } } From a6f7c05458c7a0777e6c8c695f719188babc952c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 19 May 2026 09:24:21 +0200 Subject: [PATCH 02/10] [Link new device] Automatically rotate the QR Code - limit number of rotation to 10. --- .../impl/LinkNewMobileHandler.kt | 8 ++++++++ .../screens/qrcode/ShowQrCodePresenter.kt | 19 ++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) 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 index 12cf3af3b9e..18d67f577a7 100644 --- 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 @@ -12,6 +12,7 @@ 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.ErrorType 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 @@ -69,4 +70,11 @@ class LinkNewMobileHandler( fun rotateQrCode() { createAndStartNewHandler() } + + fun onTooManyRotation() { + reset() + sessionScope.launch { + linkMobileStepFlow.emit(LinkMobileStep.Error(ErrorType.Expired("Too many QR code rotations"))) + } + } } diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt index 1688a6469d9..3deff8c6030 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt @@ -9,7 +9,10 @@ package io.element.android.features.linknewdevice.impl.screens.qrcode import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -35,6 +38,7 @@ class ShowQrCodePresenter( @Composable override fun present(): ShowQrCodeState { + var qrCodeRotationCounter by remember { mutableIntStateOf(MAX_QR_CODE_ROTATION) } val data by produceState>(AsyncData.Success(initialData)) { linkNewMobileHandler.stepFlow.collect { step -> when (step) { @@ -42,9 +46,14 @@ class ShowQrCodePresenter( value = AsyncData.Success(step.data) } is LinkMobileStep.QrRotating -> { - Timber.tag(tag.value).d("Rotating QrCode") - linkNewMobileHandler.rotateQrCode() - value = AsyncData.Loading() + if (qrCodeRotationCounter-- > 0) { + Timber.tag(tag.value).d("Rotating QrCode") + linkNewMobileHandler.rotateQrCode() + value = AsyncData.Loading() + } else { + Timber.tag(tag.value).w("Max QR code rotation reached, not rotating anymore") + linkNewMobileHandler.onTooManyRotation() + } } else -> Unit } @@ -55,4 +64,8 @@ class ShowQrCodePresenter( data = data, ) } + + companion object { + const val MAX_QR_CODE_ROTATION = 10 + } } From 18bc6a27c48622dfcbdac8fd077f3fb90f4e5af1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 19 May 2026 10:43:47 +0200 Subject: [PATCH 03/10] [Link new device] Improve UI transition between QRCodes. --- .../screens/qrcode/ShowQrCodePresenter.kt | 47 +++++++++++++-- .../impl/screens/qrcode/ShowQrCodeState.kt | 4 +- .../screens/qrcode/ShowQrCodeStateProvider.kt | 17 ++++-- .../impl/screens/qrcode/ShowQrCodeView.kt | 59 +++++++++++++------ 4 files changed, 99 insertions(+), 28 deletions(-) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt index 3deff8c6030..6a7e7cfcf55 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt @@ -22,6 +22,9 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.log.logger.LoggerTag 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.delay +import kotlinx.coroutines.launch import timber.log.Timber private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice) @@ -36,20 +39,54 @@ class ShowQrCodePresenter( fun create(initialData: String): ShowQrCodePresenter } + private var loadingJob: Job? = null + @Composable override fun present(): ShowQrCodeState { var qrCodeRotationCounter by remember { mutableIntStateOf(MAX_QR_CODE_ROTATION) } - val data by produceState>(AsyncData.Success(initialData)) { + val state by produceState( + initialValue = ShowQrCodeState( + data1 = AsyncData.Success(initialData), + data2 = AsyncData.Uninitialized, + dataToRender = 1, + ) + ) { linkNewMobileHandler.stepFlow.collect { step -> + val currentValue = value when (step) { is LinkMobileStep.QrReady -> { - value = AsyncData.Success(step.data) + loadingJob?.cancel() + if (currentValue.dataToRender == 1) { + value = currentValue.copy( + data2 = AsyncData.Success(step.data), + dataToRender = 2, + ) + } else { + value = currentValue.copy( + data1 = AsyncData.Success(step.data), + dataToRender = 1, + ) + } } is LinkMobileStep.QrRotating -> { if (qrCodeRotationCounter-- > 0) { Timber.tag(tag.value).d("Rotating QrCode") linkNewMobileHandler.rotateQrCode() - value = AsyncData.Loading() + // Ensure that outdated data is not rendered too long while rotating QR code + loadingJob = launch { + delay(1000) + if (currentValue.dataToRender == 1) { + value = currentValue.copy( + data2 = AsyncData.Loading(), + dataToRender = 2, + ) + } else { + value = currentValue.copy( + data1 = AsyncData.Loading(), + dataToRender = 1, + ) + } + } } else { Timber.tag(tag.value).w("Max QR code rotation reached, not rotating anymore") linkNewMobileHandler.onTooManyRotation() @@ -60,9 +97,7 @@ class ShowQrCodePresenter( } } - return ShowQrCodeState( - data = data, - ) + return state } companion object { diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeState.kt index e69dde82648..76302b71aa7 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeState.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeState.kt @@ -10,5 +10,7 @@ package io.element.android.features.linknewdevice.impl.screens.qrcode import io.element.android.libraries.architecture.AsyncData data class ShowQrCodeState( - val data: AsyncData, + val data1: AsyncData, + val data2: AsyncData, + val dataToRender: Int, ) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt index 0c987bc5e74..dc7c12b847c 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt @@ -14,14 +14,23 @@ class ShowQrCodeStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aShowQrCodeState(), - ShowQrCodeState( - data = AsyncData.Loading(), + aShowQrCodeState( + data1 = AsyncData.Loading(), + ), + aShowQrCodeState( + data1 = AsyncData.Success("DATA"), + data2 = AsyncData.Success("DATA2"), + dataToRender = 2, ), ) } private fun aShowQrCodeState( - data: AsyncData.Success = AsyncData.Success("DATA"), + data1: AsyncData = AsyncData.Success("DATA"), + data2: AsyncData = AsyncData.Uninitialized, + dataToRender: Int = 1, ) = ShowQrCodeState( - data = data, + data1 = data1, + data2 = data2, + dataToRender = dataToRender, ) 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 index cc55ed374dc..07918472881 100644 --- 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 @@ -9,6 +9,9 @@ package io.element.android.features.linknewdevice.impl.screens.qrcode +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -58,23 +61,15 @@ fun ShowQrCodeView( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - when (val str = state.data.dataOrNull()) { - null -> { - Box( - modifier = Modifier - .size(220.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - else -> { - QrCodeImage( - data = str, - modifier = Modifier - .size(220.dp) - ) - } + Box { + QrCodeOrLoading( + isVisible = state.dataToRender == 1, + data = state.data1.dataOrNull(), + ) + QrCodeOrLoading( + isVisible = state.dataToRender == 2, + data = state.data2.dataOrNull(), + ) } Spacer(modifier = Modifier.height(32.dp)) NumberedListOrganism( @@ -95,6 +90,36 @@ fun ShowQrCodeView( } } +@Composable +private fun QrCodeOrLoading( + isVisible: Boolean, + data: String?, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + modifier = modifier, + visible = isVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + if (data == null) { + Box( + modifier = Modifier + .size(220.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + QrCodeImage( + data = data, + modifier = Modifier + .size(220.dp) + ) + } + } +} + @PreviewsDayNight @Composable internal fun ShowQrCodeViewPreview( From 75feaa7ee7ebb1291f81d68e53d7ffec93017b01 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 19 May 2026 10:59:39 +0200 Subject: [PATCH 04/10] [Link new device] Fix and add tests. --- .../screens/qrcode/ShowQrCodeStateProvider.kt | 2 +- .../screens/qrcode/ShowQrCodePresenterTest.kt | 105 ++++++++++++++++++ .../impl/screens/qrcode/ShowQrCodeViewTest.kt | 2 +- 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt index dc7c12b847c..6079d0918cb 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt @@ -25,7 +25,7 @@ class ShowQrCodeStateProvider : PreviewParameterProvider { ) } -private fun aShowQrCodeState( +internal fun aShowQrCodeState( data1: AsyncData = AsyncData.Success("DATA"), data2: AsyncData = AsyncData.Uninitialized, dataToRender: Int = 1, diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt new file mode 100644 index 00000000000..8ecc0ea008b --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.linknewdevice.impl.screens.qrcode + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkMobileHandler +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +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 ShowQrCodePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + createPresenter().test { + val initialState = awaitItem() + assertThat(initialState.data1.dataOrNull()).isEqualTo("DATA") + assertThat(initialState.data2.isUninitialized()).isTrue() + assertThat(initialState.dataToRender).isEqualTo(1) + } + } + + @Test + fun `present - when handler emits QrRotating, the presenter requests to rotate the QrCode`() = runTest { + val linkMobileHandler = FakeLinkMobileHandler( + startResult = {}, + ) + val createLinkMobileHandlerResult = lambdaRecorder> { + Result.success(linkMobileHandler) + } + val matrixClient = FakeMatrixClient( + sessionCoroutineScope = backgroundScope, + createLinkMobileHandlerResult = createLinkMobileHandlerResult, + ) + val linkNewMobileHandler = LinkNewMobileHandler(matrixClient) + linkNewMobileHandler.createAndStartNewHandler() + createPresenter( + linkNewMobileHandler = linkNewMobileHandler, + ).test { + awaitItem() + linkMobileHandler.emitStep( + LinkMobileStep.QrRotating + ) + runCurrent() + val finalState = awaitItem() + assertThat(finalState.data2.isLoading()).isTrue() + assertThat(finalState.dataToRender).isEqualTo(2) + createLinkMobileHandlerResult.assertions().isCalledExactly(2) + } + } + + @Test + fun `present - when handler emits QrRotating, the presenter requests to rotate the QrCode and the code is rotated`() = runTest { + val linkMobileHandler = FakeLinkMobileHandler( + startResult = {}, + ) + val matrixClient = FakeMatrixClient( + sessionCoroutineScope = backgroundScope, + createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }, + ) + val linkNewMobileHandler = LinkNewMobileHandler(matrixClient) + linkNewMobileHandler.createAndStartNewHandler() + createPresenter( + linkNewMobileHandler = linkNewMobileHandler, + ).test { + awaitItem() + linkMobileHandler.emitStep( + LinkMobileStep.QrRotating + ) + runCurrent() + linkMobileHandler.emitStep( + LinkMobileStep.QrReady("DATA2") + ) + val finalState = awaitItem() + assertThat(finalState.data1.dataOrNull()).isEqualTo("DATA") + assertThat(finalState.data2.dataOrNull()).isEqualTo("DATA2") + assertThat(finalState.dataToRender).isEqualTo(2) + } + } + + private fun createPresenter( + linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()), + ) = ShowQrCodePresenter( + initialData = "DATA", + linkNewMobileHandler = linkNewMobileHandler, + ) +} 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 index d552c2bff68..7927eeed773 100644 --- 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 @@ -37,7 +37,7 @@ class ShowQrCodeViewTest { ) { setContent { ShowQrCodeView( - data = "DATA", + state = aShowQrCodeState(), onBackClick = onBackClick, ) } From 33c7c7555035392571c8902df75de7bf3ad732bc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 19 May 2026 14:24:02 +0200 Subject: [PATCH 05/10] Fix detekt issue --- .../matrix/impl/linknewdevice/RustLinkMobileHandler.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 index 77ae28acea1..cb387a9d21d 100644 --- 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 @@ -51,16 +51,18 @@ class RustLinkMobileHandler( ) // We emit Done in case the progress listener was deallocated before generate() sent the Done _linkMobileStep.emit(LinkMobileStep.Done) - } catch (e: HumanQrGrantLoginException) { + } catch (e: HumanQrGrantLoginException.NotFound) { Timber.tag(tag.value).w(e, "Error during QR login grant") // Catch timeout here? - if (_linkMobileStep.value is LinkMobileStep.QrReady - && e is HumanQrGrantLoginException.NotFound) { + if (_linkMobileStep.value is LinkMobileStep.QrReady) { Timber.tag(tag.value).d("Emit QrRotating due to HumanQrGrantLoginException.NotFound") _linkMobileStep.emit(LinkMobileStep.QrRotating) } else { _linkMobileStep.emit(LinkMobileStep.Error(e.map())) } + } catch (e: HumanQrGrantLoginException) { + Timber.tag(tag.value).w(e, "Error during QR login grant") + _linkMobileStep.emit(LinkMobileStep.Error(e.map())) } } From 1473f5e806447ca96d62b9761a42654a4abb6497 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 19 May 2026 14:41:47 +0200 Subject: [PATCH 06/10] [Link new device] and add tests. --- .../RustLinkMobileHandlerTest.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt index 9f71cb71690..ad751cc86b0 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt @@ -118,6 +118,35 @@ class RustLinkMobileHandlerTest { } } + @Test + fun `when start throws HumanQrGrantLoginException_NotFound when in state QrReady, the handler emits QrRotating step`() = runTest { + val completable = CompletableDeferred() + val handler = FakeFfiGrantLoginWithQrCodeHandler( + generateResult = { + completable.await() + throw HumanQrGrantLoginException.NotFound("Timeout") + } + ) + val sut = createRustLinkMobileHandler( + handler, + ) + sut.linkMobileStep.test { + val initialItem = awaitItem() + assertThat(initialItem).isEqualTo(LinkMobileStep.Uninitialized) + backgroundScope.launch { + sut.start() + } + runCurrent() + handler.emitGenerateProgress(GrantGeneratedQrLoginProgress.QrReady(FakeFfiQrCodeData(toBytesResult = { QR_CODE_DATA_RECIPROCATE }))) + val readyState = awaitItem() + assertThat(readyState).isInstanceOf(LinkMobileStep.QrReady::class.java) + // generate returns, error is emitted + completable.complete(Unit) + val qrRotatingState = awaitItem() + assertThat(qrRotatingState).isEqualTo(LinkMobileStep.QrRotating) + } + } + private fun TestScope.createRustLinkMobileHandler( handler: FakeFfiGrantLoginWithQrCodeHandler = FakeFfiGrantLoginWithQrCodeHandler(), ) = RustLinkMobileHandler( From 900b888044371a0f2bd53ab6a5aa4f08b1135ab7 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 19 May 2026 13:27:57 +0000 Subject: [PATCH 07/10] Update screenshots --- ...nknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_1_en.png | 3 +++ ...nknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_2_en.png | 3 +++ ...newdevice.impl.screens.qrcode_ShowQrCodeView_Night_1_en.png | 3 +++ ...newdevice.impl.screens.qrcode_ShowQrCodeView_Night_2_en.png | 3 +++ 4 files changed, 12 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_2_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_1_en.png new file mode 100644 index 00000000000..d2ab2dcb1e1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48cafd6b98791b64e4cc6a16c602f17e3a886659698c3c93bc4acaf875337e0f +size 31102 diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_2_en.png new file mode 100644 index 00000000000..2f06e298636 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:151eaa4b5619afd76b94de518dc2868d735b6b7a167056941e1f429520c3bf0d +size 31836 diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_1_en.png new file mode 100644 index 00000000000..c4593656d14 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f6f8d1282ae47a5240aec2b2c63a137b8e3e25ec8b746cf2d82d0fa7e0c0a34 +size 30287 diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_2_en.png new file mode 100644 index 00000000000..99f134760d1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa2303b621070608ba79673322f61bfa686b9729eb445913dd8d059d62c9764a +size 32374 From 5dd897dae81adfdf55601c7b16541180b89c9106 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 19 May 2026 19:00:58 +0200 Subject: [PATCH 08/10] Use AnimatedContent instead of reinventing the wheel. --- .../screens/qrcode/ShowQrCodePresenter.kt | 33 +++--------- .../impl/screens/qrcode/ShowQrCodeState.kt | 4 +- .../screens/qrcode/ShowQrCodeStateProvider.kt | 15 ++---- .../impl/screens/qrcode/ShowQrCodeView.kt | 52 ++++++++----------- .../screens/qrcode/ShowQrCodePresenterTest.kt | 11 ++-- ...screens.qrcode_ShowQrCodeView_Day_2_en.png | 3 -- ...reens.qrcode_ShowQrCodeView_Night_2_en.png | 3 -- 7 files changed, 37 insertions(+), 84 deletions(-) delete mode 100644 tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_2_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_2_en.png diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt index 6a7e7cfcf55..21071a6831a 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt @@ -46,27 +46,16 @@ class ShowQrCodePresenter( var qrCodeRotationCounter by remember { mutableIntStateOf(MAX_QR_CODE_ROTATION) } val state by produceState( initialValue = ShowQrCodeState( - data1 = AsyncData.Success(initialData), - data2 = AsyncData.Uninitialized, - dataToRender = 1, + data = AsyncData.Success(initialData), ) ) { linkNewMobileHandler.stepFlow.collect { step -> - val currentValue = value when (step) { is LinkMobileStep.QrReady -> { loadingJob?.cancel() - if (currentValue.dataToRender == 1) { - value = currentValue.copy( - data2 = AsyncData.Success(step.data), - dataToRender = 2, - ) - } else { - value = currentValue.copy( - data1 = AsyncData.Success(step.data), - dataToRender = 1, - ) - } + value = ShowQrCodeState( + data = AsyncData.Success(step.data), + ) } is LinkMobileStep.QrRotating -> { if (qrCodeRotationCounter-- > 0) { @@ -75,17 +64,9 @@ class ShowQrCodePresenter( // Ensure that outdated data is not rendered too long while rotating QR code loadingJob = launch { delay(1000) - if (currentValue.dataToRender == 1) { - value = currentValue.copy( - data2 = AsyncData.Loading(), - dataToRender = 2, - ) - } else { - value = currentValue.copy( - data1 = AsyncData.Loading(), - dataToRender = 1, - ) - } + value = ShowQrCodeState( + data = AsyncData.Loading(), + ) } } else { Timber.tag(tag.value).w("Max QR code rotation reached, not rotating anymore") diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeState.kt index 76302b71aa7..e69dde82648 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeState.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeState.kt @@ -10,7 +10,5 @@ package io.element.android.features.linknewdevice.impl.screens.qrcode import io.element.android.libraries.architecture.AsyncData data class ShowQrCodeState( - val data1: AsyncData, - val data2: AsyncData, - val dataToRender: Int, + val data: AsyncData, ) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt index 6079d0918cb..e6d33c25443 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt @@ -15,22 +15,13 @@ class ShowQrCodeStateProvider : PreviewParameterProvider { get() = sequenceOf( aShowQrCodeState(), aShowQrCodeState( - data1 = AsyncData.Loading(), - ), - aShowQrCodeState( - data1 = AsyncData.Success("DATA"), - data2 = AsyncData.Success("DATA2"), - dataToRender = 2, + data = AsyncData.Loading(), ), ) } internal fun aShowQrCodeState( - data1: AsyncData = AsyncData.Success("DATA"), - data2: AsyncData = AsyncData.Uninitialized, - dataToRender: Int = 1, + data: AsyncData = AsyncData.Success("DATA"), ) = ShowQrCodeState( - data1 = data1, - data2 = data2, - dataToRender = dataToRender, + data = data, ) 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 index 07918472881..ee38342ce28 100644 --- 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 @@ -9,9 +9,11 @@ package io.element.android.features.linknewdevice.impl.screens.qrcode -import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -44,6 +46,7 @@ import kotlinx.collections.immutable.persistentListOf * QrCode display screen: * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23617 */ +@OptIn(ExperimentalAnimationApi::class) @Composable fun ShowQrCodeView( state: ShowQrCodeState, @@ -61,14 +64,15 @@ fun ShowQrCodeView( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Box { + AnimatedContent( + targetState = state.data.dataOrNull(), + transitionSpec = { + fadeIn().togetherWith(fadeOut()) + } + ) { data -> QrCodeOrLoading( - isVisible = state.dataToRender == 1, - data = state.data1.dataOrNull(), - ) - QrCodeOrLoading( - isVisible = state.dataToRender == 2, - data = state.data2.dataOrNull(), + modifier = modifier.size(220.dp), + data = data, ) } Spacer(modifier = Modifier.height(32.dp)) @@ -92,31 +96,21 @@ fun ShowQrCodeView( @Composable private fun QrCodeOrLoading( - isVisible: Boolean, data: String?, modifier: Modifier = Modifier, ) { - AnimatedVisibility( - modifier = modifier, - visible = isVisible, - enter = fadeIn(), - exit = fadeOut(), - ) { - if (data == null) { - Box( - modifier = Modifier - .size(220.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } else { - QrCodeImage( - data = data, - modifier = Modifier - .size(220.dp) - ) + if (data == null) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() } + } else { + QrCodeImage( + modifier = modifier, + data = data, + ) } } diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt index 8ecc0ea008b..f92cf661022 100644 --- a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt @@ -32,9 +32,7 @@ class ShowQrCodePresenterTest { fun `present - initial state`() = runTest { createPresenter().test { val initialState = awaitItem() - assertThat(initialState.data1.dataOrNull()).isEqualTo("DATA") - assertThat(initialState.data2.isUninitialized()).isTrue() - assertThat(initialState.dataToRender).isEqualTo(1) + assertThat(initialState.data.dataOrNull()).isEqualTo("DATA") } } @@ -61,8 +59,7 @@ class ShowQrCodePresenterTest { ) runCurrent() val finalState = awaitItem() - assertThat(finalState.data2.isLoading()).isTrue() - assertThat(finalState.dataToRender).isEqualTo(2) + assertThat(finalState.data.isLoading()).isTrue() createLinkMobileHandlerResult.assertions().isCalledExactly(2) } } @@ -90,9 +87,7 @@ class ShowQrCodePresenterTest { LinkMobileStep.QrReady("DATA2") ) val finalState = awaitItem() - assertThat(finalState.data1.dataOrNull()).isEqualTo("DATA") - assertThat(finalState.data2.dataOrNull()).isEqualTo("DATA2") - assertThat(finalState.dataToRender).isEqualTo(2) + assertThat(finalState.data.dataOrNull()).isEqualTo("DATA2") } } diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_2_en.png deleted file mode 100644 index 2f06e298636..00000000000 --- a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:151eaa4b5619afd76b94de518dc2868d735b6b7a167056941e1f429520c3bf0d -size 31836 diff --git a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_2_en.png deleted file mode 100644 index 99f134760d1..00000000000 --- a/tests/uitests/src/test/snapshots/images/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:aa2303b621070608ba79673322f61bfa686b9729eb445913dd8d059d62c9764a -size 32374 From 19397b6fa7ed85182dd55eefee13dfab456ebbc2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 21 May 2026 09:10:43 +0200 Subject: [PATCH 09/10] Fix warning --- .../kotlin/io/element/android/libraries/qrcode/QrCodeImage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e045e42f17b..b50b202e2ca 100644 --- 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 @@ -57,8 +57,8 @@ private fun BitMatrix.toBitmap( @Composable fun QrCodeImage( data: String, - forceMaxBrightness: Boolean = true, modifier: Modifier = Modifier, + forceMaxBrightness: Boolean = true, ) { if (forceMaxBrightness) { ForceMaxBrightness() From b4f23d9d9e1d994de465d0ec91d506cf9c372917 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 21 May 2026 09:14:18 +0200 Subject: [PATCH 10/10] Fix wrong 'modifier' usage. --- .../linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ee38342ce28..f2cd07f4a50 100644 --- 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 @@ -65,13 +65,13 @@ fun ShowQrCodeView( horizontalAlignment = Alignment.CenterHorizontally, ) { AnimatedContent( + modifier = Modifier.size(220.dp), targetState = state.data.dataOrNull(), transitionSpec = { fadeIn().togetherWith(fadeOut()) } ) { data -> QrCodeOrLoading( - modifier = modifier.size(220.dp), data = data, ) }