From 33979b65747eef651a646c7809a17a1c81ffb32b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 7 Aug 2026 15:02:55 +0200 Subject: [PATCH 1/6] [LinkNewDevice] Add a 2 minutes timer to scan a QrCode #7303 --- .../linknewdevice/impl/LinkNewDesktopHandler.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt index a8a8ff14b6..1b0883ec4e 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt @@ -12,10 +12,12 @@ 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.LinkDesktopHandler import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep import io.element.android.libraries.matrix.api.logs.LoggerTags import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber +import kotlin.time.Duration.Companion.minutes private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice) @@ -40,17 +43,26 @@ class LinkNewDesktopHandler( get() = linkDesktopStepFlow.asStateFlow() private var currentJob: Job? = null + private var timerJob: Job? = null private var handler: LinkDesktopHandler? = null fun createNewHandler() { currentJob?.cancel() currentJob = null + timerJob?.cancel() + timerJob = null handler = matrixClient.createLinkDesktopHandler().getOrNull() + timerJob = sessionScope.launch { + delay(2.minutes) + linkDesktopStepFlow.emit(LinkDesktopStep.Error(ErrorType.Expired("Scanning QrCode took too long."))) + } } fun reset() { currentJob?.cancel() currentJob = null + timerJob?.cancel() + timerJob = null sessionScope.launch { linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized) } @@ -59,6 +71,8 @@ class LinkNewDesktopHandler( fun onScannedCode(data: ByteArray) { currentJob?.cancel() currentJob = null + timerJob?.cancel() + timerJob = null val currentHandler = handler if (currentHandler == null) { Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.") From 4485c8a4c018dc2e1965316f54a845da9c865b1e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 7 Aug 2026 15:35:09 +0200 Subject: [PATCH 2/6] [LinkNewDevice] Fix second and next attempts to unlock using biometric failures. `cipher.doFinal` cannot be called several times. --- .../lockscreen/impl/biometric/BiometricAuthenticator.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt index 74d36b0834..f08d2fc1af 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt @@ -59,7 +59,7 @@ class DefaultBiometricAuthentication( private var cryptoObject: CryptoObject? = null override suspend fun setup() { - if (cryptoObject != null) return + Timber.d("Setup biometric authentication") try { val secretKey = ensureKey() val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey) @@ -126,6 +126,9 @@ private class AuthenticationCallback( if (this == null) return false return runCatchingExceptions { doFinal("biometric_challenge".toByteArray()) - }.isSuccess + }.onFailure { + Timber.e(it, "Failed to validate cipher") + } + .isSuccess } } From 2b2d86a804bc26f2fd287575892a4f5ccb177dd1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 7 Aug 2026 15:45:16 +0200 Subject: [PATCH 3/6] Use Kotlin Duration. --- .../linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt index 21071a6831..c496c69f92 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber +import kotlin.time.Duration.Companion.seconds private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice) @@ -63,7 +64,7 @@ class ShowQrCodePresenter( linkNewMobileHandler.rotateQrCode() // Ensure that outdated data is not rendered too long while rotating QR code loadingJob = launch { - delay(1000) + delay(1.seconds) value = ShowQrCodeState( data = AsyncData.Loading(), ) From e66efcfc4392df4c656539a6465476d420ece935 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 7 Aug 2026 16:40:26 +0200 Subject: [PATCH 4/6] [LinkNewDevice] Disable QrCode rotation #7303 --- .../impl/screens/qrcode/ShowQrCodeNode.kt | 10 +++++++ .../screens/qrcode/ShowQrCodePresenter.kt | 12 ++++----- .../screens/qrcode/ShowQrCodePresenterTest.kt | 26 +++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt index 20bd50f488..7032458860 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt @@ -39,6 +39,7 @@ class ShowQrCodeNode( private val callback: Callback = callback() private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create( initialData = inputs.data, + maxQrCodeRotation = MAX_QR_CODE_ROTATION, ) @Composable @@ -50,4 +51,13 @@ class ShowQrCodeNode( onBackClick = callback::navigateBack, ) } + + companion object { + /** + * Max number of QrCode rotation. + * When QrCode is rotated this number of time, a TimeOut error is emitted. + * Set to 0 to disable QrCode rotation. + */ + const val MAX_QR_CODE_ROTATION = 0 + } } diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt index c496c69f92..1a5eac0522 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt @@ -33,18 +33,22 @@ private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice) @AssistedInject class ShowQrCodePresenter( @Assisted private val initialData: String, + @Assisted private val maxQrCodeRotation: Int, private val linkNewMobileHandler: LinkNewMobileHandler, ) : Presenter { @AssistedFactory interface Factory { - fun create(initialData: String): ShowQrCodePresenter + fun create( + initialData: String, + maxQrCodeRotation: Int, + ): ShowQrCodePresenter } private var loadingJob: Job? = null @Composable override fun present(): ShowQrCodeState { - var qrCodeRotationCounter by remember { mutableIntStateOf(MAX_QR_CODE_ROTATION) } + var qrCodeRotationCounter by remember { mutableIntStateOf(maxQrCodeRotation) } val state by produceState( initialValue = ShowQrCodeState( data = AsyncData.Success(initialData), @@ -81,8 +85,4 @@ class ShowQrCodePresenter( return state } - - companion object { - const val MAX_QR_CODE_ROTATION = 10 - } } diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt index f92cf66102..db7c3e8c01 100644 --- a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt @@ -91,10 +91,36 @@ class ShowQrCodePresenterTest { } } + @Test + fun `present - when handler emits QrRotating, the presenter does not emit anything when rotation is disabled`() = runTest { + val linkMobileHandler = FakeLinkMobileHandler( + startResult = {}, + ) + val matrixClient = FakeMatrixClient( + sessionCoroutineScope = backgroundScope, + createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }, + ) + val linkNewMobileHandler = LinkNewMobileHandler(matrixClient) + linkNewMobileHandler.createAndStartNewHandler() + createPresenter( + linkNewMobileHandler = linkNewMobileHandler, + maxQrCodeRotation = 0, + ).test { + awaitItem() + linkMobileHandler.emitStep( + LinkMobileStep.QrRotating + ) + runCurrent() + expectNoEvents() + } + } + private fun createPresenter( linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()), + maxQrCodeRotation: Int = 10, ) = ShowQrCodePresenter( initialData = "DATA", + maxQrCodeRotation = maxQrCodeRotation, linkNewMobileHandler = linkNewMobileHandler, ) } From 749ecb4576ac2b4854f103210b1e72c29e95112e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 11 Aug 2026 16:28:07 +0200 Subject: [PATCH 5/6] No need for a companion object. --- .../impl/screens/qrcode/ShowQrCodeNode.kt | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt index 7032458860..5110f8938f 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt @@ -20,6 +20,13 @@ import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope +/** + * Max number of QrCode rotation. + * When QrCode is rotated this number of time, a TimeOut error is emitted. + * Set to 0 to disable QrCode rotation. + */ +private const val MAX_QR_CODE_ROTATION = 0 + @ContributesNode(SessionScope::class) @AssistedInject class ShowQrCodeNode( @@ -51,13 +58,4 @@ class ShowQrCodeNode( onBackClick = callback::navigateBack, ) } - - companion object { - /** - * Max number of QrCode rotation. - * When QrCode is rotated this number of time, a TimeOut error is emitted. - * Set to 0 to disable QrCode rotation. - */ - const val MAX_QR_CODE_ROTATION = 0 - } } From 223973805f434e2dc848a92dcc98703e2b95c3c8 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 11 Aug 2026 16:30:11 +0200 Subject: [PATCH 6/6] Create function to limit code duplication. --- .../linknewdevice/impl/LinkNewDesktopHandler.kt | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt index 1b0883ec4e..7afb9a7623 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt @@ -46,11 +46,15 @@ class LinkNewDesktopHandler( private var timerJob: Job? = null private var handler: LinkDesktopHandler? = null - fun createNewHandler() { + private fun resetJobs() { currentJob?.cancel() currentJob = null timerJob?.cancel() timerJob = null + } + + fun createNewHandler() { + resetJobs() handler = matrixClient.createLinkDesktopHandler().getOrNull() timerJob = sessionScope.launch { delay(2.minutes) @@ -59,20 +63,14 @@ class LinkNewDesktopHandler( } fun reset() { - currentJob?.cancel() - currentJob = null - timerJob?.cancel() - timerJob = null + resetJobs() sessionScope.launch { linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized) } } fun onScannedCode(data: ByteArray) { - currentJob?.cancel() - currentJob = null - timerJob?.cancel() - timerJob = null + resetJobs() val currentHandler = handler if (currentHandler == null) { Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.")