Merge pull request #5909 from element-hq/feature/bma/qrCodeLogin

Link new device using QrCode - First version
This commit is contained in:
Benoit Marty 2025-12-18 16:08:21 +01:00 committed by GitHub
commit 3ea10c2c62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
180 changed files with 5052 additions and 144 deletions

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 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.androidutils.system
import android.app.Activity
import android.view.WindowManager
/**
* Set the screen brightness for the given activity.
*
* @receiver current Activity.
* @param full If true, override brightness to full; otherwise, set to none (default).
*/
fun Activity.setFullBrightness(full: Boolean) {
window.attributes = window.attributes.apply {
screenBrightness = if (full) {
WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
} else {
WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 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.designsystem.atomic.atoms
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LoadingButtonAtom(
modifier: Modifier = Modifier,
) = Button(
modifier = modifier.fillMaxWidth(),
enabled = false,
showProgress = true,
text = stringResource(CommonStrings.common_loading),
onClick = {},
)

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 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.designsystem.utils
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import io.element.android.libraries.androidutils.system.setFullBrightness
@Composable
fun ForceMaxBrightness() {
val activity = LocalActivity.current ?: return
DisposableEffect(Unit) {
activity.setFullBrightness(true)
onDispose {
activity.setFullBrightness(false)
}
}
}

View file

@ -125,4 +125,11 @@ enum class FeatureFlags(
defaultValue = { true },
isFinished = false,
),
QrCodeLogin(
key = "feature.qr_code_login",
title = "QR Code Login",
description = "Allow logging in on other devices using a QR code.",
defaultValue = { false },
isFinished = false,
),
}

View file

@ -20,6 +20,8 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.notification.NotificationService
@ -197,6 +199,21 @@ interface MatrixClient {
*/
suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result<Unit>
/**
* Check if linking a new device using QrCode is supported by the server.
*/
suspend fun canLinkNewDevice(): Result<Boolean>
/**
* Create a handler to link a new mobile device, i.e. a device capable of scanning QrCodes.
*/
fun createLinkMobileHandler(): Result<LinkMobileHandler>
/**
* Create a handler to link a new desktop device, i.e. a device not capable of scanning QrCodes.
*/
fun createLinkDesktopHandler(): Result<LinkDesktopHandler>
suspend fun performDatabaseVacuum(): Result<Unit>
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 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 CheckCodeSender {
/**
* Validates the given [code]. Returns true if the code is valid, false otherwise.
* This method can be called multiple times to validate different codes.
*/
suspend fun validate(code: UByte): Boolean
/**
* Sends the given [code].
* This method can be called only once.
*/
suspend fun send(code: UByte): Result<Unit>
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 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
sealed class ErrorType(message: String) : Exception(message) {
/**
* The requested device ID is already in use.
*/
class DeviceIdAlreadyInUse(message: String) : ErrorType(message)
/**
* The check code was incorrect.
*/
class InvalidCheckCode(message: String) : ErrorType(message)
/**
* The other client proposed an unsupported protocol.
*/
class UnsupportedProtocol(message: String) : ErrorType(message)
/**
* Secrets backup not set up properly.
*/
class MissingSecretsBackup(message: String) : ErrorType(message)
/**
* The rendezvous session was not found and might have expired.
*/
class NotFound(message: String) : ErrorType(message)
/**
* The device could not be created.
*/
class UnableToCreateDevice(message: String) : ErrorType(message)
/**
* An unknown error has happened.
*/
class Unknown(message: String) : ErrorType(message)
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 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
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException
import kotlinx.coroutines.flow.StateFlow
interface LinkDesktopHandler {
val linkDesktopStep: StateFlow<LinkDesktopStep>
suspend fun handleScannedQrCode(data: ByteArray)
}
sealed interface LinkDesktopStep {
data object Uninitialized : LinkDesktopStep
data object Starting : LinkDesktopStep
data class WaitingForAuth(
val verificationUri: String,
) : LinkDesktopStep
data class EstablishingSecureChannel(
val checkCode: UByte,
val checkCodeString: String,
) : LinkDesktopStep
data class InvalidQrCode(
val error: QrCodeDecodeException,
) : LinkDesktopStep
data class Error(
val errorType: ErrorType,
) : LinkDesktopStep
data object SyncingSecrets : LinkDesktopStep
data object Done : LinkDesktopStep
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 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
import kotlinx.coroutines.flow.Flow
interface LinkMobileHandler {
val linkMobileStep: Flow<LinkMobileStep>
suspend fun start()
}
sealed interface LinkMobileStep {
data object Uninitialized : LinkMobileStep
data object Starting : LinkMobileStep
data class QrReady(val data: String) : LinkMobileStep
data class WaitingForAuth(val verificationUri: String) : LinkMobileStep
data class QrScanned(val checkCodeSender: CheckCodeSender) : LinkMobileStep
data class Error(val errorType: ErrorType) : LinkMobileStep
data object SyncingSecrets : LinkMobileStep
data object Done : LinkMobileStep
}

View file

@ -0,0 +1,14 @@
/*
* Copyright (c) 2025 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.logs
import io.element.android.libraries.core.log.logger.LoggerTag
object LoggerTags {
val linkNewDevice = LoggerTag("LinkNewDevice")
}

View file

@ -27,6 +27,8 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.room.BaseRoom
@ -47,6 +49,9 @@ import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService
import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkMobileHandler
import io.element.android.libraries.matrix.impl.linknewdevice.RustQrCodeDataParser
import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
@ -739,6 +744,35 @@ class RustMatrixClient(
}
}
override suspend fun canLinkNewDevice(): Result<Boolean> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.isLoginWithQrCodeSupported()
}
}
override fun createLinkMobileHandler(): Result<LinkMobileHandler> {
return runCatchingExceptions {
val handler = innerClient.newGrantLoginWithQrCodeHandler()
RustLinkMobileHandler(
inner = handler,
sessionCoroutineScope = sessionCoroutineScope,
sessionDispatcher = sessionDispatcher,
)
}
}
override fun createLinkDesktopHandler(): Result<LinkDesktopHandler> {
return runCatchingExceptions {
val handler = innerClient.newGrantLoginWithQrCodeHandler()
RustLinkDesktopHandler(
inner = handler,
sessionCoroutineScope = sessionCoroutineScope,
sessionDispatcher = sessionDispatcher,
qrCodeDataParser = RustQrCodeDataParser(),
)
}
}
override suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
val room = innerClient.getRoom(roomId.value) ?: error("Could not fetch associated room")

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 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.ErrorType
import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException
internal fun HumanQrGrantLoginException.map() = when (this) {
is HumanQrGrantLoginException.DeviceIdAlreadyInUse -> ErrorType.DeviceIdAlreadyInUse(message.orEmpty())
is HumanQrGrantLoginException.InvalidCheckCode -> ErrorType.InvalidCheckCode(message.orEmpty())
is HumanQrGrantLoginException.MissingSecretsBackup -> ErrorType.MissingSecretsBackup(message.orEmpty())
is HumanQrGrantLoginException.NotFound -> ErrorType.NotFound(message.orEmpty())
is HumanQrGrantLoginException.UnableToCreateDevice -> ErrorType.UnableToCreateDevice(message.orEmpty())
is HumanQrGrantLoginException.Unknown -> ErrorType.Unknown(message.orEmpty())
is HumanQrGrantLoginException.UnsupportedProtocol -> ErrorType.UnsupportedProtocol(message.orEmpty())
}

View file

@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 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 org.matrix.rustcomponents.sdk.QrCodeData
interface QrCodeDataParser {
fun parse(data: ByteArray): QrCodeData
}
class RustQrCodeDataParser : QrCodeDataParser {
override fun parse(data: ByteArray): QrCodeData {
return QrCodeData.fromBytes(data)
}
}

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 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.CheckCodeSender
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.CheckCodeSender as FfiCheckCodeSender
class RustCheckCodeSender(
private val inner: FfiCheckCodeSender,
private val sessionDispatcher: CoroutineDispatcher,
) : CheckCodeSender {
override suspend fun validate(code: UByte): Boolean = withContext(sessionDispatcher) {
runCatchingExceptions {
// TODO https://github.com/matrix-org/matrix-rust-sdk/pull/5957
// inner.validate(code)
true
}.getOrNull() ?: true
}
override suspend fun send(code: UByte): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
inner.send(code)
}
}
}

View file

@ -0,0 +1,82 @@
/*
* Copyright (c) 2025 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.log.logger.LoggerTag
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 io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeHandler
import org.matrix.rustcomponents.sdk.GrantQrLoginProgress
import org.matrix.rustcomponents.sdk.GrantQrLoginProgressListener
import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
import timber.log.Timber
private val tag = LoggerTag("RustLinkDesktopHandler", LoggerTags.linkNewDevice)
class RustLinkDesktopHandler(
private val inner: GrantLoginWithQrCodeHandler,
private val sessionCoroutineScope: CoroutineScope,
private val sessionDispatcher: CoroutineDispatcher,
private val qrCodeDataParser: QrCodeDataParser,
) : LinkDesktopHandler {
private val _linkDesktopStep = MutableStateFlow<LinkDesktopStep>(LinkDesktopStep.Uninitialized)
override val linkDesktopStep: StateFlow<LinkDesktopStep> = _linkDesktopStep.asStateFlow()
override suspend fun handleScannedQrCode(data: ByteArray) = withContext(sessionDispatcher) {
Timber.tag(tag.value).d("Emit Uninitialized")
_linkDesktopStep.emit(LinkDesktopStep.Uninitialized)
try {
val qrCodeData = qrCodeDataParser.parse(data)
inner.scan(
qrCodeData = qrCodeData,
progressListener = object : GrantQrLoginProgressListener {
override fun onUpdate(state: GrantQrLoginProgress) {
sessionCoroutineScope.launch {
val mappedState = state.map()
Timber.tag(tag.value).d("Emit ${mappedState::class.java.simpleName}")
_linkDesktopStep.emit(mappedState)
}
}
}
)
} catch (e: QrCodeDecodeException) {
Timber.tag(tag.value).w(e, "Invalid QR code scanned")
_linkDesktopStep.emit(
LinkDesktopStep.InvalidQrCode(
error = QrErrorMapper.map(e)
)
)
} catch (e: HumanQrGrantLoginException) {
Timber.tag(tag.value).w(e, "Error during QR login grant")
_linkDesktopStep.emit(LinkDesktopStep.Error(e.map()))
}
}
private fun GrantQrLoginProgress.map() = when (this) {
GrantQrLoginProgress.Done -> LinkDesktopStep.Done
GrantQrLoginProgress.Starting -> LinkDesktopStep.Starting
GrantQrLoginProgress.SyncingSecrets -> LinkDesktopStep.SyncingSecrets
is GrantQrLoginProgress.WaitingForAuth -> LinkDesktopStep.WaitingForAuth(
verificationUri = verificationUri,
)
is GrantQrLoginProgress.EstablishingSecureChannel -> LinkDesktopStep.EstablishingSecureChannel(
checkCode = checkCode,
checkCodeString = checkCodeString,
)
}
}

View file

@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 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.log.logger.LoggerTag
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
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgress
import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgressListener
import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeHandler
import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException
import timber.log.Timber
private val tag = LoggerTag("RustLinkMobileHandler", LoggerTags.linkNewDevice)
class RustLinkMobileHandler(
private val inner: GrantLoginWithQrCodeHandler,
private val sessionCoroutineScope: CoroutineScope,
private val sessionDispatcher: CoroutineDispatcher,
) : LinkMobileHandler {
private val _linkMobileStep = MutableStateFlow<LinkMobileStep>(LinkMobileStep.Uninitialized)
override val linkMobileStep: Flow<LinkMobileStep> = _linkMobileStep.asStateFlow()
override suspend fun start() = withContext(sessionDispatcher) {
Timber.tag(tag.value).d("Emit Uninitialized")
_linkMobileStep.emit(LinkMobileStep.Uninitialized)
try {
inner.generate(
progressListener = object : GrantGeneratedQrLoginProgressListener {
override fun onUpdate(state: GrantGeneratedQrLoginProgress) {
sessionCoroutineScope.launch {
val mappedState = state.map()
Timber.tag(tag.value).d("Emit ${mappedState::class.java.simpleName}")
_linkMobileStep.emit(mappedState)
}
}
}
)
} catch (e: HumanQrGrantLoginException) {
Timber.tag(tag.value).w(e, "Error during QR login grant")
_linkMobileStep.emit(LinkMobileStep.Error(e.map()))
}
}
private fun GrantGeneratedQrLoginProgress.map(): LinkMobileStep {
return when (this) {
GrantGeneratedQrLoginProgress.Done -> LinkMobileStep.Done
is GrantGeneratedQrLoginProgress.QrReady -> {
LinkMobileStep.QrReady(String(qrCode.toBytes(), Charsets.ISO_8859_1))
}
is GrantGeneratedQrLoginProgress.QrScanned -> LinkMobileStep.QrScanned(
RustCheckCodeSender(
inner = checkCodeSender,
sessionDispatcher = sessionDispatcher,
)
)
GrantGeneratedQrLoginProgress.Starting -> LinkMobileStep.Starting
GrantGeneratedQrLoginProgress.SyncingSecrets -> LinkMobileStep.SyncingSecrets
is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth(verificationUri)
}
}
}

View file

@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 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.fixtures.fakes
import io.element.android.tests.testutils.lambda.lambdaError
import org.matrix.rustcomponents.sdk.CheckCodeSender
import org.matrix.rustcomponents.sdk.NoHandle
class FakeFfiCheckCodeSender(
private val sendResult: (UByte) -> Unit = { _ -> lambdaError() }
) : CheckCodeSender(NoHandle) {
override suspend fun send(code: UByte) {
sendResult(code)
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 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.fixtures.fakes
import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgress
import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgressListener
import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeHandler
import org.matrix.rustcomponents.sdk.GrantQrLoginProgress
import org.matrix.rustcomponents.sdk.GrantQrLoginProgressListener
import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.QrCodeData
class FakeFfiGrantLoginWithQrCodeHandler(
private val generateResult: () -> Unit = {},
private val scanResult: (QrCodeData) -> Unit = {},
) : GrantLoginWithQrCodeHandler(NoHandle) {
private var generateProgressListener: GrantGeneratedQrLoginProgressListener? = null
private var scanProgressListener: GrantQrLoginProgressListener? = null
override suspend fun generate(progressListener: GrantGeneratedQrLoginProgressListener) {
generateProgressListener = progressListener
generateResult()
}
fun emitGenerateProgress(progress: GrantGeneratedQrLoginProgress) {
generateProgressListener?.onUpdate(progress)
}
override suspend fun scan(qrCodeData: QrCodeData, progressListener: GrantQrLoginProgressListener) {
scanProgressListener = progressListener
scanResult(qrCodeData)
}
fun emitScanProgress(progress: GrantQrLoginProgress) {
scanProgressListener?.onUpdate(progress)
}
}

View file

@ -14,8 +14,13 @@ import org.matrix.rustcomponents.sdk.QrCodeData
class FakeFfiQrCodeData(
private val serverNameResult: () -> String? = { lambdaError() },
private val toBytesResult: () -> ByteArray = { lambdaError() },
) : QrCodeData(NoHandle) {
override fun serverName(): String? {
return serverNameResult()
}
override fun toBytes(): ByteArray {
return toBytesResult()
}
}

View file

@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 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.impl.fixtures.fakes.FakeFfiQrCodeData
import org.matrix.rustcomponents.sdk.QrCodeData
class FakeQrCodeDataParser : QrCodeDataParser {
override fun parse(data: ByteArray): QrCodeData {
return FakeFfiQrCodeData()
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 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 com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiCheckCodeSender
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RustCheckCodeSenderTest {
@Test
fun `send invokes the Ffi object`() = runTest {
val sendResult = lambdaRecorder<UByte, Unit> { }
val sut = RustCheckCodeSender(
inner = FakeFfiCheckCodeSender(
sendResult = sendResult,
),
sessionDispatcher = StandardTestDispatcher(testScheduler),
)
sut.send(1.toUByte())
sendResult.assertions().isCalledOnce().with(value(1.toUByte()))
}
@Test
fun `validate always returns true for now`() = runTest {
val sut = RustCheckCodeSender(
inner = FakeFfiCheckCodeSender(),
sessionDispatcher = StandardTestDispatcher(testScheduler),
)
val result = sut.validate(1.toUByte())
assertThat(result).isTrue()
}
}

View file

@ -0,0 +1,109 @@
/*
* Copyright (c) 2025 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.libraries.matrix.impl.linknewdevice
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler
import io.element.android.libraries.matrix.test.QR_CODE_DATA
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.GrantQrLoginProgress
import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
class RustLinkDesktopHandlerTest {
@Test
fun `handleScannedQrCode function works as expected`() = runTest {
val handler = FakeFfiGrantLoginWithQrCodeHandler()
val sut = createRustLinkDesktopHandler(
handler,
)
sut.linkDesktopStep.test {
val initialItem = awaitItem()
assertThat(initialItem).isEqualTo(LinkDesktopStep.Uninitialized)
backgroundScope.launch {
sut.handleScannedQrCode(QR_CODE_DATA)
}
runCurrent()
// progress from the handler is mapped and emitted
listOf(
GrantQrLoginProgress.Starting to LinkDesktopStep.Starting,
GrantQrLoginProgress.SyncingSecrets to LinkDesktopStep.SyncingSecrets,
GrantQrLoginProgress.WaitingForAuth("aVerificationUri")
to LinkDesktopStep.WaitingForAuth("aVerificationUri"),
GrantQrLoginProgress.EstablishingSecureChannel(1.toUByte(), "1")
to LinkDesktopStep.EstablishingSecureChannel(1.toUByte(), "1"),
GrantQrLoginProgress.Done to LinkDesktopStep.Done,
).forEach { (progress, expectedStep) ->
handler.emitScanProgress(progress)
assertThat(awaitItem()).isEqualTo(expectedStep)
}
}
}
@Test
fun `when handleScannedQrCode throws QrCodeDecodeException, the handler emits error step`() = runTest {
val handler = FakeFfiGrantLoginWithQrCodeHandler(
scanResult = { throw QrCodeDecodeException.Crypto("Scan failed") }
)
val sut = createRustLinkDesktopHandler(
handler,
)
sut.linkDesktopStep.test {
val initialItem = awaitItem()
assertThat(initialItem).isEqualTo(LinkDesktopStep.Uninitialized)
backgroundScope.launch {
sut.handleScannedQrCode(QR_CODE_DATA)
}
runCurrent()
val errorState = awaitItem()
assertThat(errorState).isInstanceOf(LinkDesktopStep.InvalidQrCode::class.java)
}
}
@Test
fun `when handleScannedQrCode throws HumanQrGrantLoginException, the handler emits error step`() = runTest {
val handler = FakeFfiGrantLoginWithQrCodeHandler(
scanResult = { throw HumanQrGrantLoginException.InvalidCheckCode("Invalid check code") }
)
val sut = createRustLinkDesktopHandler(
handler,
)
sut.linkDesktopStep.test {
val initialItem = awaitItem()
assertThat(initialItem).isEqualTo(LinkDesktopStep.Uninitialized)
backgroundScope.launch {
sut.handleScannedQrCode(QR_CODE_DATA)
}
runCurrent()
val errorState = awaitItem()
assertThat(errorState).isInstanceOf(LinkDesktopStep.Error::class.java)
val errorType = (errorState as LinkDesktopStep.Error).errorType
assertThat(errorType).isInstanceOf(ErrorType.InvalidCheckCode::class.java)
}
}
private fun TestScope.createRustLinkDesktopHandler(
handler: FakeFfiGrantLoginWithQrCodeHandler = FakeFfiGrantLoginWithQrCodeHandler(),
) = RustLinkDesktopHandler(
inner = handler,
sessionCoroutineScope = backgroundScope,
sessionDispatcher = StandardTestDispatcher(testScheduler),
qrCodeDataParser = FakeQrCodeDataParser(),
)
}

View file

@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 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.libraries.matrix.impl.linknewdevice
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiCheckCodeSender
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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgress
import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException
class RustLinkMobileHandlerTest {
@Test
fun `start function works as expected`() = runTest {
val handler = FakeFfiGrantLoginWithQrCodeHandler()
val sut = createRustLinkMobileHandler(
handler,
)
sut.linkMobileStep.test {
val initialItem = awaitItem()
assertThat(initialItem).isEqualTo(LinkMobileStep.Uninitialized)
backgroundScope.launch {
sut.start()
}
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.WaitingForAuth("aVerificationUri")
to LinkMobileStep.WaitingForAuth::class.java,
GrantGeneratedQrLoginProgress.QrScanned(FakeFfiCheckCodeSender())
to 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) ->
handler.emitGenerateProgress(progress)
assertThat(awaitItem()).isInstanceOf(expectedStepClass)
}
}
}
@Test
fun `when start throws HumanQrGrantLoginException, the handler emits error step`() = runTest {
val handler = FakeFfiGrantLoginWithQrCodeHandler(
generateResult = { throw HumanQrGrantLoginException.NotFound("Timeout") }
)
val sut = createRustLinkMobileHandler(
handler,
)
sut.linkMobileStep.test {
val initialItem = awaitItem()
assertThat(initialItem).isEqualTo(LinkMobileStep.Uninitialized)
backgroundScope.launch {
sut.start()
}
runCurrent()
val errorState = awaitItem()
assertThat(errorState).isInstanceOf(LinkMobileStep.Error::class.java)
val errorType = (errorState as LinkMobileStep.Error).errorType
assertThat(errorType).isInstanceOf(ErrorType.NotFound::class.java)
}
}
private fun TestScope.createRustLinkMobileHandler(
handler: FakeFfiGrantLoginWithQrCodeHandler = FakeFfiGrantLoginWithQrCodeHandler(),
) = RustLinkMobileHandler(
inner = handler,
sessionCoroutineScope = backgroundScope,
sessionDispatcher = StandardTestDispatcher(testScheduler),
)
}

View file

@ -19,6 +19,8 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.notification.NotificationService
@ -96,6 +98,9 @@ class FakeMatrixClient(
private val deactivateAccountResult: (String, Boolean) -> Result<Unit> = { _, _ -> lambdaError() },
private val currentSlidingSyncVersionLambda: () -> Result<SlidingSyncVersion> = { lambdaError() },
private val ignoreUserResult: (UserId) -> Result<Unit> = { lambdaError() },
private val canLinkNewDeviceResult: () -> Result<Boolean> = { lambdaError() },
private val createLinkMobileHandlerResult: () -> Result<LinkMobileHandler> = { lambdaError() },
private val createLinkDesktopHandlerResult: () -> Result<LinkDesktopHandler> = { lambdaError() },
private var unIgnoreUserResult: (UserId) -> Result<Unit> = { Result.success(Unit) },
private val canReportRoomLambda: () -> Boolean = { false },
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
@ -362,4 +367,16 @@ class FakeMatrixClient(
override suspend fun performDatabaseVacuum(): Result<Unit> {
return performDatabaseVacuumLambda()
}
override suspend fun canLinkNewDevice(): Result<Boolean> = simulateLongTask {
return canLinkNewDeviceResult()
}
override fun createLinkDesktopHandler(): Result<LinkDesktopHandler> {
return createLinkDesktopHandlerResult()
}
override fun createLinkMobileHandler(): Result<LinkMobileHandler> {
return createLinkMobileHandlerResult()
}
}

View file

@ -100,3 +100,31 @@ const val A_LOGIN_HINT = "mxid:@alice:example.org"
@ColorInt
const val A_COLOR_INT: Int = 0xFFFF0000.toInt()
// From https://github.com/matrix-org/matrix-rust-sdk/blob/3a63838cdb50cde3d74da920186fbae0a2e6db37/crates/matrix-sdk-crypto/src/types/qr_login.rs#L275
// Test vector for the QR code data, copied from the MSC.
@Suppress("ktlint:standard:argument-list-wrapping")
val QR_CODE_DATA = listOf(
0x4D, 0x41, 0x54, 0x52, 0x49, 0x58, 0x02, 0x03, 0xd8, 0x86, 0x68, 0x6a, 0xb2, 0x19, 0x7b,
0x78, 0x0e, 0x30, 0x0a, 0x9d, 0x4a, 0x21, 0x47, 0x48, 0x07, 0x00, 0xd7, 0x92, 0x9f, 0x39,
0xab, 0x31, 0xb9, 0xe5, 0x14, 0x37, 0x02, 0x48, 0xed, 0x6b, 0x00, 0x47, 0x68, 0x74, 0x74,
0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x72, 0x65, 0x6e, 0x64, 0x65, 0x7a, 0x76, 0x6f, 0x75, 0x73,
0x2e, 0x6c, 0x61, 0x62, 0x2e, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x64, 0x65,
0x76, 0x2f, 0x65, 0x38, 0x64, 0x61, 0x36, 0x33, 0x35, 0x35, 0x2d, 0x35, 0x35, 0x30, 0x62,
0x2d, 0x34, 0x61, 0x33, 0x32, 0x2d, 0x61, 0x31, 0x39, 0x33, 0x2d, 0x31, 0x36, 0x31, 0x39,
0x64, 0x39, 0x38, 0x33, 0x30, 0x36, 0x36, 0x38,
).map { it.toByte() }.toByteArray()
// Test vector for the QR code data, copied from the MSC, with the mode set to reciprocate.
@Suppress("ktlint:standard:argument-list-wrapping")
val QR_CODE_DATA_RECIPROCATE = listOf(
0x4D, 0x41, 0x54, 0x52, 0x49, 0x58, 0x02, 0x04, 0xd8, 0x86, 0x68, 0x6a, 0xb2, 0x19, 0x7b,
0x78, 0x0e, 0x30, 0x0a, 0x9d, 0x4a, 0x21, 0x47, 0x48, 0x07, 0x00, 0xd7, 0x92, 0x9f, 0x39,
0xab, 0x31, 0xb9, 0xe5, 0x14, 0x37, 0x02, 0x48, 0xed, 0x6b, 0x00, 0x47, 0x68, 0x74, 0x74,
0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x72, 0x65, 0x6e, 0x64, 0x65, 0x7a, 0x76, 0x6f, 0x75, 0x73,
0x2e, 0x6c, 0x61, 0x62, 0x2e, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x64, 0x65,
0x76, 0x2f, 0x65, 0x38, 0x64, 0x61, 0x36, 0x33, 0x35, 0x35, 0x2d, 0x35, 0x35, 0x30, 0x62,
0x2d, 0x34, 0x61, 0x33, 0x32, 0x2d, 0x61, 0x31, 0x39, 0x33, 0x2d, 0x31, 0x36, 0x31, 0x39,
0x64, 0x39, 0x38, 0x33, 0x30, 0x36, 0x36, 0x38, 0x00, 0x0A, 0x6d, 0x61, 0x74, 0x72, 0x69,
0x78, 0x2e, 0x6f, 0x72, 0x67,
).map { it.toByte() }.toByteArray()

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 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.test.linknewdevice
import io.element.android.libraries.matrix.api.linknewdevice.CheckCodeSender
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
class FakeCheckCodeSender(
private val validateResult: (UByte) -> Boolean = { lambdaError() },
private val sendResult: (UByte) -> Result<Unit> = { lambdaError() },
) : CheckCodeSender {
override suspend fun validate(code: UByte): Boolean = simulateLongTask {
validateResult(code)
}
override suspend fun send(code: UByte): Result<Unit> = simulateLongTask {
sendResult(code)
}
}

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 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.test.linknewdevice
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class FakeLinkDesktopHandler(
private val handleScannedQrCodeResult: (ByteArray) -> Unit = { lambdaError() },
) : LinkDesktopHandler {
private val mutableLinkDesktopStep: MutableStateFlow<LinkDesktopStep> = MutableStateFlow(LinkDesktopStep.Uninitialized)
override val linkDesktopStep: StateFlow<LinkDesktopStep>
get() = mutableLinkDesktopStep.asStateFlow()
override suspend fun handleScannedQrCode(data: ByteArray) {
handleScannedQrCodeResult(data)
}
suspend fun emitStep(step: LinkDesktopStep) {
mutableLinkDesktopStep.emit(step)
}
}

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 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.test.linknewdevice
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class FakeLinkMobileHandler(
private val startResult: () -> Unit = { lambdaError() },
) : LinkMobileHandler {
private val mutableLinkMobileStep: MutableStateFlow<LinkMobileStep> = MutableStateFlow(LinkMobileStep.Uninitialized)
override val linkMobileStep: StateFlow<LinkMobileStep>
get() = mutableLinkMobileStep.asStateFlow()
override suspend fun start() = simulateLongTask {
startResult()
}
suspend fun emitStep(step: LinkMobileStep) {
mutableLinkMobileStep.emit(step)
}
}

View file

@ -19,4 +19,5 @@ dependencies {
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.camera2)
implementation(libs.zxing.cpp)
implementation(libs.google.zxing)
}

View file

@ -31,6 +31,7 @@ import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
@ -117,7 +118,13 @@ fun QrCodeCameraView(
.background(color = ElementTheme.colors.bgSubtlePrimary),
contentAlignment = Alignment.Center,
) {
Text("CameraView")
Text(
text = buildString {
append("CameraView\n")
append(if (isScanning) "scanning" else "frozen")
},
textAlign = TextAlign.Center,
)
}
} else {
AndroidView(

View file

@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 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.qrcode
import android.graphics.Bitmap
import android.graphics.Color
import androidx.annotation.ColorInt
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntSize
import com.google.zxing.BarcodeFormat
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.QRCodeWriter
import io.element.android.libraries.designsystem.modifiers.squareSize
import io.element.android.libraries.designsystem.utils.ForceMaxBrightness
private fun String.toBitMatrix(size: Int): BitMatrix {
return QRCodeWriter().encode(
this,
BarcodeFormat.QR_CODE,
size,
size,
)
}
private fun BitMatrix.toBitmap(
@ColorInt backgroundColor: Int = Color.WHITE,
@ColorInt foregroundColor: Int = Color.BLACK,
): Bitmap {
val colorBuffer = IntArray(width * height)
var rowOffset = 0
for (y in 0 until height) {
for (x in 0 until width) {
val arrayIndex = x + rowOffset
colorBuffer[arrayIndex] = if (get(x, y)) foregroundColor else backgroundColor
}
rowOffset += width
}
return Bitmap.createBitmap(colorBuffer, width, height, Bitmap.Config.ARGB_8888)
}
@Composable
fun QrCodeImage(
data: String,
forceMaxBrightness: Boolean = true,
modifier: Modifier = Modifier,
) {
if (forceMaxBrightness) {
ForceMaxBrightness()
}
var size by remember { mutableStateOf(IntSize.Zero) }
Box(
modifier = modifier
.squareSize()
.onSizeChanged {
size = it
},
) {
val image = remember(data, size) {
val sideSide = maxOf(size.width, size.height).coerceAtLeast(128)
data.toBitMatrix(sideSide).toBitmap().asImageBitmap()
}
Image(
contentDescription = null,
bitmap = image,
)
}
}
@Composable
@Preview
internal fun QrCodeViewPreview() {
QrCodeImage(
modifier = Modifier.fillMaxHeight(),
data = "RANDOM_QRCODE_DATA",
)
}

View file

@ -434,32 +434,6 @@ Kas sa oled kindel, et soovid jätkata?"</string>
<string name="screen_create_poll_options_section_title">"Valikud"</string>
<string name="screen_create_poll_remove_accessibility_label">"Kustuta: %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Seadistused"</string>
<string name="screen_link_new_device_desktop_scanning_title">"Skaneeri QR-koodi"</string>
<string name="screen_link_new_device_desktop_step1">"Ava %1$s kas oma süle- või lauaarvutis"</string>
<string name="screen_link_new_device_desktop_step3">"Skaneeri QR-koodi selle seadmega"</string>
<string name="screen_link_new_device_desktop_submit">"Skaneerimiseks valmis"</string>
<string name="screen_link_new_device_desktop_title">"QR-koodi laadimiseks ava %1$s süle- või lauaarvutis"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numbrid ei klapi"</string>
<string name="screen_link_new_device_enter_number_notice">"Sisesta kahekohaline kood"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline."</string>
<string name="screen_link_new_device_enter_number_title">"Sisesta teises seadmes kuvatud number"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"Sinu teenusepakkuja ei toeta rakendust %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s pole toetatud"</string>
<string name="screen_link_new_device_error_not_supported_subtitle">"Sinu kasutajakonto teenusepakkuja ei toeta võimalust logida sisse QR-koodi abil."</string>
<string name="screen_link_new_device_error_not_supported_title">"QR-kood pole toetatud"</string>
<string name="screen_link_new_device_error_request_cancelled_subtitle">"Sisselogimine katkestati teises seadmes."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Sisselogimispäring on tühistatud"</string>
<string name="screen_link_new_device_error_request_timeout_subtitle">"Sisselogimine aegus. Palun proovi uuesti."</string>
<string name="screen_link_new_device_error_request_timeout_title">"Sisselogimine jäi etteantud aja jooksul tegemata"</string>
<string name="screen_link_new_device_mobile_step1">"Ava %1$s teises seadmes"</string>
<string name="screen_link_new_device_mobile_step2">"Vali %1$s"</string>
<string name="screen_link_new_device_mobile_step2_action">"„Logi sisse QR-koodiga“"</string>
<string name="screen_link_new_device_mobile_step3">"Skaneeri siin näidatud QR-koodi teise seadmega"</string>
<string name="screen_link_new_device_mobile_title">"Ava %1$s teises seadmes"</string>
<string name="screen_link_new_device_root_desktop_computer">"Lauaarvuti"</string>
<string name="screen_link_new_device_root_loading_qr_code">"Laadin QR-koodi…"</string>
<string name="screen_link_new_device_root_mobile_device">"Nutiseade"</string>
<string name="screen_link_new_device_root_title">"Mis tüüpi seadet soovid siduda?"</string>
<string name="screen_manage_authorized_spaces_header">"Kogukonnad, milles on võimalik jututoaga liituda ilma kutseta."</string>
<string name="screen_manage_authorized_spaces_title">"Halda kogukondi"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Tundmatu kogukond)"</string>

View file

@ -442,32 +442,6 @@ Jeste li sigurni da želite nastaviti?"</string>
<string name="screen_create_poll_options_section_title">"Mogućnosti"</string>
<string name="screen_create_poll_remove_accessibility_label">"Ukloni %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Postavke"</string>
<string name="screen_link_new_device_desktop_scanning_title">"Skeniraj QR kod"</string>
<string name="screen_link_new_device_desktop_step1">"Otvorite %1$s na prijenosnom ili stolnom računalu"</string>
<string name="screen_link_new_device_desktop_step3">"Skenirajte QR kod ovim uređajem"</string>
<string name="screen_link_new_device_desktop_submit">"Spremno za skeniranje"</string>
<string name="screen_link_new_device_desktop_title">"Otvorite %1$s na stolnom računalu kako biste dobili QR kod"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Brojevi se ne podudaraju"</string>
<string name="screen_link_new_device_enter_number_notice">"Unesite dvoznamenkasti kod"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Time ćete potvrditi da je veza s vašim drugim uređajem sigurna."</string>
<string name="screen_link_new_device_enter_number_title">"Unesite broj prikazan na vašem drugom uređaju"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"Vaš davatelj usluga računa ne podržava %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s nije podržan"</string>
<string name="screen_link_new_device_error_not_supported_subtitle">"Vaš davatelj usluga računa ne podržava prijavu na novi uređaj pomoću QR koda."</string>
<string name="screen_link_new_device_error_not_supported_title">"QR kod nije podržan"</string>
<string name="screen_link_new_device_error_request_cancelled_subtitle">"Prijava je otkazana na drugom uređaju."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Zahtjev za prijavu je otkazan"</string>
<string name="screen_link_new_device_error_request_timeout_subtitle">"Prijava je istekla. Pokušajte ponovno."</string>
<string name="screen_link_new_device_error_request_timeout_title">"Prijava nije dovršena na vrijeme"</string>
<string name="screen_link_new_device_mobile_step1">"Otvorite %1$s na drugom uređaju"</string>
<string name="screen_link_new_device_mobile_step2">"Odaberi %1$s"</string>
<string name="screen_link_new_device_mobile_step2_action">"“Prijavi se pomoću QR koda”"</string>
<string name="screen_link_new_device_mobile_step3">"Skenirajte ovdje prikazani QR kod drugim uređajem"</string>
<string name="screen_link_new_device_mobile_title">"Otvorite %1$s na drugom uređaju"</string>
<string name="screen_link_new_device_root_desktop_computer">"Stolno računalo"</string>
<string name="screen_link_new_device_root_loading_qr_code">"Učitavanje QR koda…"</string>
<string name="screen_link_new_device_root_mobile_device">"Mobilni uređaj"</string>
<string name="screen_link_new_device_root_title">"Koju vrstu uređaja želite povezati?"</string>
<string name="screen_manage_authorized_spaces_header">"Prostori u kojima se članovi mogu pridružiti sobi bez pozivnice."</string>
<string name="screen_manage_authorized_spaces_title">"Upravljaj prostorima"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(nepoznati prostor)"</string>

View file

@ -442,32 +442,6 @@ Sunteți sigur că doriți să continuați?"</string>
<string name="screen_create_poll_options_section_title">"Opțiuni"</string>
<string name="screen_create_poll_remove_accessibility_label">"Ștergeți %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Setări"</string>
<string name="screen_link_new_device_desktop_scanning_title">"Scanați codul QR"</string>
<string name="screen_link_new_device_desktop_step1">"Deschide %1$s pe un laptop sau un computer desktop"</string>
<string name="screen_link_new_device_desktop_step3">"Scanați codul QR cu acest dispozitiv"</string>
<string name="screen_link_new_device_desktop_submit">"Gata de scanare"</string>
<string name="screen_link_new_device_desktop_title">"Deschide %1$s pe un computer desktop pentru a obține codul QR"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numerele nu se potrivesc"</string>
<string name="screen_link_new_device_enter_number_notice">"Introduceți codul de 2 cifre"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Aceasta va verifica dacă conexiunea cu celălalt dispozitiv este sigură."</string>
<string name="screen_link_new_device_enter_number_title">"Introduceți numărul afișat pe celălalt dispozitiv"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"Furnizorul contului dumneavoastră nu acceptă %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s nu este acceptat"</string>
<string name="screen_link_new_device_error_not_supported_subtitle">"Furnizorul contului dumneavoastră nu acceptă conectarea la un dispozitiv nou cu un cod QR."</string>
<string name="screen_link_new_device_error_not_supported_title">"Codul QR nu este acceptat"</string>
<string name="screen_link_new_device_error_request_cancelled_subtitle">"Autentificarea a fost anulată de pe celălalt dispozitiv."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Cererea de autentificare a fost anulată"</string>
<string name="screen_link_new_device_error_request_timeout_subtitle">"Conectarea a expirat. Vă rugăm să încercați din nou."</string>
<string name="screen_link_new_device_error_request_timeout_title">"Conectarea nu a fost finalizată la timp"</string>
<string name="screen_link_new_device_mobile_step1">"Deschideți %1$s pe celălalt dispozitiv"</string>
<string name="screen_link_new_device_mobile_step2">"Selectați %1$s"</string>
<string name="screen_link_new_device_mobile_step2_action">"“Conectați-vă cu un cod QR”"</string>
<string name="screen_link_new_device_mobile_step3">"Scanați codul QR afișat aici cu celălalt dispozitiv."</string>
<string name="screen_link_new_device_mobile_title">"Deschideți %1$s pe celălalt dispozitiv"</string>
<string name="screen_link_new_device_root_desktop_computer">"Calculator desktop"</string>
<string name="screen_link_new_device_root_loading_qr_code">"Se încarcă codul QR…"</string>
<string name="screen_link_new_device_root_mobile_device">"Dispozitiv mobil"</string>
<string name="screen_link_new_device_root_title">"Ce tip de dispozitiv doriți să conectați?"</string>
<string name="screen_manage_authorized_spaces_header">"Spațile din care membrii se pot alătura camerei fără invitație."</string>
<string name="screen_manage_authorized_spaces_title">"Gestionați spațiile"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Spațiu necunoscut)"</string>

View file

@ -434,32 +434,6 @@ Are you sure you want to continue?"</string>
<string name="screen_create_poll_options_section_title">"Options"</string>
<string name="screen_create_poll_remove_accessibility_label">"Remove %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Settings"</string>
<string name="screen_link_new_device_desktop_scanning_title">"Scan the QR code"</string>
<string name="screen_link_new_device_desktop_step1">"Open %1$s on a laptop or desktop computer"</string>
<string name="screen_link_new_device_desktop_step3">"Scan the QR code with this device"</string>
<string name="screen_link_new_device_desktop_submit">"Ready to scan"</string>
<string name="screen_link_new_device_desktop_title">"Open %1$s on a desktop computer to get the QR code"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"The numbers dont match"</string>
<string name="screen_link_new_device_enter_number_notice">"Enter 2-digit code"</string>
<string name="screen_link_new_device_enter_number_subtitle">"This will verify that the connection to your other device is secure."</string>
<string name="screen_link_new_device_enter_number_title">"Enter the number shown on your other device"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"Your account provider does not support %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s not supported"</string>
<string name="screen_link_new_device_error_not_supported_subtitle">"Your account provider doesnt support signing into a new device with a QR code."</string>
<string name="screen_link_new_device_error_not_supported_title">"QR code not supported"</string>
<string name="screen_link_new_device_error_request_cancelled_subtitle">"The sign in was cancelled on the other device."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Sign in request cancelled"</string>
<string name="screen_link_new_device_error_request_timeout_subtitle">"Sign in expired. Please try again."</string>
<string name="screen_link_new_device_error_request_timeout_title">"The sign in was not completed in time"</string>
<string name="screen_link_new_device_mobile_step1">"Open %1$s on the other device"</string>
<string name="screen_link_new_device_mobile_step2">"Select %1$s"</string>
<string name="screen_link_new_device_mobile_step2_action">"“Sign in with QR code”"</string>
<string name="screen_link_new_device_mobile_step3">"Scan the QR code shown here with the other device"</string>
<string name="screen_link_new_device_mobile_title">"Open %1$s on the other device"</string>
<string name="screen_link_new_device_root_desktop_computer">"Desktop computer"</string>
<string name="screen_link_new_device_root_loading_qr_code">"Loading QR code…"</string>
<string name="screen_link_new_device_root_mobile_device">"Mobile device"</string>
<string name="screen_link_new_device_root_title">"What type of device do you want to link?"</string>
<string name="screen_manage_authorized_spaces_header">"Spaces where members can join the room without an invitation."</string>
<string name="screen_manage_authorized_spaces_title">"Manage spaces"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Unknown space)"</string>