[Link new device] Automatically rotate the QR Code

This commit is contained in:
Benoit Marty 2026-05-19 09:00:11 +02:00 committed by Benoit Marty
parent 5af57906b5
commit c59b96de66
9 changed files with 152 additions and 12 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

@ -65,4 +65,8 @@ class LinkNewMobileHandler(
linkMobileStepFlow.emit(LinkMobileStep.Uninitialized)
}
}
fun rotateQrCode() {
createAndStartNewHandler()
}
}

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,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<ShowQrCodeState> {
@AssistedFactory
interface Factory {
fun create(initialData: String): ShowQrCodePresenter
}
@Composable
override fun present(): ShowQrCodeState {
val data by produceState<AsyncData<String>>(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,
)
}
}

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(),
ShowQrCodeState(
data = AsyncData.Loading(),
),
)
}
private fun aShowQrCodeState(
data: AsyncData.Success<String> = AsyncData.Success("DATA"),
) = ShowQrCodeState(
data = data,
)

View file

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

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

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