Merge pull request #7338 from element-hq/feature/bma/linkNewDeviceTimeout

[Link new device] Add timeout
This commit is contained in:
Benoit Marty
2026-08-11 17:51:39 +02:00
committed by GitHub
5 changed files with 64 additions and 14 deletions
@@ -12,10 +12,12 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient 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.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
import io.element.android.libraries.matrix.api.logs.LoggerTags import io.element.android.libraries.matrix.api.logs.LoggerTags
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.minutes
private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice) private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice)
@@ -40,25 +43,34 @@ class LinkNewDesktopHandler(
get() = linkDesktopStepFlow.asStateFlow() get() = linkDesktopStepFlow.asStateFlow()
private var currentJob: Job? = null private var currentJob: Job? = null
private var timerJob: Job? = null
private var handler: LinkDesktopHandler? = null private var handler: LinkDesktopHandler? = null
fun createNewHandler() { private fun resetJobs() {
currentJob?.cancel() currentJob?.cancel()
currentJob = null currentJob = null
timerJob?.cancel()
timerJob = null
}
fun createNewHandler() {
resetJobs()
handler = matrixClient.createLinkDesktopHandler().getOrNull() handler = matrixClient.createLinkDesktopHandler().getOrNull()
timerJob = sessionScope.launch {
delay(2.minutes)
linkDesktopStepFlow.emit(LinkDesktopStep.Error(ErrorType.Expired("Scanning QrCode took too long.")))
}
} }
fun reset() { fun reset() {
currentJob?.cancel() resetJobs()
currentJob = null
sessionScope.launch { sessionScope.launch {
linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized) linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized)
} }
} }
fun onScannedCode(data: ByteArray) { fun onScannedCode(data: ByteArray) {
currentJob?.cancel() resetJobs()
currentJob = null
val currentHandler = handler val currentHandler = handler
if (currentHandler == null) { if (currentHandler == null) {
Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.") Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.")
@@ -20,6 +20,13 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope 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) @ContributesNode(SessionScope::class)
@AssistedInject @AssistedInject
class ShowQrCodeNode( class ShowQrCodeNode(
@@ -39,6 +46,7 @@ class ShowQrCodeNode(
private val callback: Callback = callback() private val callback: Callback = callback()
private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create( private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create(
initialData = inputs.data, initialData = inputs.data,
maxQrCodeRotation = MAX_QR_CODE_ROTATION,
) )
@Composable @Composable
@@ -26,24 +26,29 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice) private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice)
@AssistedInject @AssistedInject
class ShowQrCodePresenter( class ShowQrCodePresenter(
@Assisted private val initialData: String, @Assisted private val initialData: String,
@Assisted private val maxQrCodeRotation: Int,
private val linkNewMobileHandler: LinkNewMobileHandler, private val linkNewMobileHandler: LinkNewMobileHandler,
) : Presenter<ShowQrCodeState> { ) : Presenter<ShowQrCodeState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(initialData: String): ShowQrCodePresenter fun create(
initialData: String,
maxQrCodeRotation: Int,
): ShowQrCodePresenter
} }
private var loadingJob: Job? = null private var loadingJob: Job? = null
@Composable @Composable
override fun present(): ShowQrCodeState { override fun present(): ShowQrCodeState {
var qrCodeRotationCounter by remember { mutableIntStateOf(MAX_QR_CODE_ROTATION) } var qrCodeRotationCounter by remember { mutableIntStateOf(maxQrCodeRotation) }
val state by produceState( val state by produceState(
initialValue = ShowQrCodeState( initialValue = ShowQrCodeState(
data = AsyncData.Success(initialData), data = AsyncData.Success(initialData),
@@ -63,7 +68,7 @@ class ShowQrCodePresenter(
linkNewMobileHandler.rotateQrCode() linkNewMobileHandler.rotateQrCode()
// Ensure that outdated data is not rendered too long while rotating QR code // Ensure that outdated data is not rendered too long while rotating QR code
loadingJob = launch { loadingJob = launch {
delay(1000) delay(1.seconds)
value = ShowQrCodeState( value = ShowQrCodeState(
data = AsyncData.Loading(), data = AsyncData.Loading(),
) )
@@ -80,8 +85,4 @@ class ShowQrCodePresenter(
return state return state
} }
companion object {
const val MAX_QR_CODE_ROTATION = 10
}
} }
@@ -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( private fun createPresenter(
linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()), linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()),
maxQrCodeRotation: Int = 10,
) = ShowQrCodePresenter( ) = ShowQrCodePresenter(
initialData = "DATA", initialData = "DATA",
maxQrCodeRotation = maxQrCodeRotation,
linkNewMobileHandler = linkNewMobileHandler, linkNewMobileHandler = linkNewMobileHandler,
) )
} }
@@ -59,7 +59,7 @@ class DefaultBiometricAuthentication(
private var cryptoObject: CryptoObject? = null private var cryptoObject: CryptoObject? = null
override suspend fun setup() { override suspend fun setup() {
if (cryptoObject != null) return Timber.d("Setup biometric authentication")
try { try {
val secretKey = ensureKey() val secretKey = ensureKey()
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey) val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
@@ -126,6 +126,9 @@ private class AuthenticationCallback(
if (this == null) return false if (this == null) return false
return runCatchingExceptions { return runCatchingExceptions {
doFinal("biometric_challenge".toByteArray()) doFinal("biometric_challenge".toByteArray())
}.isSuccess }.onFailure {
Timber.e(it, "Failed to validate cipher")
}
.isSuccess
} }
} }