Merge pull request #4824 from element-hq/feature/fga/fix_identity_change

fix (identity change) :  RoomMemberIdentityStateChange in non encrypted room
This commit is contained in:
ganfra 2025-06-05 11:16:56 +02:00 committed by GitHub
commit 45113818eb
6 changed files with 437 additions and 45 deletions

View file

@ -15,7 +15,7 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.ui.room.observeRoomMemberIdentityStateChange import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -30,7 +30,7 @@ class IdentityChangeStatePresenter @Inject constructor(
override fun present(): IdentityChangeState { override fun present(): IdentityChangeState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val roomMemberIdentityStateChange by produceState(persistentListOf()) { val roomMemberIdentityStateChange by produceState(persistentListOf()) {
observeRoomMemberIdentityStateChange(room) room.roomMemberIdentityStateChange(waitForEncryption = true).collect { value = it }
} }
fun handleEvent(event: IdentityChangeEvent) { fun handleEvent(event: IdentityChangeEvent) {

View file

@ -174,7 +174,7 @@ class RoomDetailsPresenter @Inject constructor(
} }
val hasMemberVerificationViolations by produceState(false) { val hasMemberVerificationViolations by produceState(false) {
room.roomMemberIdentityStateChange() room.roomMemberIdentityStateChange(waitForEncryption = true)
.onEach { identities -> value = identities.any { it.identityState == IdentityState.VerificationViolation } } .onEach { identities -> value = identities.any { it.identityState == IdentityState.VerificationViolation } }
.launchIn(this) .launchIn(this)
} }

View file

@ -66,7 +66,7 @@ class RoomMemberListPresenter @Inject constructor(
val roomModerationState = roomMembersModerationPresenter.present() val roomModerationState = roomMembersModerationPresenter.present()
val roomMemberIdentityStates by produceState(persistentMapOf<UserId, IdentityState>()) { val roomMemberIdentityStates by produceState(persistentMapOf<UserId, IdentityState>()) {
room.roomMemberIdentityStateChange() room.roomMemberIdentityStateChange(waitForEncryption = true)
.onEach { identities -> .onEach { identities ->
value = identities.associateBy({ it.identityRoomMember.userId }, { it.identityState }).toPersistentMap() value = identities.associateBy({ it.identityRoomMember.userId }, { it.identityState }).toPersistentMap()
} }

View file

@ -9,6 +9,7 @@ package io.element.android.features.roomdetails.impl.members.details
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -33,9 +34,7 @@ import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -86,31 +85,30 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
val userProfileState = userProfilePresenter.present() val userProfileState = userProfilePresenter.present()
val identityStateChanges by produceState<IdentityStateChange?>(initialValue = null) { val identityStateChanges = produceState<IdentityStateChange?>(initialValue = null) {
room.roomInfoFlow.filter { it.isEncrypted == true } // Fetch the initial identity state manually
.flatMapLatest { val identityState = encryptionService.getUserIdentity(roomMemberId).getOrNull()
// Fetch the initial identity state manually value = identityState?.let { IdentityStateChange(roomMemberId, it) }
val identityState = encryptionService.getUserIdentity(roomMemberId).getOrNull()
value = identityState?.let { IdentityStateChange(roomMemberId, it) }
// Subscribe to the identity changes // Subscribe to the identity changes
room.roomMemberIdentityStateChange() room.roomMemberIdentityStateChange(waitForEncryption = false)
.map { it.find { it.identityRoomMember.userId == roomMemberId } } .map { it.find { it.identityRoomMember.userId == roomMemberId } }
.map { roomMemberIdentityStateChange -> .map { roomMemberIdentityStateChange ->
// If we didn't receive any info, manually fetch it // If we didn't receive any info, manually fetch it
roomMemberIdentityStateChange?.identityState ?: encryptionService.getUserIdentity(roomMemberId).getOrNull() roomMemberIdentityStateChange?.identityState ?: encryptionService.getUserIdentity(roomMemberId).getOrNull()
}
.filterNotNull()
} }
.filterNotNull()
.collect { value = IdentityStateChange(roomMemberId, it) } .collect { value = IdentityStateChange(roomMemberId, it) }
} }
val verificationState = remember(identityStateChanges) { val verificationState by remember {
when (identityStateChanges?.identityState) { derivedStateOf {
IdentityState.VerificationViolation -> UserProfileVerificationState.VERIFICATION_VIOLATION when (identityStateChanges.value?.identityState) {
IdentityState.Verified -> UserProfileVerificationState.VERIFIED IdentityState.VerificationViolation -> UserProfileVerificationState.VERIFICATION_VIOLATION
IdentityState.Pinned, IdentityState.PinViolation -> UserProfileVerificationState.UNVERIFIED IdentityState.Verified -> UserProfileVerificationState.VERIFIED
else -> UserProfileVerificationState.UNKNOWN IdentityState.Pinned, IdentityState.PinViolation -> UserProfileVerificationState.UNVERIFIED
else -> UserProfileVerificationState.UNKNOWN
}
} }
} }

View file

@ -7,7 +7,6 @@
package io.element.android.libraries.matrix.ui.room 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.core.UserId
@ -17,25 +16,25 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun JoinedRoom.roomMemberIdentityStateChange(): Flow<ImmutableList<RoomMemberIdentityStateChange>> { fun JoinedRoom.roomMemberIdentityStateChange(waitForEncryption: Boolean): Flow<ImmutableList<RoomMemberIdentityStateChange>> {
return roomInfoFlow val encryptionChangeFlow = flow {
.filter { if (waitForEncryption) {
// Room cannot become unencrypted, so we can just apply a filter here. // Room cannot become unencrypted, so it's ok to use first here
it.isEncrypted == true roomInfoFlow.first { roomInfo -> roomInfo.isEncrypted == true }
} }
.distinctUntilChanged() emit(Unit)
}
return encryptionChangeFlow
.flatMapLatest { .flatMapLatest {
combine(identityStateChangesFlow, membersStateFlow) { identityStateChanges, membersState -> combine(identityStateChangesFlow, membersStateFlow) { identityStateChanges, membersState ->
identityStateChanges.map { identityStateChange -> identityStateChanges.map { identityStateChange ->
@ -52,14 +51,6 @@ fun JoinedRoom.roomMemberIdentityStateChange(): Flow<ImmutableList<RoomMemberIde
} }
} }
fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange(room: JoinedRoom) {
room.roomMemberIdentityStateChange()
.onEach { roomMemberIdentityStateChanges ->
value = roomMemberIdentityStateChanges.toPersistentList()
}
.launchIn(this)
}
private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember( private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember(
userId = userId, userId = userId,
displayNameOrDefault = displayNameOrDefault, displayNameOrDefault = displayNameOrDefault,

View file

@ -0,0 +1,403 @@
/*
* 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 app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ObserveRoomMemberIdentityStateChangeTest {
private val aliceRoomMember = aRoomMember(A_USER_ID, displayName = "Alice")
private val bobRoomMember = aRoomMember(A_USER_ID_2, displayName = "Bob")
private val carolRoomMember = aRoomMember(A_USER_ID_3, displayName = "Carol")
@Test
fun `roomMemberIdentityStateChange emits empty list for non-encrypted room with no identity changes`() =
runTest {
val identityStateChangesFlow = MutableStateFlow<List<IdentityStateChange>>(emptyList())
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = false))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test {
val result = awaitItem()
assertThat(result).isEmpty()
}
}
@Test
fun `roomMemberIdentityStateChange emits identity changes for non-encrypted room when waitForEncryption is false`() =
runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(
IdentityStateChange(bobRoomMember.userId, IdentityState.Verified),
IdentityStateChange(carolRoomMember.userId, IdentityState.PinViolation)
)
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = false))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember,
carolRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test {
val result = awaitItem()
assertThat(result).hasSize(2)
val bobChange = result.find { it.identityRoomMember.userId == bobRoomMember.userId }
assertThat(bobChange).isNotNull()
assertThat(bobChange?.identityState).isEqualTo(IdentityState.Verified)
assertThat(bobChange?.identityRoomMember?.displayNameOrDefault).isEqualTo("Bob")
val carolChange = result.find { it.identityRoomMember.userId == carolRoomMember.userId }
assertThat(carolChange).isNotNull()
assertThat(carolChange?.identityState).isEqualTo(IdentityState.PinViolation)
assertThat(carolChange?.identityRoomMember?.displayNameOrDefault).isEqualTo("Carol")
}
}
@Test
fun `roomMemberIdentityStateChange emits identity changes for already encrypted room`() =
runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(
IdentityStateChange(bobRoomMember.userId, IdentityState.VerificationViolation)
)
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = true))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = true).test {
val result = awaitItem()
assertThat(result).hasSize(1)
val bobChange = result.first()
assertThat(bobChange.identityRoomMember.userId).isEqualTo(bobRoomMember.userId)
assertThat(bobChange.identityState).isEqualTo(IdentityState.VerificationViolation)
assertThat(bobChange.identityRoomMember.displayNameOrDefault).isEqualTo("Bob")
}
}
@Test
fun `roomMemberIdentityStateChange waits for encryption before emitting when waitForEncryption is true`() =
runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(IdentityStateChange(bobRoomMember.userId, IdentityState.Pinned))
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = false))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = true).test {
// Should not emit anything yet since room is not encrypted
expectNoEvents()
// Enable encryption
joinedRoom.baseRoom.givenRoomInfo(aRoomInfo(isEncrypted = true))
val result = awaitItem()
assertThat(result).hasSize(1)
assertThat(result.first().identityRoomMember.userId).isEqualTo(bobRoomMember.userId)
assertThat(result.first().identityState).isEqualTo(IdentityState.Pinned)
}
}
@Test
fun `roomMemberIdentityStateChange creates default member when room member not found`() =
runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(IdentityStateChange(carolRoomMember.userId, IdentityState.PinViolation))
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = true))
// Only include aliceRoomMember and bobRoomMember, not carolRoomMember
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test {
val result = awaitItem()
assertThat(result).hasSize(1)
val carolChange = result.first()
assertThat(carolChange.identityRoomMember.userId).isEqualTo(carolRoomMember.userId)
assertThat(carolChange.identityState).isEqualTo(IdentityState.PinViolation)
// Should use extracted display name from user ID since member not found
assertThat(carolChange.identityRoomMember.displayNameOrDefault).isEqualTo(
carolRoomMember.userId.extractedDisplayName
)
}
}
@Test
fun `roomMemberIdentityStateChange updates when identity state changes`() = runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(IdentityStateChange(bobRoomMember.userId, IdentityState.Pinned))
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = true))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test {
val firstResult = awaitItem()
assertThat(firstResult).hasSize(1)
assertThat(firstResult.first().identityState).isEqualTo(IdentityState.Pinned)
// Update identity state
identityStateChangesFlow.value = listOf(
IdentityStateChange(bobRoomMember.userId, IdentityState.VerificationViolation)
)
val secondResult = awaitItem()
assertThat(secondResult).hasSize(1)
assertThat(secondResult.first().identityState).isEqualTo(IdentityState.VerificationViolation)
}
}
@Test
fun `roomMemberIdentityStateChange updates when members state changes`() = runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(IdentityStateChange(bobRoomMember.userId, IdentityState.Verified))
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = true))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test {
val firstResult = awaitItem()
assertThat(firstResult).hasSize(1)
assertThat(firstResult.first().identityRoomMember.displayNameOrDefault).isEqualTo("Bob")
// Update room member with different display name
val updatedMember2 = bobRoomMember.copy(displayName = "Bobby")
joinedRoom.baseRoom.givenRoomMembersState(
RoomMembersState.Ready(persistentListOf(aliceRoomMember, updatedMember2))
)
val secondResult = awaitItem()
assertThat(secondResult).hasSize(1)
assertThat(secondResult.first().identityRoomMember.displayNameOrDefault).isEqualTo("Bobby")
}
}
@Test
fun `roomMemberIdentityStateChange handles multiple identity states`() = runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(
IdentityStateChange(aliceRoomMember.userId, IdentityState.Verified),
IdentityStateChange(bobRoomMember.userId, IdentityState.PinViolation),
IdentityStateChange(carolRoomMember.userId, IdentityState.VerificationViolation)
)
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = true))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember,
carolRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test {
val result = awaitItem()
assertThat(result).hasSize(3)
val verifiedUser = result.find { it.identityState == IdentityState.Verified }
assertThat(verifiedUser?.identityRoomMember?.userId).isEqualTo(aliceRoomMember.userId)
val pinViolationUser = result.find { it.identityState == IdentityState.PinViolation }
assertThat(pinViolationUser?.identityRoomMember?.userId).isEqualTo(bobRoomMember.userId)
val verificationViolationUser =
result.find { it.identityState == IdentityState.VerificationViolation }
assertThat(verificationViolationUser?.identityRoomMember?.userId).isEqualTo(carolRoomMember.userId)
}
}
@Test
fun `roomMemberIdentityStateChange handles room becoming encrypted scenario`() = runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(IdentityStateChange(bobRoomMember.userId, IdentityState.Pinned))
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = false))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = true).test {
// Should not emit anything initially as room is not encrypted
expectNoEvents()
// Room becomes encrypted
joinedRoom.baseRoom.givenRoomInfo(aRoomInfo(isEncrypted = true))
val result = awaitItem()
assertThat(result).hasSize(1)
assertThat(result.first().identityRoomMember.userId).isEqualTo(bobRoomMember.userId)
assertThat(result.first().identityState).isEqualTo(IdentityState.Pinned)
// Add more identity changes after encryption is enabled
identityStateChangesFlow.value = listOf(
IdentityStateChange(bobRoomMember.userId, IdentityState.Pinned),
IdentityStateChange(aliceRoomMember.userId, IdentityState.VerificationViolation)
)
val updatedResult = awaitItem()
assertThat(updatedResult).hasSize(2)
}
}
@Test
fun `roomMemberIdentityStateChange does not emit duplicates for same state`() = runTest {
val identityStateChangesFlow = MutableSharedFlow<List<IdentityStateChange>>()
val identityStateChanges = listOf(
IdentityStateChange(bobRoomMember.userId, IdentityState.Verified)
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = true))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test {
identityStateChangesFlow.emit(identityStateChanges)
val firstResult = awaitItem()
assertThat(firstResult).hasSize(1)
// Emit the same state again
identityStateChangesFlow.emit(identityStateChanges)
// Should not emit a new item due to distinctUntilChanged
expectNoEvents()
}
}
}