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:
parent
6f54de58f3
commit
b91933625c
145 changed files with 1662 additions and 830 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,4 +61,6 @@ enum class AvatarSize(val dp: Dp) {
|
|||
MediaSender(32.dp),
|
||||
|
||||
DmCreationConfirmation(64.dp),
|
||||
|
||||
UserVerification(52.dp),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -11,4 +11,8 @@ plugins {
|
|||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.ui.strings"
|
||||
|
||||
lint {
|
||||
disable += "Typos"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue