Merge pull request #5909 from element-hq/feature/bma/qrCodeLogin
Link new device using QrCode - First version
This commit is contained in:
commit
3ea10c2c62
180 changed files with 5052 additions and 144 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -19,4 +19,5 @@ dependencies {
|
|||
implementation(libs.androidx.camera.view)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.zxing.cpp)
|
||||
implementation(libs.google.zxing)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 don’t 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 doesn’t 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue