Link new device using QrCode.

This commit is contained in:
Benoit Marty 2025-12-04 10:52:26 +01:00 committed by Benoit Marty
parent 5ebb615751
commit a073117d62
94 changed files with 4431 additions and 36 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

@ -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
@ -195,6 +197,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

@ -25,6 +25,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
@ -45,6 +47,8 @@ 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.mapper.map
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
@ -726,6 +730,34 @@ 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,
)
}
}
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,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.QrCodeData
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,
) : 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 = QrCodeData.fromBytes(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

@ -18,6 +18,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
@ -95,6 +97,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 },
@ -356,4 +361,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 @@ 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>