diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt index d686b22860..b1492f53cc 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt @@ -54,8 +54,10 @@ import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep import io.element.android.libraries.matrix.api.logs.LoggerTags import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import timber.log.Timber @@ -102,6 +104,7 @@ class LinkNewDeviceFlowNode( @Suppress("AssignedValueIsNeverRead") linkDesktopHandlerJob = observeLinkNewDesktopHandler() }, + onResume = ::onResume, onDestroy = { linkMobileHandlerJob?.cancel() linkDesktopHandlerJob?.cancel() @@ -209,6 +212,18 @@ class LinkNewDeviceFlowNode( .launchIn(sessionCoroutineScope) } + private fun onResume() = sessionCoroutineScope.launch { + // Application is resumed, if the step is waiting for auth, send the confirmation + (linkNewMobileHandler.stepFlow.first() 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) { // Map the error to an error screen val error = when (errorType) { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ContinuationMessageSender.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ContinuationMessageSender.kt new file mode 100644 index 0000000000..0bbbaf3187 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ContinuationMessageSender.kt @@ -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 + suspend fun confirm(): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt index d600c1a8bf..343624aa34 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt @@ -18,8 +18,10 @@ interface LinkDesktopHandler { sealed interface LinkDesktopStep { data object Uninitialized : LinkDesktopStep data object Starting : LinkDesktopStep + data class WaitingForAuth( val verificationUri: String, + val continuationMessageSender: ContinuationMessageSender, ) : LinkDesktopStep data class EstablishingSecureChannel( diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt index eb2d49b5b0..f187b8275a 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt @@ -22,7 +22,12 @@ sealed interface 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 WaitingForAuth( + val verificationUri: String, + val continuationMessageSender: ContinuationMessageSender, + ) : LinkMobileStep + data class QrScanned(val checkCodeSender: CheckCodeSender) : LinkMobileStep data class Error(val errorType: ErrorType) : LinkMobileStep data object SyncingSecrets : LinkMobileStep diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustContinuationMessageSender.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustContinuationMessageSender.kt new file mode 100644 index 0000000000..98cf29418c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustContinuationMessageSender.kt @@ -0,0 +1,31 @@ +/* + * 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 org.matrix.rustcomponents.sdk.ContinuationMessageSender as FfiContinuationMessageSender + +class RustContinuationMessageSender( + private val inner: FfiContinuationMessageSender, + private val sessionDispatcher: CoroutineDispatcher, +) : ContinuationMessageSender { + override suspend fun cancel(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + inner.cancel() + } + } + + override suspend fun confirm(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + inner.confirm() + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt index 83e657eb07..8c9695ae4b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt @@ -75,6 +75,10 @@ class RustLinkDesktopHandler( GrantQrLoginProgress.SyncingSecrets -> LinkDesktopStep.SyncingSecrets is GrantQrLoginProgress.WaitingForAuth -> LinkDesktopStep.WaitingForAuth( verificationUri = verificationUri, + continuationMessageSender = RustContinuationMessageSender( + inner = continuationSender, + sessionDispatcher = sessionDispatcher, + ) ) is GrantQrLoginProgress.EstablishingSecureChannel -> LinkDesktopStep.EstablishingSecureChannel( checkCode = checkCode, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt index a44a8d5943..33f1ad736d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt @@ -84,7 +84,13 @@ class RustLinkMobileHandler( ) GrantGeneratedQrLoginProgress.Starting -> LinkMobileStep.Starting GrantGeneratedQrLoginProgress.SyncingSecrets -> LinkMobileStep.SyncingSecrets - is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth(verificationUri) + is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth( + verificationUri = verificationUri, + continuationMessageSender = RustContinuationMessageSender( + inner = continuationSender, + sessionDispatcher = sessionDispatcher, + ) + ) } } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeContinuationMessageSender.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeContinuationMessageSender.kt new file mode 100644 index 0000000000..d1544ec8bb --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeContinuationMessageSender.kt @@ -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 = { lambdaError() }, + private val confirmResult: () -> Result = { lambdaError() }, +) : ContinuationMessageSender { + override suspend fun cancel(): Result = simulateLongTask { + cancelResult() + } + + override suspend fun confirm(): Result = simulateLongTask { + confirmResult() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt index c0781972ac..8a0a046946 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt @@ -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.FakeFfiGrantLoginWithQrCodeHandler 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.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -50,16 +52,18 @@ class RustLinkDesktopHandlerTest { runCurrent() // progress from the handler is mapped and emitted listOf( - GrantQrLoginProgress.Starting to LinkDesktopStep.Starting, - GrantQrLoginProgress.SyncingSecrets to LinkDesktopStep.SyncingSecrets, + GrantQrLoginProgress.Starting to ExpectedResult(LinkDesktopStep.Starting), + GrantQrLoginProgress.SyncingSecrets to ExpectedResult(LinkDesktopStep.SyncingSecrets), GrantQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender()) - to LinkDesktopStep.WaitingForAuth("aVerificationUri"), + to ExpectedResult( + resultClass = LinkDesktopStep.WaitingForAuth::class.java, + ), GrantQrLoginProgress.EstablishingSecureChannel(1.toUByte(), "1") - to LinkDesktopStep.EstablishingSecureChannel(1.toUByte(), "1"), - GrantQrLoginProgress.Done to LinkDesktopStep.Done, - ).forEach { (progress, expectedStep) -> + to ExpectedResult(LinkDesktopStep.EstablishingSecureChannel(1.toUByte(), "1")), + GrantQrLoginProgress.Done to ExpectedResult(LinkDesktopStep.Done), + ).forEach { (progress, expectedResult) -> handler.emitScanProgress(progress) - assertThat(awaitItem()).isEqualTo(expectedStep) + assertThat(awaitItem()).match(expectedResult) } // scan returns, no new event is emitted completable.complete(Unit) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt index 8c0306c8ad..f1f568e3ca 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt @@ -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.FakeFfiQrCodeData 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.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -51,18 +53,18 @@ class RustLinkMobileHandlerTest { runCurrent() // progress from the handler is mapped and emitted listOf( - GrantGeneratedQrLoginProgress.Starting to LinkMobileStep.Starting::class.java, - GrantGeneratedQrLoginProgress.SyncingSecrets to LinkMobileStep.SyncingSecrets::class.java, + GrantGeneratedQrLoginProgress.Starting to ExpectedResult(LinkMobileStep.Starting), + GrantGeneratedQrLoginProgress.SyncingSecrets to ExpectedResult(LinkMobileStep.SyncingSecrets), GrantGeneratedQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender()) - to LinkMobileStep.WaitingForAuth::class.java, + to ExpectedResult(LinkMobileStep.WaitingForAuth::class.java), GrantGeneratedQrLoginProgress.QrScanned(FakeFfiCheckCodeSender()) - to LinkMobileStep.QrScanned::class.java, + to ExpectedResult(LinkMobileStep.QrScanned::class.java), GrantGeneratedQrLoginProgress.QrReady(FakeFfiQrCodeData(toBytesResult = { QR_CODE_DATA_RECIPROCATE })) - to LinkMobileStep.QrReady::class.java, - GrantGeneratedQrLoginProgress.Done to LinkMobileStep.Done::class.java, - ).forEach { (progress, expectedStepClass) -> + to ExpectedResult(LinkMobileStep.QrReady::class.java), + GrantGeneratedQrLoginProgress.Done to ExpectedResult(LinkMobileStep.Done), + ).forEach { (progress, expectedResult) -> handler.emitGenerateProgress(progress) - assertThat(awaitItem()).isInstanceOf(expectedStepClass) + assertThat(awaitItem()).match(expectedResult) } // generate returns, no new event is emitted completable.complete(Unit) diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ExpectedResult.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ExpectedResult.kt new file mode 100644 index 0000000000..b5e383f324 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ExpectedResult.kt @@ -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( + val resultClass: Class, + val result: T? = null, +) { + constructor(result: T) : this(result::class.java, result) +} + +fun Subject.match(expectedResult: ExpectedResult) { + if (expectedResult.result != null) { + isEqualTo(expectedResult.result) + } else { + isInstanceOf(expectedResult.resultClass) + } +}