Merge pull request #6719 from element-hq/feature/bma/qrCodeLoginIteration

QR code login iteration
This commit is contained in:
Benoit Marty
2026-08-07 17:31:50 +02:00
committed by GitHub
11 changed files with 156 additions and 17 deletions
@@ -56,6 +56,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
@@ -102,6 +103,7 @@ class LinkNewDeviceFlowNode(
@Suppress("AssignedValueIsNeverRead") @Suppress("AssignedValueIsNeverRead")
linkDesktopHandlerJob = observeLinkNewDesktopHandler() linkDesktopHandlerJob = observeLinkNewDesktopHandler()
}, },
onResume = ::onResume,
onDestroy = { onDestroy = {
linkMobileHandlerJob?.cancel() linkMobileHandlerJob?.cancel()
linkDesktopHandlerJob?.cancel() linkDesktopHandlerJob?.cancel()
@@ -209,6 +211,18 @@ class LinkNewDeviceFlowNode(
.launchIn(sessionCoroutineScope) .launchIn(sessionCoroutineScope)
} }
private fun onResume() = sessionCoroutineScope.launch {
// Application is resumed, if the step is waiting for auth, send the confirmation
(linkNewMobileHandler.stepFlow.value as? LinkMobileStep.WaitingForAuth)?.let {
Timber.tag(tag.value).d("Resuming while waiting for auth on mobile, sending confirmation")
it.continuationMessageSender.confirm()
}
(linkNewDesktopHandler.stepFlow.value as? LinkDesktopStep.WaitingForAuth)?.let {
Timber.tag(tag.value).d("Resuming while waiting for auth on desktop, sending confirmation")
it.continuationMessageSender.confirm()
}
}
private fun navigateToError(errorType: ErrorType) { private fun navigateToError(errorType: ErrorType) {
// Map the error to an error screen // Map the error to an error screen
val error = when (errorType) { val error = when (errorType) {
@@ -0,0 +1,13 @@
/*
* 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.libraries.matrix.api.linknewdevice
interface ContinuationMessageSender {
suspend fun cancel(): Result<Unit>
suspend fun confirm(): Result<Unit>
}
@@ -18,8 +18,10 @@ interface LinkDesktopHandler {
sealed interface LinkDesktopStep { sealed interface LinkDesktopStep {
data object Uninitialized : LinkDesktopStep data object Uninitialized : LinkDesktopStep
data object Starting : LinkDesktopStep data object Starting : LinkDesktopStep
data class WaitingForAuth( data class WaitingForAuth(
val verificationUri: String, val verificationUri: String,
val continuationMessageSender: ContinuationMessageSender,
) : LinkDesktopStep ) : LinkDesktopStep
data class EstablishingSecureChannel( data class EstablishingSecureChannel(
@@ -22,7 +22,12 @@ sealed interface LinkMobileStep {
data object Starting : LinkMobileStep data object Starting : LinkMobileStep
data class QrReady(val data: String) : LinkMobileStep data class QrReady(val data: String) : LinkMobileStep
data object QrRotating : LinkMobileStep data object QrRotating : LinkMobileStep
data class WaitingForAuth(val verificationUri: String) : LinkMobileStep
data class WaitingForAuth(
val verificationUri: String,
val continuationMessageSender: ContinuationMessageSender,
) : LinkMobileStep
data class QrScanned(val checkCodeSender: CheckCodeSender) : LinkMobileStep data class QrScanned(val checkCodeSender: CheckCodeSender) : LinkMobileStep
data class Error(val errorType: ErrorType) : LinkMobileStep data class Error(val errorType: ErrorType) : LinkMobileStep
data object SyncingSecrets : LinkMobileStep data object SyncingSecrets : LinkMobileStep
@@ -0,0 +1,36 @@
/*
* 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.libraries.matrix.impl.linknewdevice
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.linknewdevice.ContinuationMessageSender
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import timber.log.Timber
import org.matrix.rustcomponents.sdk.ContinuationMessageSender as FfiContinuationMessageSender
class RustContinuationMessageSender(
private val inner: FfiContinuationMessageSender,
private val sessionDispatcher: CoroutineDispatcher,
) : ContinuationMessageSender {
override suspend fun cancel(): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
inner.cancel()
}.onFailure {
Timber.e(it, "Error cancelling continuation message")
}
}
override suspend fun confirm(): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
inner.confirm()
}.onFailure {
Timber.e(it, "Error confirming continuation message")
}
}
}
@@ -75,6 +75,10 @@ class RustLinkDesktopHandler(
GrantQrLoginProgress.SyncingSecrets -> LinkDesktopStep.SyncingSecrets GrantQrLoginProgress.SyncingSecrets -> LinkDesktopStep.SyncingSecrets
is GrantQrLoginProgress.WaitingForAuth -> LinkDesktopStep.WaitingForAuth( is GrantQrLoginProgress.WaitingForAuth -> LinkDesktopStep.WaitingForAuth(
verificationUri = verificationUri, verificationUri = verificationUri,
continuationMessageSender = RustContinuationMessageSender(
inner = continuationSender,
sessionDispatcher = sessionDispatcher,
)
) )
is GrantQrLoginProgress.EstablishingSecureChannel -> LinkDesktopStep.EstablishingSecureChannel( is GrantQrLoginProgress.EstablishingSecureChannel -> LinkDesktopStep.EstablishingSecureChannel(
checkCode = checkCode, checkCode = checkCode,
@@ -84,7 +84,13 @@ class RustLinkMobileHandler(
) )
GrantGeneratedQrLoginProgress.Starting -> LinkMobileStep.Starting GrantGeneratedQrLoginProgress.Starting -> LinkMobileStep.Starting
GrantGeneratedQrLoginProgress.SyncingSecrets -> LinkMobileStep.SyncingSecrets GrantGeneratedQrLoginProgress.SyncingSecrets -> LinkMobileStep.SyncingSecrets
is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth(verificationUri) is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth(
verificationUri = verificationUri,
continuationMessageSender = RustContinuationMessageSender(
inner = continuationSender,
sessionDispatcher = sessionDispatcher,
)
)
} }
} }
} }
@@ -0,0 +1,25 @@
/*
* 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.libraries.matrix.impl.linknewdevice
import io.element.android.libraries.matrix.api.linknewdevice.ContinuationMessageSender
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
class FakeContinuationMessageSender(
private val cancelResult: () -> Result<Unit> = { lambdaError() },
private val confirmResult: () -> Result<Unit> = { lambdaError() },
) : ContinuationMessageSender {
override suspend fun cancel(): Result<Unit> = simulateLongTask {
cancelResult()
}
override suspend fun confirm(): Result<Unit> = simulateLongTask {
confirmResult()
}
}
@@ -16,6 +16,8 @@ import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiContinuationMessageSender import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiContinuationMessageSender
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler
import io.element.android.libraries.matrix.test.QR_CODE_DATA import io.element.android.libraries.matrix.test.QR_CODE_DATA
import io.element.android.tests.testutils.ExpectedResult
import io.element.android.tests.testutils.match
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -50,16 +52,18 @@ class RustLinkDesktopHandlerTest {
runCurrent() runCurrent()
// progress from the handler is mapped and emitted // progress from the handler is mapped and emitted
listOf( listOf(
GrantQrLoginProgress.Starting to LinkDesktopStep.Starting, GrantQrLoginProgress.Starting to ExpectedResult(LinkDesktopStep.Starting),
GrantQrLoginProgress.SyncingSecrets to LinkDesktopStep.SyncingSecrets, GrantQrLoginProgress.SyncingSecrets to ExpectedResult(LinkDesktopStep.SyncingSecrets),
GrantQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender()) GrantQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender())
to LinkDesktopStep.WaitingForAuth("aVerificationUri"), to ExpectedResult(
resultClass = LinkDesktopStep.WaitingForAuth::class.java,
),
GrantQrLoginProgress.EstablishingSecureChannel(1.toUByte(), "1") GrantQrLoginProgress.EstablishingSecureChannel(1.toUByte(), "1")
to LinkDesktopStep.EstablishingSecureChannel(1.toUByte(), "1"), to ExpectedResult(LinkDesktopStep.EstablishingSecureChannel(1.toUByte(), "1")),
GrantQrLoginProgress.Done to LinkDesktopStep.Done, GrantQrLoginProgress.Done to ExpectedResult(LinkDesktopStep.Done),
).forEach { (progress, expectedStep) -> ).forEach { (progress, expectedResult) ->
handler.emitScanProgress(progress) handler.emitScanProgress(progress)
assertThat(awaitItem()).isEqualTo(expectedStep) assertThat(awaitItem()).match(expectedResult)
} }
// scan returns, no new event is emitted // scan returns, no new event is emitted
completable.complete(Unit) completable.complete(Unit)
@@ -18,6 +18,8 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiContinuati
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData
import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE
import io.element.android.tests.testutils.ExpectedResult
import io.element.android.tests.testutils.match
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -51,18 +53,18 @@ class RustLinkMobileHandlerTest {
runCurrent() runCurrent()
// progress from the handler is mapped and emitted // progress from the handler is mapped and emitted
listOf( listOf(
GrantGeneratedQrLoginProgress.Starting to LinkMobileStep.Starting::class.java, GrantGeneratedQrLoginProgress.Starting to ExpectedResult(LinkMobileStep.Starting),
GrantGeneratedQrLoginProgress.SyncingSecrets to LinkMobileStep.SyncingSecrets::class.java, GrantGeneratedQrLoginProgress.SyncingSecrets to ExpectedResult(LinkMobileStep.SyncingSecrets),
GrantGeneratedQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender()) GrantGeneratedQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender())
to LinkMobileStep.WaitingForAuth::class.java, to ExpectedResult(LinkMobileStep.WaitingForAuth::class.java),
GrantGeneratedQrLoginProgress.QrScanned(FakeFfiCheckCodeSender()) GrantGeneratedQrLoginProgress.QrScanned(FakeFfiCheckCodeSender())
to LinkMobileStep.QrScanned::class.java, to ExpectedResult(LinkMobileStep.QrScanned::class.java),
GrantGeneratedQrLoginProgress.QrReady(FakeFfiQrCodeData(toBytesResult = { QR_CODE_DATA_RECIPROCATE })) GrantGeneratedQrLoginProgress.QrReady(FakeFfiQrCodeData(toBytesResult = { QR_CODE_DATA_RECIPROCATE }))
to LinkMobileStep.QrReady::class.java, to ExpectedResult(LinkMobileStep.QrReady::class.java),
GrantGeneratedQrLoginProgress.Done to LinkMobileStep.Done::class.java, GrantGeneratedQrLoginProgress.Done to ExpectedResult(LinkMobileStep.Done),
).forEach { (progress, expectedStepClass) -> ).forEach { (progress, expectedResult) ->
handler.emitGenerateProgress(progress) handler.emitGenerateProgress(progress)
assertThat(awaitItem()).isInstanceOf(expectedStepClass) assertThat(awaitItem()).match(expectedResult)
} }
// generate returns, no new event is emitted // generate returns, no new event is emitted
completable.complete(Unit) completable.complete(Unit)
@@ -0,0 +1,28 @@
/*
* 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.tests.testutils
import com.google.common.truth.Subject
/**
* A helper class to assert expected results in tests. It can be used to assert either the type of result or its value.
*/
data class ExpectedResult<T : Any>(
val resultClass: Class<out T>,
val result: T? = null,
) {
constructor(result: T) : this(result::class.java, result)
}
fun <T : Any> Subject.match(expectedResult: ExpectedResult<T>) {
if (expectedResult.result != null) {
isEqualTo(expectedResult.result)
} else {
isInstanceOf(expectedResult.resultClass)
}
}