Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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>,
)
Comment thread
jmartinesp marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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,
) {
Expand All @@ -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(),
Expand All @@ -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 = { },
)
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
Loading
Loading