Add user verification and verification state violation badges (#4392)

* Move `observeRoomMemberIdentityStateChange` and associated classes to `libs:matrixui` module so they can be reused

* Add `EncryptionService.getUserIdentity` method to retrieve not only if the user is verified or not, but in which state they are

* Fix `IdentityChangePresenter` after the previous changes

* Fix `withFakeLifecycleOwner` and add `testWithLifecycleOwner` helper

* Display verified badge in DM top app bar when possible

* Display a verification violation warning icon next to the 'People' item in room details screen

* Display either a verified badge or a verification violation warning icon next to the room members in the room member list screen

* Display either a verified badge or a verification violation warning and withdraw verification button in the room member profile.

Generic user profiles won't display verification state anymore since we can't easily track changes in it.

* Add preview for room member details screen with verification violation identity state

* Add verified and violation badge to the `Profile` list item in room details screen

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2025-03-12 12:22:53 +01:00 committed by GitHub
parent b0e6b50c79
commit fd50ce4daf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 889 additions and 364 deletions

View file

@ -10,10 +10,12 @@ package io.element.android.libraries.designsystem.components.list
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
@ -77,8 +79,9 @@ sealed interface ListItemContent {
/**
* Default Icon content for [ListItem]. Sets the Icon component to a predefined size.
* @param iconSource The icon to display, using [IconSource.getPainter].
* @param tintColor The tint color for the icon, if any. Defaults to `null`.
*/
data class Icon(val iconSource: IconSource) : ListItemContent
data class Icon(val iconSource: IconSource, val tintColor: Color? = null) : ListItemContent
/**
* Default Text content for [ListItem]. Sets the Text component to a max size and clips overflow.
@ -119,7 +122,8 @@ sealed interface ListItemContent {
IconComponent(
modifier = Modifier.size(maxCompactSize),
painter = iconSource.getPainter(),
contentDescription = iconSource.contentDescription
contentDescription = iconSource.contentDescription,
tint = tintColor ?: LocalContentColor.current,
)
}
is Text -> TextComponent(modifier = Modifier.widthIn(max = 128.dp), text = text, maxLines = 1, overflow = TextOverflow.Ellipsis)

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.api.encryption
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@ -75,6 +76,11 @@ interface EncryptionService {
* user trust from verified to TOFU verified.
*/
suspend fun withdrawVerification(userId: UserId): Result<Unit>
/**
* Get the identity state of a user, if known.
*/
suspend fun getUserIdentity(userId: UserId): Result<IdentityState?>
}
/**

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.impl.sync.RustSyncService
import kotlinx.coroutines.CoroutineScope
@ -202,18 +203,29 @@ internal class RustEncryptionService(
}
override suspend fun isUserVerified(userId: UserId): Result<Boolean> = runCatching {
getUserIdentity(userId).isVerified()
getUserIdentityInternal(userId).isVerified()
}
override suspend fun pinUserIdentity(userId: UserId): Result<Unit> = runCatching {
getUserIdentity(userId).pin()
getUserIdentityInternal(userId).pin()
}
override suspend fun withdrawVerification(userId: UserId): Result<Unit> = runCatching {
getUserIdentity(userId).withdrawVerification()
getUserIdentityInternal(userId).withdrawVerification()
}
private suspend fun getUserIdentity(userId: UserId): UserIdentity {
override suspend fun getUserIdentity(userId: UserId): Result<IdentityState?> = runCatching {
val identity = getUserIdentityInternal(userId)
val isVerified = identity.isVerified()
when {
identity.hasVerificationViolation() -> IdentityState.VerificationViolation
isVerified -> IdentityState.Verified
!isVerified -> IdentityState.Pinned
else -> null
}
}
suspend fun getUserIdentityInternal(userId: UserId): UserIdentity {
return service.userIdentity(
userId = userId.value,
// requestFromHomeserverIfNeeded = true,

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
@ -25,6 +26,7 @@ class FakeEncryptionService(
private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() },
private val isUserVerifiedResult: (UserId) -> Result<Boolean> = { lambdaError() },
private val withdrawVerificationResult: (UserId) -> Result<Unit> = { lambdaError() },
private val getUserIdentityResult: (UserId) -> Result<IdentityState?> = { lambdaError() }
) : EncryptionService {
private var disableRecoveryFailure: Exception? = null
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
@ -133,6 +135,10 @@ class FakeEncryptionService(
isUserVerifiedResult(userId)
}
override suspend fun getUserIdentity(userId: UserId): Result<IdentityState?> = simulateLongTask {
return getUserIdentityResult(userId)
}
companion object {
const val FAKE_RECOVERY_KEY = "fake"
}

View file

@ -0,0 +1,89 @@
/*
* 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.ui.room
import androidx.compose.runtime.ProduceStateScope
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.ui.model.getAvatarData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@OptIn(ExperimentalCoroutinesApi::class)
fun MatrixRoom.roomMemberIdentityStateChange(): Flow<ImmutableList<RoomMemberIdentityStateChange>> {
return syncUpdateFlow
.filter {
// Room cannot become unencrypted, so we can just apply a filter here.
isEncrypted
}
.distinctUntilChanged()
.flatMapLatest {
combine(identityStateChangesFlow, membersStateFlow) { identityStateChanges, membersState ->
identityStateChanges.map { identityStateChange ->
val member = membersState.roomMembers()
?.find { roomMember -> roomMember.userId == identityStateChange.userId }
?.toIdentityRoomMember()
?: createDefaultRoomMemberForIdentityChange(identityStateChange.userId)
RoomMemberIdentityStateChange(
identityRoomMember = member,
identityState = identityStateChange.identityState,
)
}.toPersistentList()
}.distinctUntilChanged()
}
}
fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange(room: MatrixRoom) {
room.roomMemberIdentityStateChange()
.onEach { roomMemberIdentityStateChanges ->
value = roomMemberIdentityStateChanges.toPersistentList()
}
.launchIn(this)
}
private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember(
userId = userId,
displayNameOrDefault = displayNameOrDefault,
avatarData = getAvatarData(AvatarSize.ComposerAlert),
)
private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityRoomMember(
userId = userId,
displayNameOrDefault = userId.extractedDisplayName,
avatarData = AvatarData(
id = userId.value,
name = null,
url = null,
size = AvatarSize.ComposerAlert,
),
)
data class RoomMemberIdentityStateChange(
val identityRoomMember: IdentityRoomMember,
val identityState: IdentityState,
)
data class IdentityRoomMember(
val userId: UserId,
val displayNameOrDefault: String,
val avatarData: AvatarData,
)