diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 236ea211fe..1e2fbb591f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor +import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState @@ -91,6 +92,7 @@ class MessagesPresenter @AssistedInject constructor( private val voiceMessageComposerPresenter: Presenter, timelinePresenterFactory: TimelinePresenter.Factory, private val timelineProtectionPresenter: Presenter, + private val identityChangeStatePresenter: IdentityChangeStatePresenter, private val actionListPresenterFactory: ActionListPresenter.Factory, private val customReactionPresenter: Presenter, private val reactionSummaryPresenter: Presenter, @@ -125,6 +127,7 @@ class MessagesPresenter @AssistedInject constructor( val voiceMessageComposerState = voiceMessageComposerPresenter.present() val timelineState = timelinePresenter.present() val timelineProtectionState = timelineProtectionPresenter.present() + val identityChangeState = identityChangeStatePresenter.present() val actionListState = actionListPresenter.present() val customReactionState = customReactionPresenter.present() val reactionSummaryState = reactionSummaryPresenter.present() @@ -217,6 +220,7 @@ class MessagesPresenter @AssistedInject constructor( voiceMessageComposerState = voiceMessageComposerState, timelineState = timelineState, timelineProtectionState = timelineProtectionState, + identityChangeState = identityChangeState, actionListState = actionListState, customReactionState = customReactionState, reactionSummaryState = reactionSummaryState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 2e03cbdb9d..2dc43030a4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl import androidx.compose.runtime.Immutable import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.features.messages.impl.timeline.TimelineState @@ -34,6 +35,7 @@ data class MessagesState( val voiceMessageComposerState: VoiceMessageComposerState, val timelineState: TimelineState, val timelineProtectionState: TimelineProtectionState, + val identityChangeState: IdentityChangeState, val actionListState: ActionListState, val customReactionState: CustomReactionState, val reactionSummaryState: ReactionSummaryState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 985471c641..f42af8231c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -10,6 +10,7 @@ package io.element.android.features.messages.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.anActionListState +import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState @@ -125,6 +126,7 @@ fun aMessagesState( composerState = composerState, voiceMessageComposerState = voiceMessageComposerState, timelineProtectionState = timelineProtectionState, + identityChangeState = anIdentityChangeState(), timelineState = timelineState, readReceiptBottomSheetState = readReceiptBottomSheetState, actionListState = actionListState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 36bf0bc5fb..709ee3734f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -57,6 +57,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents @@ -103,6 +104,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import timber.log.Timber @@ -448,6 +450,11 @@ private fun MessagesViewComposerBottomSheetContents( state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it)) } ) + // Do not show the identity change if user is composing a Rich message or is seeing suggestion(s). + if (state.composerState.suggestions.isEmpty() && + state.composerState.textEditorState is TextEditorState.Markdown) { + IdentityChangeStateView(state.identityChangeState) + } MessageComposerView( state = state.composerState, voiceMessageState = state.voiceMessageComposerState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeEvent.kt new file mode 100644 index 0000000000..df58c0346e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeEvent.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import io.element.android.libraries.matrix.api.core.UserId + +sealed interface IdentityChangeEvent { + data class Submit(val userId: UserId) : IdentityChangeEvent +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeState.kt new file mode 100644 index 0000000000..c29e375a44 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeState.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.room.RoomMember +import kotlinx.collections.immutable.ImmutableList + +data class IdentityChangeState( + val roomMemberIdentityStateChanges: ImmutableList, + val eventSink: (IdentityChangeEvent) -> Unit, +) + +data class RoomMemberIdentityStateChange( + val roomMember: RoomMember, + val identityState: IdentityState, +) 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 new file mode 100644 index 0000000000..1d08125b44 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.UserId +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.RoomMembershipState +import io.element.android.libraries.matrix.api.room.roomMembers +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +class IdentityChangeStatePresenter @Inject constructor( + private val room: MatrixRoom, +) : Presenter { + @Composable + override fun present(): IdentityChangeState { + val roomMemberIdentityStateChange = remember { + mutableStateOf(emptyList()) + } + + // Keep the ignored alert locally for now + val ignoredUserIdChange = rememberSaveable { + mutableStateOf(emptyList()) + } + + LaunchedEffect(Unit) { + observeRoomMemberIdentityStateChange(roomMemberIdentityStateChange) + } + + fun handleEvent(event: IdentityChangeEvent) { + when (event) { + is IdentityChangeEvent.Submit -> { + ignoredUserIdChange.value += event.userId + // TODO notify the SDK + } + } + } + + return IdentityChangeState( + roomMemberIdentityStateChanges = roomMemberIdentityStateChange.value + .filter { it.roomMember.userId !in ignoredUserIdChange.value } + .toImmutableList(), + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.observeRoomMemberIdentityStateChange(roomMemberIdentityStateChange: MutableState>) { + combine(room.identityStateChangesFlow, room.membersStateFlow) { IdentityStateChanges, membersState -> + IdentityStateChanges.map { IdentityStateChange -> + val member = membersState.roomMembers() + ?.firstOrNull { roomMember -> roomMember.userId == IdentityStateChange.userId } + ?: createDefaultRoomMemberForIdentityChange(IdentityStateChange.userId) + RoomMemberIdentityStateChange( + roomMember = member, + identityState = IdentityStateChange.identityState, + ) + } + } + .distinctUntilChanged() + .onEach { roomMemberIdentityStateChanges -> + roomMemberIdentityStateChange.value = roomMemberIdentityStateChanges + } + .launchIn(this) + } +} + +/** + * Create a default [RoomMember] for identity change events. + * In this case, only the userId will be used for rendering, other fields are not used, but keep them + * as close as possible to the actual data. + */ +private fun createDefaultRoomMemberForIdentityChange(userId: UserId): RoomMember { + return RoomMember( + userId = userId, + displayName = null, + avatarUrl = null, + membership = RoomMembershipState.JOIN, + isNameAmbiguous = false, + powerLevel = 0, + normalizedPowerLevel = 0, + isIgnored = false, + role = RoomMember.Role.USER, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt new file mode 100644 index 0000000000..167ccc68ab --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.typing.aTypingRoomMember +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import kotlinx.collections.immutable.toImmutableList + +class IdentityChangeStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anIdentityChangeState(), + anIdentityChangeState( + roomMemberIdentityStateChanges = listOf( + RoomMemberIdentityStateChange( + roomMember = aTypingRoomMember(displayName = "Alice"), + identityState = IdentityState.PinViolation, + ), + ), + ), + ) +} + +internal fun anIdentityChangeState( + roomMemberIdentityStateChanges: List = emptyList(), +) = IdentityChangeState( + roomMemberIdentityStateChanges = roomMemberIdentityStateChanges.toImmutableList(), + eventSink = {}, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt new file mode 100644 index 0000000000..3a13359b4c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun IdentityChangeStateView( + state: IdentityChangeState, + modifier: Modifier = Modifier, +) { + // Pick the first identity change to PinViolation + val identityChange = state.roomMemberIdentityStateChanges.firstOrNull { + // For now only render PinViolation + it.identityState == IdentityState.PinViolation + } + if (identityChange != null) { + ComposerAlertMolecule( + modifier = modifier, + avatar = identityChange.roomMember.getAvatarData(AvatarSize.ComposerAlert), + content = buildAnnotatedString { + val coloredPart = stringResource(CommonStrings.action_learn_more) + val fullText = stringResource( + CommonStrings.crypto_identity_change_pin_violation, + identityChange.roomMember.disambiguatedDisplayName, + coloredPart, + ) + val startIndex = fullText.indexOf(coloredPart) + append(fullText) + addStyle( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Bold, + ), + start = startIndex, + end = startIndex + coloredPart.length, + ) + addStringAnnotation( + tag = "LEARN_MORE", + annotation = "TODO", + start = startIndex, + end = startIndex + coloredPart.length + ) + }, + onSubmitClick = { state.eventSink(IdentityChangeEvent.Submit(identityChange.roomMember.userId)) }, + isCritical = identityChange.identityState == IdentityState.VerificationViolation, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun IdentityChangeStateViewPreview( + @PreviewParameter(IdentityChangeStateProvider::class) state: IdentityChangeState, +) = ElementPreview { + IdentityChangeStateView( + state = state, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt new file mode 100644 index 0000000000..c484e4e8c7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.messages.impl.MessagesView +import io.element.android.features.messages.impl.aMessagesState +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +@PreviewsDayNight +@Composable +internal fun MessagesViewWithIdentityChangePreview( + @PreviewParameter(IdentityChangeStateProvider::class) identityChangeState: IdentityChangeState +) = ElementPreview { + MessagesView( + state = aMessagesState().copy(identityChangeState = identityChangeState), + onBackClick = {}, + onRoomDetailsClick = {}, + onEventClick = { false }, + onUserDataClick = {}, + onLinkClick = {}, + onPreviewAttachments = {}, + onSendLocationClick = {}, + onCreatePollClick = {}, + onJoinCallClick = {}, + onViewAllPinnedMessagesClick = {}, + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 284f141d41..1b22c59c25 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -16,6 +16,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerState @@ -1016,6 +1017,9 @@ class MessagesPresenterTest { } } val featureFlagService = FakeFeatureFlagService() + val identityChangeStatePresenter = IdentityChangeStatePresenter( + room = matrixRoom, + ) return MessagesPresenter( room = matrixRoom, composerPresenter = messageComposerPresenter, @@ -1026,6 +1030,7 @@ class MessagesPresenterTest { customReactionPresenter = { aCustomReactionState() }, reactionSummaryPresenter = { aReactionSummaryState() }, readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() }, + identityChangeStatePresenter = identityChangeStatePresenter, pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() }, networkMonitor = FakeNetworkMonitor(), snackbarDispatcher = SnackbarDispatcher(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt new file mode 100644 index 0000000000..539e9f11a2 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.typing.aTypingRoomMember +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.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class IdentityChangeStatePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createIdentityChangeStatePresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.roomMemberIdentityStateChanges).isEmpty() + } + } + + @Test + fun `present - when the room emits identity change, the presenter emits new state`() = runTest { + val room = FakeMatrixRoom() + val presenter = createIdentityChangeStatePresenter(room) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.roomMemberIdentityStateChanges).isEmpty() + room.emitIdentityStateChanges( + listOf( + IdentityStateChange( + userId = A_USER_ID_2, + identityState = IdentityState.PinViolation, + ), + ) + ) + val finalItem = awaitItem() + assertThat(finalItem.roomMemberIdentityStateChanges).hasSize(1) + val value = finalItem.roomMemberIdentityStateChanges.first() + assertThat(value.roomMember.userId).isEqualTo(A_USER_ID_2) + assertThat(value.identityState).isEqualTo(IdentityState.PinViolation) + } + } + + @Test + fun `present - when the room emits identity change, the presenter emits new state with member details`() = + runTest { + val room = FakeMatrixRoom().apply { + givenRoomMembersState( + MatrixRoomMembersState.Ready( + listOf( + aTypingRoomMember( + A_USER_ID_2, + displayName = "Alice", + ), + ).toImmutableList() + ) + ) + } + val presenter = createIdentityChangeStatePresenter(room) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.roomMemberIdentityStateChanges).isEmpty() + room.emitIdentityStateChanges( + listOf( + IdentityStateChange( + userId = A_USER_ID_2, + identityState = IdentityState.PinViolation, + ), + ) + ) + val finalItem = awaitItem() + assertThat(finalItem.roomMemberIdentityStateChanges).hasSize(1) + val value = finalItem.roomMemberIdentityStateChanges.first() + assertThat(value.roomMember.userId).isEqualTo(A_USER_ID_2) + assertThat(value.roomMember.displayName).isEqualTo("Alice") + assertThat(value.identityState).isEqualTo(IdentityState.PinViolation) + } + } + + private fun createIdentityChangeStatePresenter( + room: MatrixRoom = FakeMatrixRoom(), + ): IdentityChangeStatePresenter { + return IdentityChangeStatePresenter( + room = room, + ) + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityState.kt new file mode 100644 index 0000000000..2aa9d31ede --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityState.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.encryption.identity + +enum class IdentityState { + /** The user is verified with us */ + Verified, + + /** + * Either this is the first identity we have seen for this user, or the + * user has acknowledged a change of identity explicitly e.g. by + * clicking OK on a notification. + */ + Pinned, + + /** + * The user's identity has changed since it was pinned. The user should be + * notified about this and given the opportunity to acknowledge the + * change, which will make the new identity pinned. + */ + PinViolation, + + /** + * The user's identity has changed, and before that it was verified. This + * is a serious problem. The user can either verify again to make this + * identity verified, or withdraw verification to make it pinned. + */ + VerificationViolation, +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityStateChange.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityStateChange.kt new file mode 100644 index 0000000000..6eef5ff5e6 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityStateChange.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.encryption.identity + +import io.element.android.libraries.matrix.api.core.UserId + +data class IdentityStateChange( + val userId: UserId, + val identityState: IdentityState, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 98ebc531a5..b8d6d66043 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo @@ -52,6 +53,7 @@ interface MatrixRoom : Closeable { val roomInfoFlow: Flow val roomTypingMembersFlow: Flow> + val identityStateChangesFlow: Flow> /** * A one-to-one is a room with exactly 2 members. diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/IdentityState.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/IdentityState.kt new file mode 100644 index 0000000000..66e6b0e5e6 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/IdentityState.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.mapper + +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import org.matrix.rustcomponents.sdk.IdentityState as RustIdentityState + +fun RustIdentityState.map(): IdentityState = when (this) { + RustIdentityState.VERIFIED -> IdentityState.Verified + RustIdentityState.PINNED -> IdentityState.Pinned + RustIdentityState.PIN_VIOLATION -> IdentityState.PinViolation + RustIdentityState.VERIFICATION_VIOLATION -> IdentityState.VerificationViolation +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index c3521ecf99..7bc95471c3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -19,6 +19,7 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo @@ -43,6 +44,7 @@ import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings +import io.element.android.libraries.matrix.impl.mapper.map import io.element.android.libraries.matrix.impl.room.draft.into import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper @@ -69,6 +71,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener import org.matrix.rustcomponents.sdk.RoomInfo import org.matrix.rustcomponents.sdk.RoomInfoListener import org.matrix.rustcomponents.sdk.RoomListItem @@ -82,6 +85,7 @@ import timber.log.Timber import uniffi.matrix_sdk.RoomPowerLevelChanges import java.io.File import kotlin.coroutines.cancellation.CancellationException +import org.matrix.rustcomponents.sdk.IdentityStatusChange as RustIdentityStateChange import org.matrix.rustcomponents.sdk.Room as InnerRoom import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline @@ -130,6 +134,23 @@ class RustMatrixRoom( }) } + override val identityStateChangesFlow: Flow> = mxCallbackFlow { + val initial = emptyList() + channel.trySend(initial) + innerRoom.subscribeToIdentityStatusChanges(object : IdentityStatusChangeListener { + override fun call(identityStatusChange: List) { + channel.trySend( + identityStatusChange.map { + IdentityStateChange( + userId = UserId(it.userId), + identityState = it.changedTo.map(), + ) + } + ) + } + }) + } + // Create a dispatcher for all room methods... private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 1d78e87369..81f276cc84 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo @@ -137,7 +138,7 @@ class FakeMatrixRoom( private val subscribeToSyncLambda: () -> Unit = { lambdaError() }, private val ignoreDeviceTrustAndResendResult: (Map>, TransactionId) -> Result = { _, _ -> lambdaError() }, private val withdrawVerificationAndResendResult: (List, TransactionId) -> Result = { _, _ -> lambdaError() }, - ) : MatrixRoom { +) : MatrixRoom { private val _roomInfoFlow: MutableSharedFlow = MutableSharedFlow(replay = 1) override val roomInfoFlow: Flow = _roomInfoFlow @@ -152,6 +153,13 @@ class FakeMatrixRoom( _roomTypingMembersFlow.tryEmit(typingMembers) } + private val _identityStateChangesFlow: MutableSharedFlow> = MutableSharedFlow(replay = 1) + override val identityStateChangesFlow: Flow> = _identityStateChangesFlow + + fun emitIdentityStateChanges(identityStateChanges: List) { + _identityStateChangesFlow.tryEmit(identityStateChanges) + } + override val membersStateFlow: MutableStateFlow = MutableStateFlow(MatrixRoomMembersState.Unknown) override val roomNotificationSettingsStateFlow: MutableStateFlow =