Merge pull request #6817 from element-hq/feature/bma/automaticRetry

[Link new device] Rotate QrCode instead of showing an error
This commit is contained in:
Benoit Marty 2026-05-21 10:29:29 +02:00 committed by GitHub
commit 4526563668
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 346 additions and 13 deletions

View file

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

View file

@ -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
@ -65,4 +66,15 @@ class LinkNewMobileHandler(
linkMobileStepFlow.emit(LinkMobileStep.Uninitialized)
}
}
fun rotateQrCode() {
createAndStartNewHandler()
}
fun onTooManyRotation() {
reset()
sessionScope.launch {
linkMobileStepFlow.emit(LinkMobileStep.Error(ErrorType.Expired("Too many QR code rotations")))
}
}
}

View file

@ -25,6 +25,7 @@ import io.element.android.libraries.di.SessionScope
class ShowQrCodeNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
showQrCodePresenterFactory: ShowQrCodePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
class Inputs(
val data: String,
@ -36,11 +37,15 @@ class ShowQrCodeNode(
private val inputs: 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,
)

View file

@ -0,0 +1,87 @@
/*
* 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.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
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 kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice)
@AssistedInject
class ShowQrCodePresenter(
@Assisted private val initialData: String,
private val linkNewMobileHandler: LinkNewMobileHandler,
) : Presenter<ShowQrCodeState> {
@AssistedFactory
interface Factory {
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 state by produceState(
initialValue = ShowQrCodeState(
data = AsyncData.Success(initialData),
)
) {
linkNewMobileHandler.stepFlow.collect { step ->
when (step) {
is LinkMobileStep.QrReady -> {
loadingJob?.cancel()
value = ShowQrCodeState(
data = AsyncData.Success(step.data),
)
}
is LinkMobileStep.QrRotating -> {
if (qrCodeRotationCounter-- > 0) {
Timber.tag(tag.value).d("Rotating QrCode")
linkNewMobileHandler.rotateQrCode()
// Ensure that outdated data is not rendered too long while rotating QR code
loadingJob = launch {
delay(1000)
value = ShowQrCodeState(
data = AsyncData.Loading(),
)
}
} else {
Timber.tag(tag.value).w("Max QR code rotation reached, not rotating anymore")
linkNewMobileHandler.onTooManyRotation()
}
}
else -> Unit
}
}
}
return state
}
companion object {
const val MAX_QR_CODE_ROTATION = 10
}
}

View file

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

View file

@ -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<ShowQrCodeState> {
override val values: Sequence<ShowQrCodeState>
get() = sequenceOf(
aShowQrCodeState(),
aShowQrCodeState(
data = AsyncData.Loading(),
),
)
}
internal fun aShowQrCodeState(
data: AsyncData<String> = AsyncData.Success("DATA"),
) = ShowQrCodeState(
data = data,
)

View file

@ -9,6 +9,12 @@
package io.element.android.features.linknewdevice.impl.screens.qrcode
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
import androidx.compose.foundation.layout.fillMaxSize
@ -21,6 +27,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 +37,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
@ -38,9 +46,10 @@ 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(
data: String,
state: ShowQrCodeState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -55,11 +64,17 @@ fun ShowQrCodeView(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
QrCodeImage(
data = data,
modifier = Modifier
.size(220.dp)
)
AnimatedContent(
modifier = Modifier.size(220.dp),
targetState = state.data.dataOrNull(),
transitionSpec = {
fadeIn().togetherWith(fadeOut())
}
) { data ->
QrCodeOrLoading(
data = data,
)
}
Spacer(modifier = Modifier.height(32.dp))
NumberedListOrganism(
modifier = Modifier.fillMaxSize(),
@ -79,11 +94,33 @@ fun ShowQrCodeView(
}
}
@Composable
private fun QrCodeOrLoading(
data: String?,
modifier: Modifier = Modifier,
) {
if (data == null) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else {
QrCodeImage(
modifier = modifier,
data = data,
)
}
}
@PreviewsDayNight
@Composable
internal fun ShowQrCodeViewPreview() = ElementPreview {
internal fun ShowQrCodeViewPreview(
@PreviewParameter(ShowQrCodeStateProvider::class) state: ShowQrCodeState,
) = ElementPreview {
ShowQrCodeView(
data = "DATA",
state = state,
onBackClick = { },
)
}

View file

@ -0,0 +1,100 @@
/*
* 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.data.dataOrNull()).isEqualTo("DATA")
}
}
@Test
fun `present - when handler emits QrRotating, the presenter requests to rotate the QrCode`() = runTest {
val linkMobileHandler = FakeLinkMobileHandler(
startResult = {},
)
val createLinkMobileHandlerResult = lambdaRecorder<Result<LinkMobileHandler>> {
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.data.isLoading()).isTrue()
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.data.dataOrNull()).isEqualTo("DATA2")
}
}
private fun createPresenter(
linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()),
) = ShowQrCodePresenter(
initialData = "DATA",
linkNewMobileHandler = linkNewMobileHandler,
)
}

View file

@ -37,7 +37,7 @@ class ShowQrCodeViewTest {
) {
setContent {
ShowQrCodeView(
data = "DATA",
state = aShowQrCodeState(),
onBackClick = onBackClick,
)
}

View file

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

View file

@ -51,6 +51,15 @@ class RustLinkMobileHandler(
)
// We emit Done in case the progress listener was deallocated before generate() sent the Done
_linkMobileStep.emit(LinkMobileStep.Done)
} catch (e: HumanQrGrantLoginException.NotFound) {
Timber.tag(tag.value).w(e, "Error during QR login grant")
// Catch timeout here?
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()))

View file

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

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:48cafd6b98791b64e4cc6a16c602f17e3a886659698c3c93bc4acaf875337e0f
size 31102

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f6f8d1282ae47a5240aec2b2c63a137b8e3e25ec8b746cf2d82d0fa7e0c0a34
size 30287