Implement user verification (#4294)

* Add support for starting verification of a user

* Add support for replying to incoming user verification requests

* Add reset recovery key button and previews to `ChooseSelfVerificationModeView`

* Add 'Profile' item in room details screen

* Update screenshots

* Remove `showDeviceVerifiedScreen` parameter from `NavTarget.UseAnotherDevice`

* Allow exiting the FTUE flow, which will close the app. The previous state will be restored when the app is reopened.

* When outgoing verification fails, move to the `Canceled` state. Then, when resetting the state machine state also reset the verification service.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2025-03-10 11:20:17 +01:00 committed by GitHub
parent 6f54de58f3
commit b91933625c
145 changed files with 1662 additions and 830 deletions

View file

@ -32,6 +32,7 @@ android {
implementation(libs.coil.compose)
implementation(libs.vanniktech.blurhash)
implementation(projects.features.enterprise.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.preferences.api)

View file

@ -33,6 +33,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.ui.strings.CommonStrings
@ -78,6 +79,11 @@ object BigIcon {
* A success style with a tinted background.
*/
data object SuccessSolid : Style
/**
* A loading style with the default background color.
*/
data object Loading : Style
}
/**
@ -101,31 +107,7 @@ object BigIcon {
Style.Success -> Color.Transparent
Style.AlertSolid -> ElementTheme.colors.bgCriticalSubtle
Style.SuccessSolid -> ElementTheme.colors.bgSuccessSubtle
}
val icon = when (style) {
is Style.Default -> style.vectorIcon
Style.Alert,
Style.AlertSolid -> CompoundIcons.ErrorSolid()
Style.Success,
Style.SuccessSolid -> CompoundIcons.CheckCircleSolid()
}
val contentDescription = when (style) {
is Style.Default -> style.contentDescription
Style.Alert,
Style.AlertSolid -> stringResource(CommonStrings.common_error)
Style.Success,
Style.SuccessSolid -> stringResource(CommonStrings.common_success)
}
val iconTint = when (style) {
is Style.Default -> if (style.useCriticalTint) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconSecondary
}
Style.Alert,
Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary
Style.Success,
Style.SuccessSolid -> ElementTheme.colors.iconSuccessPrimary
Style.Loading -> ElementTheme.colors.bgSubtleSecondary
}
Box(
modifier = modifier
@ -134,12 +116,50 @@ object BigIcon {
.background(backgroundColor),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(32.dp),
tint = iconTint,
imageVector = icon,
contentDescription = contentDescription
)
if (style is Style.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(27.dp),
color = ElementTheme.colors.iconSecondary,
trackColor = Color.Transparent,
strokeWidth = 3.dp,
)
} else {
val icon = when (style) {
is Style.Default -> style.vectorIcon
Style.Alert,
Style.AlertSolid -> CompoundIcons.ErrorSolid()
Style.Success,
Style.SuccessSolid -> CompoundIcons.CheckCircleSolid()
Style.Loading -> error("This should never be reached")
}
val contentDescription = when (style) {
is Style.Default -> style.contentDescription
Style.Alert,
Style.AlertSolid -> stringResource(CommonStrings.common_error)
Style.Success,
Style.SuccessSolid -> stringResource(CommonStrings.common_success)
Style.Loading -> error("This should never be reached")
}
val iconTint = when (style) {
is Style.Default -> if (style.useCriticalTint) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconSecondary
}
Style.Alert,
Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary
Style.Success,
Style.SuccessSolid -> ElementTheme.colors.iconSuccessPrimary
Style.Loading -> error("This should never be reached")
}
Icon(
modifier = Modifier.size(32.dp),
tint = iconTint,
imageVector = icon,
contentDescription = contentDescription
)
}
}
}
}
@ -173,6 +193,7 @@ internal class BigIconStyleProvider : PreviewParameterProvider<BigIcon.Style> {
BigIcon.Style.AlertSolid,
BigIcon.Style.Default(Icons.Filled.CatchingPokemon, useCriticalTint = true),
BigIcon.Style.Success,
BigIcon.Style.SuccessSolid
BigIcon.Style.SuccessSolid,
BigIcon.Style.Loading,
)
}

View file

@ -61,4 +61,6 @@ enum class AvatarSize(val dp: Dp) {
MediaSender(32.dp),
DmCreationConfirmation(64.dp),
UserVerification(52.dp),
}

View file

@ -0,0 +1,29 @@
/*
* Copyright 2025 New Vector 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.LaunchedEffect
import androidx.compose.runtime.MutableState
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
@Suppress("MutableStateParam")
@Composable
fun OpenUrlInTabView(url: MutableState<String?>) {
val activity = requireNotNull(LocalActivity.current)
val darkTheme = ElementTheme.isLightTheme.not()
LaunchedEffect(url.value) {
url.value?.let {
activity.openUrlInChromeCustomTab(null, darkTheme, it)
url.value = null
}
}
}

View file

@ -15,9 +15,15 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class SessionVerificationRequestDetails(
val senderId: UserId,
val senderProfile: SenderProfile,
val flowId: FlowId,
val deviceId: DeviceId,
val displayName: String?,
val firstSeenTimestamp: Long,
) : Parcelable
) : Parcelable {
@Parcelize
data class SenderProfile(
val userId: UserId,
val displayName: String?,
val avatarUrl: String?,
) : Parcelable
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.api.verification
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@ -31,7 +32,12 @@ interface SessionVerificationService {
/**
* Request verification of the current session.
*/
suspend fun requestVerification()
suspend fun requestCurrentSessionVerification()
/**
* Request verification of the user with the given [userId].
*/
suspend fun requestUserVerification(userId: UserId)
/**
* Cancels the current verification attempt.
@ -67,7 +73,7 @@ interface SessionVerificationService {
* Set this particular request as the currently active one and register for
* events pertaining it.
*/
suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails)
suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming)
/**
* Accept the previously acknowledged verification request.
@ -76,7 +82,7 @@ interface SessionVerificationService {
}
interface SessionVerificationServiceListener {
fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails)
fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming)
}
/** Verification status of the current session. */

View file

@ -0,0 +1,30 @@
/*
* Copyright 2025 New Vector 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.verification
import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize
sealed interface VerificationRequest : Parcelable {
sealed interface Outgoing : VerificationRequest {
@Parcelize
data object CurrentSession : Outgoing
@Parcelize
data class User(val userId: UserId) : Outgoing
}
sealed class Incoming(open val details: SessionVerificationRequestDetails) : VerificationRequest {
@Parcelize
data class OtherSession(override val details: SessionVerificationRequestDetails) : Incoming(details)
@Parcelize
data class User(override val details: SessionVerificationRequestDetails) : Incoming(details)
}
}

View file

@ -8,13 +8,14 @@
package io.element.android.libraries.matrix.impl.verification
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable
@ -50,6 +51,8 @@ class RustSessionVerificationService(
isSyncServiceReady: Flow<Boolean>,
private val sessionCoroutineScope: CoroutineScope,
) : SessionVerificationService, SessionVerificationControllerDelegate {
private var currentVerificationRequest: VerificationRequest? = null
private val encryptionService: Encryption = client.encryption()
private lateinit var verificationController: SessionVerificationController
@ -88,10 +91,8 @@ class RustSessionVerificationService(
verificationStatus == SessionVerifiedStatus.NotVerified
}
private var isOwnVerification = true
override fun didReceiveVerificationRequest(details: RustSessionVerificationRequestDetails) {
listener?.onIncomingSessionRequest(details.map())
listener?.onIncomingSessionRequest(details.toVerificationRequest(UserId(client.userId())))
}
private var listener: SessionVerificationServiceListener? = null
@ -111,9 +112,16 @@ class RustSessionVerificationService(
this.listener = listener
}
override suspend fun requestVerification() = tryOrFail {
override suspend fun requestCurrentSessionVerification() = tryOrFail {
initVerificationControllerIfNeeded()
verificationController.requestDeviceVerification()
currentVerificationRequest = VerificationRequest.Outgoing.CurrentSession
}
override suspend fun requestUserVerification(userId: UserId) = tryOrFail {
initVerificationControllerIfNeeded()
verificationController.requestUserVerification(userId.value)
currentVerificationRequest = VerificationRequest.Outgoing.User(userId)
}
override suspend fun cancelVerification() = tryOrFail {
@ -130,16 +138,16 @@ class RustSessionVerificationService(
verificationController.startSasVerification()
}
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) = tryOrFail {
isOwnVerification = false
override suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) = tryOrFail {
initVerificationControllerIfNeeded()
verificationController.acknowledgeVerificationRequest(
senderId = details.senderId.value,
flowId = details.flowId.value,
senderId = verificationRequest.details.senderProfile.userId.value,
flowId = verificationRequest.details.flowId.value,
)
}
override suspend fun acceptVerificationRequest() = tryOrFail {
Timber.d("Accepting incoming verification request")
verificationController.acceptVerificationRequest()
}
@ -183,7 +191,7 @@ class RustSessionVerificationService(
}
}
.onSuccess {
if (isOwnVerification) {
if (currentVerificationRequest is VerificationRequest.Outgoing.CurrentSession) {
// Try waiting for the final recovery state for better UX, but don't block the verification state on it
tryOrNull {
withTimeout(10.seconds) {
@ -215,7 +223,7 @@ class RustSessionVerificationService(
// end-region
override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
isOwnVerification = true
currentVerificationRequest = null
if (isReady.value && cancelAnyPendingVerificationAttempt) {
// Cancel any pending verification attempt
tryOrNull { verificationController.cancelVerification() }

View file

@ -11,12 +11,28 @@ import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
import org.matrix.rustcomponents.sdk.UserProfile as RustUserProfile
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
senderId = UserId(senderProfile.userId),
senderProfile = senderProfile.map(),
flowId = FlowId(flowId),
deviceId = DeviceId(deviceId),
displayName = senderProfile.displayName,
firstSeenTimestamp = firstSeenTimestamp.toLong(),
)
fun RustUserProfile.map() = SessionVerificationRequestDetails.SenderProfile(
userId = UserId(userId),
displayName = displayName,
avatarUrl = avatarUrl,
)
fun RustSessionVerificationRequestDetails.toVerificationRequest(currentUserId: UserId): VerificationRequest.Incoming {
val details = map()
return if (currentUserId == details.senderProfile.userId) {
VerificationRequest.Incoming.OtherSession(details)
} else {
VerificationRequest.Incoming.User(details)
}
}

View file

@ -7,11 +7,12 @@
package io.element.android.libraries.matrix.test.verification
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
@ -20,13 +21,14 @@ import kotlinx.coroutines.flow.StateFlow
class FakeSessionVerificationService(
initialSessionVerifiedStatus: SessionVerifiedStatus = SessionVerifiedStatus.Unknown,
private val requestVerificationLambda: () -> Unit = { lambdaError() },
private val requestCurrentSessionVerificationLambda: () -> Unit = { lambdaError() },
private val requestUserVerificationLambda: (UserId) -> Unit = { lambdaError() },
private val cancelVerificationLambda: () -> Unit = { lambdaError() },
private val approveVerificationLambda: () -> Unit = { lambdaError() },
private val declineVerificationLambda: () -> Unit = { lambdaError() },
private val startVerificationLambda: () -> Unit = { lambdaError() },
private val resetLambda: (Boolean) -> Unit = { lambdaError() },
private val acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() },
private val acknowledgeVerificationRequestLambda: (VerificationRequest.Incoming) -> Unit = { lambdaError() },
private val acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
) : SessionVerificationService {
private val _sessionVerifiedStatus = MutableStateFlow(initialSessionVerifiedStatus)
@ -37,8 +39,12 @@ class FakeSessionVerificationService(
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus
override val needsSessionVerification: Flow<Boolean> = _needsSessionVerification
override suspend fun requestVerification() {
requestVerificationLambda()
override suspend fun requestCurrentSessionVerification() {
requestCurrentSessionVerificationLambda()
}
override suspend fun requestUserVerification(userId: UserId) {
requestUserVerificationLambda(userId)
}
override suspend fun cancelVerification() {
@ -68,8 +74,8 @@ class FakeSessionVerificationService(
this.listener = listener
}
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) {
acknowledgeVerificationRequestLambda(details)
override suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) {
acknowledgeVerificationRequestLambda(verificationRequest)
}
override suspend fun acceptVerificationRequest() = simulateLongTask {

View file

@ -11,4 +11,8 @@ plugins {
android {
namespace = "io.element.android.libraries.ui.strings"
lint {
disable += "Typos"
}
}