diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt index 7b5f07649b5..1d3b9778c3e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt @@ -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.encryption.EncryptionService 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.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -30,7 +30,7 @@ class IdentityChangeStatePresenter @Inject constructor( override fun present(): IdentityChangeState { val coroutineScope = rememberCoroutineScope() val roomMemberIdentityStateChange by produceState(persistentListOf()) { - observeRoomMemberIdentityStateChange(room) + room.roomMemberIdentityStateChange(waitForEncryption = true).collect { value = it } } fun handleEvent(event: IdentityChangeEvent) { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index 51fda0f7bcb..505751c412b 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -174,7 +174,7 @@ class RoomDetailsPresenter @Inject constructor( } val hasMemberVerificationViolations by produceState(false) { - room.roomMemberIdentityStateChange() + room.roomMemberIdentityStateChange(waitForEncryption = true) .onEach { identities -> value = identities.any { it.identityState == IdentityState.VerificationViolation } } .launchIn(this) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt index ec63f383d3d..56268e69520 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -66,7 +66,7 @@ class RoomMemberListPresenter @Inject constructor( val roomModerationState = roomMembersModerationPresenter.present() val roomMemberIdentityStates by produceState(persistentMapOf()) { - room.roomMemberIdentityStateChange() + room.roomMemberIdentityStateChange(waitForEncryption = true) .onEach { identities -> value = identities.associateBy({ it.identityRoomMember.userId }, { it.identityState }).toPersistentMap() } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt index 9afe43daf2c..55126b33cda 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt @@ -9,6 +9,7 @@ package io.element.android.features.roomdetails.impl.members.details import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState 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.ui.strings.CommonStrings import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -86,31 +85,30 @@ class RoomMemberDetailsPresenter @AssistedInject constructor( val userProfileState = userProfilePresenter.present() - val identityStateChanges by produceState(initialValue = null) { - room.roomInfoFlow.filter { it.isEncrypted == true } - .flatMapLatest { - // Fetch the initial identity state manually - val identityState = encryptionService.getUserIdentity(roomMemberId).getOrNull() - value = identityState?.let { IdentityStateChange(roomMemberId, it) } + val identityStateChanges = produceState(initialValue = null) { + // Fetch the initial identity state manually + val identityState = encryptionService.getUserIdentity(roomMemberId).getOrNull() + value = identityState?.let { IdentityStateChange(roomMemberId, it) } - // Subscribe to the identity changes - room.roomMemberIdentityStateChange() - .map { it.find { it.identityRoomMember.userId == roomMemberId } } - .map { roomMemberIdentityStateChange -> - // If we didn't receive any info, manually fetch it - roomMemberIdentityStateChange?.identityState ?: encryptionService.getUserIdentity(roomMemberId).getOrNull() - } - .filterNotNull() + // Subscribe to the identity changes + room.roomMemberIdentityStateChange(waitForEncryption = false) + .map { it.find { it.identityRoomMember.userId == roomMemberId } } + .map { roomMemberIdentityStateChange -> + // If we didn't receive any info, manually fetch it + roomMemberIdentityStateChange?.identityState ?: encryptionService.getUserIdentity(roomMemberId).getOrNull() } + .filterNotNull() .collect { value = IdentityStateChange(roomMemberId, it) } } - val verificationState = remember(identityStateChanges) { - when (identityStateChanges?.identityState) { - IdentityState.VerificationViolation -> UserProfileVerificationState.VERIFICATION_VIOLATION - IdentityState.Verified -> UserProfileVerificationState.VERIFIED - IdentityState.Pinned, IdentityState.PinViolation -> UserProfileVerificationState.UNVERIFIED - else -> UserProfileVerificationState.UNKNOWN + val verificationState by remember { + derivedStateOf { + when (identityStateChanges.value?.identityState) { + IdentityState.VerificationViolation -> UserProfileVerificationState.VERIFICATION_VIOLATION + IdentityState.Verified -> UserProfileVerificationState.VERIFIED + IdentityState.Pinned, IdentityState.PinViolation -> UserProfileVerificationState.UNVERIFIED + else -> UserProfileVerificationState.UNKNOWN + } } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChange.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChange.kt index 303fae4d48c..0001131e596 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChange.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChange.kt @@ -7,7 +7,6 @@ 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 @@ -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.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.first import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.flow @OptIn(ExperimentalCoroutinesApi::class) -fun JoinedRoom.roomMemberIdentityStateChange(): Flow> { - return roomInfoFlow - .filter { - // Room cannot become unencrypted, so we can just apply a filter here. - it.isEncrypted == true +fun JoinedRoom.roomMemberIdentityStateChange(waitForEncryption: Boolean): Flow> { + val encryptionChangeFlow = flow { + if (waitForEncryption) { + // Room cannot become unencrypted, so it's ok to use first here + roomInfoFlow.first { roomInfo -> roomInfo.isEncrypted == true } } - .distinctUntilChanged() + emit(Unit) + } + return encryptionChangeFlow .flatMapLatest { combine(identityStateChangesFlow, membersStateFlow) { identityStateChanges, membersState -> identityStateChanges.map { identityStateChange -> @@ -52,14 +51,6 @@ fun JoinedRoom.roomMemberIdentityStateChange(): Flow>.observeRoomMemberIdentityStateChange(room: JoinedRoom) { - room.roomMemberIdentityStateChange() - .onEach { roomMemberIdentityStateChanges -> - value = roomMemberIdentityStateChanges.toPersistentList() - } - .launchIn(this) -} - private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember( userId = userId, displayNameOrDefault = displayNameOrDefault, diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChangeTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChangeTest.kt new file mode 100644 index 00000000000..8abf5092e66 --- /dev/null +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChangeTest.kt @@ -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>(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>() + 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() + } + } +}