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:
commit
4526563668
15 changed files with 346 additions and 13 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 = { },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ class ShowQrCodeViewTest {
|
|||
) {
|
||||
setContent {
|
||||
ShowQrCodeView(
|
||||
data = "DATA",
|
||||
state = aShowQrCodeState(),
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:48cafd6b98791b64e4cc6a16c602f17e3a886659698c3c93bc4acaf875337e0f
|
||||
size 31102
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3f6f8d1282ae47a5240aec2b2c63a137b8e3e25ec8b746cf2d82d0fa7e0c0a34
|
||||
size 30287
|
||||
Loading…
Add table
Add a link
Reference in a new issue