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 5284af15a5..f657fb6393 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 @@ -30,6 +30,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.sendfailure.resolve.ResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerState @@ -95,6 +96,7 @@ class MessagesPresenter @AssistedInject constructor( private val reactionSummaryPresenter: ReactionSummaryPresenter, private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter, private val pinnedMessagesBannerPresenter: Presenter, + private val resolveVerifiedUserSendFailurePresenter: Presenter, private val networkMonitor: NetworkMonitor, private val snackbarDispatcher: SnackbarDispatcher, private val dispatchers: CoroutineDispatchers, @@ -128,6 +130,7 @@ class MessagesPresenter @AssistedInject constructor( val reactionSummaryState = reactionSummaryPresenter.present() val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present() val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present() + val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present() val syncUpdateFlow = room.syncUpdateFlow.collectAsState() @@ -227,6 +230,7 @@ class MessagesPresenter @AssistedInject constructor( appName = buildMeta.applicationName, callState = callState, pinnedMessagesBannerState = pinnedMessagesBannerState, + resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, eventSink = { handleEvents(it) } ) } 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 546e558ba8..c3b4b9183e 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.sendfailure.resolve.ResolveVerifiedUserSendFailureState 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 @@ -47,6 +48,7 @@ data class MessagesState( val callState: RoomCallState, val appName: String, val pinnedMessagesBannerState: PinnedMessagesBannerState, + val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, val eventSink: (MessagesEvents) -> Unit ) 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 96e55aac91..cc651bc978 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,8 @@ 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.sendfailure.resolve.ResolveVerifiedUserSendFailureState +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState 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 @@ -113,6 +115,7 @@ fun aMessagesState( enableVoiceMessages: Boolean = true, callState: RoomCallState = RoomCallState.ENABLED, pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(), + resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(), eventSink: (MessagesEvents) -> Unit = {}, ) = MessagesState( roomId = RoomId("!id:domain"), @@ -137,6 +140,7 @@ fun aMessagesState( callState = callState, appName = "Element", pinnedMessagesBannerState = pinnedMessagesBannerState, + resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, eventSink = eventSink, ) 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 05329c3fea..d56ef675c3 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,8 @@ 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.sendfailure.resolve.ResolveVerifiedUserSendFailureEvents +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView 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 @@ -242,8 +244,12 @@ fun MessagesView( }, onEmojiReactionClick = ::onEmojiReactionClick, onVerifiedUserSendFailureClick = { event -> + state.resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(event)) + }, + ) - } + ResolveVerifiedUserSendFailureView( + state = state.resolveVerifiedUserSendFailureState, ) CustomReactionBottomSheet( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index 802fdbf9ed..88b7128b76 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -22,6 +22,8 @@ import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEn import io.element.android.features.messages.impl.UserEventPermissions 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.sendfailure.VerifiedUserSendFailure +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent @@ -36,7 +38,6 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.preferences.api.store.AppPreferencesStore import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -57,6 +58,7 @@ class DefaultActionListPresenter @AssistedInject constructor( private val appPreferencesStore: AppPreferencesStore, private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled, private val room: MatrixRoom, + private val userSendFailureFactory: VerifiedUserSendFailureFactory, ) : ActionListPresenter { @AssistedFactory @ContributesBinding(RoomScope::class) @@ -116,10 +118,10 @@ class DefaultActionListPresenter @AssistedInject constructor( isEventPinned = pinnedEventIds.contains(timelineItem.eventId), ) - val verifiedUserSendFailure = buildVerifiedUserSendFailure(timelineItem) + val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState) val displayEmojiReactions = usersEventPermissions.canSendReaction && timelineItem.content.canReact() - if (actions.isNotEmpty() || displayEmojiReactions || verifiedUserSendFailure != ActionListState.VerifiedUserSendFailure.None) { + if (actions.isNotEmpty() || displayEmojiReactions || verifiedUserSendFailure != VerifiedUserSendFailure.None) { target.value = ActionListState.Target.Success( event = timelineItem, displayEmojiReactions = displayEmojiReactions, @@ -131,32 +133,6 @@ class DefaultActionListPresenter @AssistedInject constructor( } } - private suspend fun buildVerifiedUserSendFailure( - timelineItem: TimelineItem.Event, - ): ActionListState.VerifiedUserSendFailure { - return when (val sendState = timelineItem.localSendState) { - is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> { - val userId = sendState.devices.keys.firstOrNull() - if (userId == null) { - ActionListState.VerifiedUserSendFailure.None - } else { - val displayName = room.userDisplayName(userId).getOrNull() ?: userId.value - ActionListState.VerifiedUserSendFailure.UnsignedDevice(displayName) - } - } - is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> { - val userId = sendState.users.firstOrNull() - if (userId == null) { - ActionListState.VerifiedUserSendFailure.None - } else { - val displayName = room.userDisplayName(userId).getOrNull() ?: userId.value - ActionListState.VerifiedUserSendFailure.ChangedIdentity(displayName) - } - } - else -> ActionListState.VerifiedUserSendFailure.None - } - } - private fun buildActions( timelineItem: TimelineItem.Event, usersEventPermissions: UserEventPermissions, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt index 614dcd3776..75c598df36 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt @@ -7,12 +7,10 @@ package io.element.android.features.messages.impl.actionlist -import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.ui.res.stringResource import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure import io.element.android.features.messages.impl.timeline.model.TimelineItem -import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @Immutable @@ -31,20 +29,4 @@ data class ActionListState( val actions: ImmutableList, ) : Target } - - @Immutable - sealed interface VerifiedUserSendFailure { - data object None : VerifiedUserSendFailure - data class UnsignedDevice(val displayName: String) : VerifiedUserSendFailure - data class ChangedIdentity(val displayName: String) : VerifiedUserSendFailure - - @Composable - fun formatted(): String { - return when (this) { - is None -> "" - is UnsignedDevice -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_unsigned_device, displayName) - is ChangedIdentity -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, displayName) - } - } - } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt index e53f3f11b2..4f92bc72ba 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt @@ -9,6 +9,8 @@ package io.element.android.features.messages.impl.actionlist import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.anUnsignedDeviceSendFailure import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent @@ -18,8 +20,6 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent -import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -37,7 +37,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = true, - verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ) ), @@ -50,7 +50,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState, ), displayEmojiReactions = true, - verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ) ), @@ -60,7 +60,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = true, - verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ) ), @@ -70,7 +70,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = true, - verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ) ), @@ -80,7 +80,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = true, - verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ) ), @@ -90,7 +90,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = true, - verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ) ), @@ -100,7 +100,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = true, - verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ) ), @@ -110,7 +110,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = false, - verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ), ), @@ -120,7 +120,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = false, - verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = aTimelineItemPollActionList(), ), ), @@ -131,7 +131,7 @@ open class ActionListStateProvider : PreviewParameterProvider { messageShield = MessageShield.UnknownDevice(isCritical = true) ), displayEmojiReactions = true, - verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ) ), @@ -139,7 +139,7 @@ open class ActionListStateProvider : PreviewParameterProvider { target = ActionListState.Target.Success( event = aTimelineItemEvent(), displayEmojiReactions = true, - verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.UnsignedDevice(displayName = "Alice"), + verifiedUserSendFailure = anUnsignedDeviceSendFailure(), actions = aTimelineItemActionList(), ) ), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index e56a1e63f5..8c950012db 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -47,6 +47,10 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.ChangedIdentity +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.None +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.UnsignedDevice import io.element.android.features.messages.impl.timeline.components.MessageShieldView import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent @@ -196,7 +200,7 @@ private fun SheetContent( HorizontalDivider() } } - if (target.verifiedUserSendFailure != ActionListState.VerifiedUserSendFailure.None) { + if (target.verifiedUserSendFailure != None) { item { VerifiedUserSendFailureView( sendFailure = target.verifiedUserSendFailure, @@ -362,10 +366,19 @@ private fun EmojiReactionsRow( @Composable private fun VerifiedUserSendFailureView( - sendFailure: ActionListState.VerifiedUserSendFailure, + sendFailure: VerifiedUserSendFailure, onClick: () -> Unit, modifier: Modifier = Modifier, ) { + @Composable + fun VerifiedUserSendFailure.headline(): String { + return when (this) { + is None -> "" + is UnsignedDevice -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_unsigned_device, userDisplayName) + is ChangedIdentity -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, userDisplayName) + } + } + ListItem( modifier = modifier .clickable(onClick = onClick) @@ -374,7 +387,7 @@ private fun VerifiedUserSendFailureView( trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChevronRight())), headlineContent = { Text( - text = sendFailure.formatted(), + text = sendFailure.headline(), style = ElementTheme.typography.fontBodySmMedium, ) }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/VerifiedUserSendFailure.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/VerifiedUserSendFailure.kt new file mode 100644 index 0000000000..e3c798f7df --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/VerifiedUserSendFailure.kt @@ -0,0 +1,23 @@ +/* + * 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.sendfailure + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface VerifiedUserSendFailure { + data object None : VerifiedUserSendFailure + + data class UnsignedDevice( + val userDisplayName: String, + ) : VerifiedUserSendFailure + + data class ChangedIdentity( + val userDisplayName: String, + ) : VerifiedUserSendFailure +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/VerifiedUserSendFailureFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/VerifiedUserSendFailureFactory.kt new file mode 100644 index 0000000000..de5817c909 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/VerifiedUserSendFailureFactory.kt @@ -0,0 +1,42 @@ +/* + * 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.sendfailure + +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import javax.inject.Inject + +class VerifiedUserSendFailureFactory @Inject constructor( + private val room: MatrixRoom, +) { + suspend fun create( + sendState: LocalEventSendState?, + ): VerifiedUserSendFailure { + return when (sendState) { + is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> { + val userId = sendState.devices.keys.firstOrNull() + if (userId == null) { + VerifiedUserSendFailure.None + } else { + val displayName = room.userDisplayName(userId).getOrNull() ?: userId.value + VerifiedUserSendFailure.UnsignedDevice(displayName) + } + } + is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> { + val userId = sendState.users.firstOrNull() + if (userId == null) { + VerifiedUserSendFailure.None + } else { + val displayName = room.userDisplayName(userId).getOrNull() ?: userId.value + VerifiedUserSendFailure.ChangedIdentity(displayName) + } + } + else -> VerifiedUserSendFailure.None + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureEvents.kt new file mode 100644 index 0000000000..7743ef9dcd --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureEvents.kt @@ -0,0 +1,20 @@ +/* + * 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.sendfailure.resolve + +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +sealed interface ResolveVerifiedUserSendFailureEvents { + data class ComputeForMessage( + val messageEvent: TimelineItem.Event, + ) : ResolveVerifiedUserSendFailureEvents + + data object ResolveAndResend : ResolveVerifiedUserSendFailureEvents + data object Retry : ResolveVerifiedUserSendFailureEvents + data object Dismiss : ResolveVerifiedUserSendFailureEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailurePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailurePresenter.kt new file mode 100644 index 0000000000..c96e695375 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailurePresenter.kt @@ -0,0 +1,95 @@ +/* + * 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.sendfailure.resolve + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import kotlinx.coroutines.launch +import javax.inject.Inject + +class ResolveVerifiedUserSendFailurePresenter @Inject constructor( + private val room: MatrixRoom, + private val verifiedUserSendFailureFactory: VerifiedUserSendFailureFactory, +) : Presenter { + @Composable + override fun present(): ResolveVerifiedUserSendFailureState { + var resolver by remember { + mutableStateOf(null) + } + val verifiedUserSendFailure by produceState(VerifiedUserSendFailure.None, resolver?.currentSendFailure?.value) { + val currentSendFailure = resolver?.currentSendFailure?.value + value = verifiedUserSendFailureFactory.create(currentSendFailure) + } + + val resolveAction = remember { + mutableStateOf>(AsyncAction.Uninitialized) + } + val retryAction = remember { + mutableStateOf>(AsyncAction.Uninitialized) + } + val coroutineScope = rememberCoroutineScope() + + fun handleEvents(event: ResolveVerifiedUserSendFailureEvents) { + when (event) { + is ResolveVerifiedUserSendFailureEvents.ComputeForMessage -> { + val sendState = event.messageEvent.localSendState as? LocalEventSendState.Failed.VerifiedUser + val transactionId = event.messageEvent.transactionId + resolver = if (sendState != null && transactionId != null) { + VerifiedUserSendFailureResolver( + room = room, + transactionId = transactionId, + iterator = VerifiedUserSendFailureIterator.from(sendState) + ) + } else { + null + } + } + ResolveVerifiedUserSendFailureEvents.Dismiss -> { + resolver = null + } + ResolveVerifiedUserSendFailureEvents.Retry -> { + coroutineScope.launch { + resolver?.run { + runUpdatingState(retryAction) { + resend() + } + } + } + } + ResolveVerifiedUserSendFailureEvents.ResolveAndResend -> { + coroutineScope.launch { + resolver?.run { + runUpdatingState(resolveAction) { + resolveAndResend() + } + } + } + } + } + } + + return ResolveVerifiedUserSendFailureState( + verifiedUserSendFailure = verifiedUserSendFailure, + resolveAction = resolveAction.value, + retryAction = retryAction.value, + eventSink = ::handleEvents + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureState.kt new file mode 100644 index 0000000000..44ec6640b0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureState.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.features.messages.impl.crypto.sendfailure.resolve + +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.libraries.architecture.AsyncAction + +data class ResolveVerifiedUserSendFailureState( + val verifiedUserSendFailure: VerifiedUserSendFailure, + val resolveAction: AsyncAction, + val retryAction: AsyncAction, + val eventSink: (ResolveVerifiedUserSendFailureEvents) -> Unit +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureStateProvider.kt new file mode 100644 index 0000000000..1f8335b648 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureStateProvider.kt @@ -0,0 +1,45 @@ +/* + * 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.sendfailure.resolve + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.libraries.architecture.AsyncAction + +open class ResolveVerifiedUserSendFailureStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aResolveVerifiedUserSendFailureState(), + aResolveVerifiedUserSendFailureState( + verifiedUserSendFailure = anUnsignedDeviceSendFailure() + ), + aResolveVerifiedUserSendFailureState( + verifiedUserSendFailure = aChangedIdentitySendFailure() + ) + ) +} + +fun aResolveVerifiedUserSendFailureState( + verifiedUserSendFailure: VerifiedUserSendFailure = VerifiedUserSendFailure.None, + resolveAction: AsyncAction = AsyncAction.Uninitialized, + retryAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (ResolveVerifiedUserSendFailureEvents) -> Unit = {} +) = ResolveVerifiedUserSendFailureState( + verifiedUserSendFailure = verifiedUserSendFailure, + resolveAction = resolveAction, + retryAction = retryAction, + eventSink = eventSink +) + +fun anUnsignedDeviceSendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.UnsignedDevice( + userDisplayName = userDisplayName, +) + +fun aChangedIdentitySendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.ChangedIdentity( + userDisplayName = userDisplayName, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureView.kt new file mode 100644 index 0000000000..10c3236f46 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureView.kt @@ -0,0 +1,156 @@ +/* + * 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.sendfailure.resolve + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ResolveVerifiedUserSendFailureView( + state: ResolveVerifiedUserSendFailureState, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + var showSheet by remember { mutableStateOf(false) } + + fun dismiss() { + state.eventSink(ResolveVerifiedUserSendFailureEvents.Dismiss) + } + + fun onRetryClick() { + state.eventSink(ResolveVerifiedUserSendFailureEvents.Retry) + } + + fun onResolveAndResendClick() { + state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + + LaunchedEffect(state.verifiedUserSendFailure) { + if (state.verifiedUserSendFailure is VerifiedUserSendFailure.None) { + sheetState.hide() + showSheet = false + } else { + showSheet = true + } + } + + Box(modifier = modifier) { + if (showSheet) { + ModalBottomSheet( + modifier = Modifier + .systemBarsPadding() + .navigationBarsPadding(), + sheetState = sheetState, + onDismissRequest = ::dismiss, + ) { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(24.dp), + title = state.verifiedUserSendFailure.title(), + subTitle = state.verifiedUserSendFailure.subtitle(), + iconImageVector = CompoundIcons.Error(), + iconTint = ElementTheme.colors.iconCriticalPrimary, + iconBackgroundTint = ElementTheme.colors.bgCriticalSubtle, + ) + ButtonColumnMolecule( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + ) { + Button( + modifier = Modifier.fillMaxWidth(), + text = state.verifiedUserSendFailure.resolveAction(), + showProgress = state.resolveAction.isLoading(), + onClick = ::onResolveAndResendClick + ) + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = CommonStrings.action_retry), + showProgress = state.retryAction.isLoading(), + onClick = ::onRetryClick + ) + TextButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = CommonStrings.action_cancel_for_now), + onClick = ::dismiss, + ) + } + } + } + } +} + +@Composable +private fun VerifiedUserSendFailure.title(): String { + return when (this) { + is VerifiedUserSendFailure.UnsignedDevice -> stringResource(id = CommonStrings.screen_resolve_send_failure_unsigned_device_title, userDisplayName) + is VerifiedUserSendFailure.ChangedIdentity -> stringResource( + id = CommonStrings.screen_resolve_send_failure_changed_identity_title, + userDisplayName + ) + VerifiedUserSendFailure.None -> "" + } +} + +@Composable +private fun VerifiedUserSendFailure.subtitle(): String { + return when (this) { + is VerifiedUserSendFailure.UnsignedDevice -> stringResource( + id = CommonStrings.screen_resolve_send_failure_unsigned_device_subtitle, + userDisplayName, + userDisplayName, + ) + is VerifiedUserSendFailure.ChangedIdentity -> stringResource( + id = CommonStrings.screen_resolve_send_failure_changed_identity_subtitle, + userDisplayName + ) + VerifiedUserSendFailure.None -> "" + } +} + +@Composable +private fun VerifiedUserSendFailure.resolveAction(): String { + return when (this) { + is VerifiedUserSendFailure.UnsignedDevice -> stringResource(id = CommonStrings.screen_resolve_send_failure_unsigned_device_primary_button_title) + is VerifiedUserSendFailure.ChangedIdentity -> stringResource(id = CommonStrings.screen_resolve_send_failure_changed_identity_primary_button_title) + VerifiedUserSendFailure.None -> "" + } +} + +@PreviewsDayNight +@Composable +internal fun ResolveVerifiedUserSendFailureViewPreview( + @PreviewParameter(ResolveVerifiedUserSendFailureStateProvider::class) state: ResolveVerifiedUserSendFailureState +) = ElementPreview { + ResolveVerifiedUserSendFailureView(state) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/VerifiedUserSendFailureIterator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/VerifiedUserSendFailureIterator.kt new file mode 100644 index 0000000000..ccdcfb509b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/VerifiedUserSendFailureIterator.kt @@ -0,0 +1,60 @@ +/* + * 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.sendfailure.resolve + +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState + +/** + * Iterator for [LocalEventSendState.Failed.VerifiedUser] + * Allow to iterate through the internal state of the failure. + * This is useful to allow solving the failure step by step (e.g. for each user). + */ +interface VerifiedUserSendFailureIterator : Iterator { + companion object { + fun from(failure: LocalEventSendState.Failed.VerifiedUser): VerifiedUserSendFailureIterator { + return when (failure) { + is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> UnsignedDeviceSendFailureIterator(failure) + is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> ChangedIdentitySendFailureIterator(failure) + } + } + } +} + +class UnsignedDeviceSendFailureIterator( + failure: LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice +) : VerifiedUserSendFailureIterator { + private val iterator = failure.devices.iterator() + + override fun hasNext(): Boolean { + return iterator.hasNext() + } + + override fun next(): LocalEventSendState.Failed.VerifiedUser { + val (userId, deviceIds) = iterator.next() + return LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice( + mapOf(userId to deviceIds) + ) + } +} + +class ChangedIdentitySendFailureIterator( + failure: LocalEventSendState.Failed.VerifiedUserChangedIdentity +) : VerifiedUserSendFailureIterator { + private val iterator = failure.users.iterator() + + override fun hasNext(): Boolean { + return iterator.hasNext() + } + + override fun next(): LocalEventSendState.Failed.VerifiedUser { + val userId = iterator.next() + return LocalEventSendState.Failed.VerifiedUserChangedIdentity( + listOf(userId) + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/VerifiedUserSendFailureResolver.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/VerifiedUserSendFailureResolver.kt new file mode 100644 index 0000000000..0f6049b11f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/VerifiedUserSendFailureResolver.kt @@ -0,0 +1,64 @@ +/* + * 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.sendfailure.resolve + +import androidx.compose.runtime.mutableStateOf +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import timber.log.Timber + +class VerifiedUserSendFailureResolver( + private val room: MatrixRoom, + private val transactionId: TransactionId, + private val iterator: VerifiedUserSendFailureIterator, +) { + val currentSendFailure = mutableStateOf(null) + + init { + if (iterator.hasNext()) { + currentSendFailure.value = iterator.next() + } + } + + suspend fun resend(): Result { + return room.retrySendMessage(transactionId) + .onSuccess { + Timber.d("Succeed to resend message with transactionId: $transactionId") + currentSendFailure.value = null + } + .onFailure { + Timber.e(it, "Failed to resend message with transactionId: $transactionId") + } + } + + suspend fun resolveAndResend(): Result { + return when (val failure = currentSendFailure.value) { + is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> { + room.ignoreDeviceTrustAndResend(failure.devices, transactionId) + } + is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> { + room.withdrawVerificationAndResend(failure.users, transactionId) + } + else -> { + Result.failure(IllegalStateException("Unknown send failure type")) + } + }.onSuccess { + Timber.d("Succeed to resolve and resend message with transactionId: $transactionId") + if (iterator.hasNext()) { + val failure = iterator.next() + currentSendFailure.value = failure + } else { + currentSendFailure.value = null + Timber.d("No more failure to resolve for transactionId: $transactionId") + } + }.onFailure { + Timber.e(it, "Failed to resolve and resend message with transactionId: $transactionId") + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt index e6cb916aef..8c488d61da 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt @@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.di import com.squareup.anvil.annotations.ContributesTo import dagger.Binds import dagger.Module +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.libraries.architecture.Presenter @@ -20,4 +22,7 @@ import io.element.android.libraries.di.RoomScope interface MessagesModule { @Binds fun bindPinnedMessagesBannerPresenter(presenter: PinnedMessagesBannerPresenter): Presenter + + @Binds + fun bindResolveVerifiedUserSendFailurePresenter(presenter: ResolveVerifiedUserSendFailurePresenter): Presenter } 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 df873d2328..fec6ea000e 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 com.google.common.truth.Truth.assertThat 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.sendfailure.resolve.aResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.draft.FakeComposerDraftService import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator @@ -1062,6 +1063,7 @@ class MessagesPresenterTest { reactionSummaryPresenter = reactionSummaryPresenter, readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter, pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() }, + resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() }, networkMonitor = FakeNetworkMonitor(), snackbarDispatcher = SnackbarDispatcher(), navigator = navigator, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 52387f821a..08c1119a6a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -34,6 +34,10 @@ 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.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvents +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aChangedIdentitySendFailure +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerItem import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState @@ -329,6 +333,7 @@ class MessagesViewTest { event = timelineItem, displayEmojiReactions = true, actions = persistentListOf(TimelineItemAction.Edit), + verifiedUserSendFailure = VerifiedUserSendFailure.None, ) ), ) @@ -399,6 +404,7 @@ class MessagesViewTest { target = ActionListState.Target.Success( event = timelineItem, displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf(TimelineItemAction.Edit), ), ), @@ -416,6 +422,34 @@ class MessagesViewTest { eventsRecorder.assertSingle(CustomReactionEvents.ShowCustomReactionSheet(timelineItem)) } + @Test + fun `clicking on verified user send failure from action list emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aMessagesState() + val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event + val stateWithActionListState = state.copy( + actionListState = anActionListState( + target = ActionListState.Target.Success( + event = timelineItem, + displayEmojiReactions = true, + verifiedUserSendFailure = aChangedIdentitySendFailure(), + actions = persistentListOf(), + ), + ), + resolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState( + eventSink = eventsRecorder + ), + ) + rule.setMessagesView( + state = stateWithActionListState, + ) + val verifiedUserSendFailure = rule.activity.getString(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, "Alice") + rule.onNodeWithText(verifiedUserSendFailure).performClick() + // Give time for the close animation to complete + rule.mainClock.advanceTimeBy(milliseconds = 1_000) + eventsRecorder.assertSingle(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(timelineItem)) + } + @Test fun `clicking on a custom emoji emits the expected Events`() { val aUnicode = "🙈" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt index bd580b5f98..078fb9e676 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt @@ -14,6 +14,8 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.messages.impl.aUserEventPermissions 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.sendfailure.VerifiedUserSendFailure +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent @@ -25,8 +27,10 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore @@ -79,6 +83,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = false, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.ViewSource, ) @@ -120,6 +125,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = false, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.ViewSource, ) @@ -161,6 +167,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, @@ -208,6 +215,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.Forward, TimelineItemAction.Pin, @@ -252,6 +260,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, @@ -298,6 +307,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = false, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, @@ -345,6 +355,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, @@ -392,6 +403,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, @@ -439,6 +451,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, @@ -484,6 +497,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = stateEvent, displayEmojiReactions = false, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.ViewSource, ) @@ -553,6 +567,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, @@ -599,6 +614,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, @@ -652,6 +668,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, @@ -748,6 +765,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.Edit, TimelineItemAction.Copy, @@ -787,6 +805,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Edit, @@ -829,6 +848,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.EndPoll, @@ -870,6 +890,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Pin, @@ -910,6 +931,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, @@ -949,6 +971,7 @@ class ActionListPresenterTest { ActionListState.Target.Success( event = messageEvent, displayEmojiReactions = false, + verifiedUserSendFailure = VerifiedUserSendFailure.None, actions = persistentListOf( TimelineItemAction.ViewSource ) @@ -956,6 +979,32 @@ class ActionListPresenterTest { ) } } + + @Test + fun `present - compute for verified user send failure`() = runTest { + val room = FakeMatrixRoom( + userDisplayNameResult = { Result.success("Alice") } + ) + val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = false, room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)), + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions(), + ) + ) + skipItems(1) + val successState = awaitItem() + val target = successState.target as ActionListState.Target.Success + assertThat(target.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(userDisplayName = "Alice")) + } + } } private fun createActionListPresenter( @@ -968,6 +1017,7 @@ private fun createActionListPresenter( postProcessor = TimelineItemActionPostProcessor.Default, appPreferencesStore = preferencesStore, isPinnedMessagesFeatureEnabled = { isPinFeatureEnabled }, - room = room + room = room, + userSendFailureFactory = VerifiedUserSendFailureFactory(room) ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailurePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailurePresenterTest.kt new file mode 100644 index 0000000000..8d5884cf08 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailurePresenterTest.kt @@ -0,0 +1,353 @@ +/* + * 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.sendfailure.resolve + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory +import io.element.android.features.messages.impl.fixtures.aMessageEvent +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_TRANSACTION_ID +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.room.FakeMatrixRoom +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ResolveVerifiedUserSendFailurePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createResolveVerifiedUserSendFailurePresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + } + } + + @Test + fun `present - remote message scenario`() = runTest { + val presenter = createResolveVerifiedUserSendFailurePresenter() + presenter.test { + val sentMessage = aMessageEvent() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(sentMessage)) + ensureAllEventsConsumed() + } + } + + @Test + fun `present - sent message scenario`() = runTest { + val presenter = createResolveVerifiedUserSendFailurePresenter() + presenter.test { + val sentMessage = aMessageEvent( + sendState = LocalEventSendState.Sent(AN_EVENT_ID) + ) + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(sentMessage)) + ensureAllEventsConsumed() + } + } + + @Test + fun `present - unknown failed message scenario`() = runTest { + val presenter = createResolveVerifiedUserSendFailurePresenter() + presenter.test { + val failedMessage = aMessageEvent( + sendState = LocalEventSendState.Failed.Unknown("") + ) + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + ensureAllEventsConsumed() + } + } + + @Test + fun `present - verified user unsigned device failure dismiss scenario`() = runTest { + val room = FakeMatrixRoom( + userDisplayNameResult = { userId -> + Result.success(userId.value) + }, + ) + val presenter = createResolveVerifiedUserSendFailurePresenter(room) + presenter.test { + val failedMessage = aVerifiedUserHasUnsignedDeviceFailedMessage() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice(A_USER_ID.value)) + state.eventSink(ResolveVerifiedUserSendFailureEvents.Dismiss) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `present - verified user unsigned device failure retry scenario`() = runTest { + val room = FakeMatrixRoom( + userDisplayNameResult = { userId -> + Result.success(userId.value) + }, + retrySendMessageResult = { + Result.success(Unit) + }, + ) + val presenter = createResolveVerifiedUserSendFailurePresenter(room) + presenter.test { + val failedMessage = aVerifiedUserHasUnsignedDeviceFailedMessage() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice(A_USER_ID.value)) + state.eventSink(ResolveVerifiedUserSendFailureEvents.Retry) + } + awaitItem().also { state -> + assertThat(state.retryAction).isEqualTo(AsyncAction.Loading) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + assertThat(state.retryAction).isEqualTo(AsyncAction.Success(Unit)) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `present - verified user unsigned device failure resolve and resend scenario`() = runTest { + val room = FakeMatrixRoom( + userDisplayNameResult = { userId -> + Result.success(userId.value) + }, + ignoreDeviceTrustAndResendResult = { _, _ -> + Result.success(Unit) + }, + ) + val presenter = createResolveVerifiedUserSendFailurePresenter(room) + presenter.test { + val failedMessage = aVerifiedUserHasUnsignedDeviceFailedMessage() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice(A_USER_ID.value)) + state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + awaitItem().also { state -> + assertThat(state.resolveAction).isEqualTo(AsyncAction.Loading) + } + // This should move to the next user + skipItems(2) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice(A_USER_ID_2.value)) + assertThat(state.resolveAction).isEqualTo(AsyncAction.Success(Unit)) + state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + + skipItems(3) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `present - verified user unsigned device failure resolve and resend scenario with error`() = runTest { + val room = FakeMatrixRoom( + userDisplayNameResult = { userId -> + Result.success(userId.value) + }, + ignoreDeviceTrustAndResendResult = { _, _ -> + Result.failure(Exception()) + }, + ) + val presenter = createResolveVerifiedUserSendFailurePresenter(room) + presenter.test { + val failedMessage = aVerifiedUserHasUnsignedDeviceFailedMessage() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice(A_USER_ID.value)) + state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + awaitItem().also { state -> + assertThat(state.resolveAction).isEqualTo(AsyncAction.Loading) + } + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice(A_USER_ID.value)) + assertThat(state.resolveAction).isInstanceOf(AsyncAction.Failure::class.java) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `present - verified user changed identity failure retry scenario`() = runTest { + val room = FakeMatrixRoom( + userDisplayNameResult = { userId -> + Result.success(userId.value) + }, + retrySendMessageResult = { + Result.success(Unit) + }, + ) + val presenter = createResolveVerifiedUserSendFailurePresenter(room) + presenter.test { + val failedMessage = aVerifiedUserChangedIdentityMessage() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(A_USER_ID.value)) + state.eventSink(ResolveVerifiedUserSendFailureEvents.Retry) + } + awaitItem().also { state -> + assertThat(state.retryAction).isEqualTo(AsyncAction.Loading) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + assertThat(state.retryAction).isEqualTo(AsyncAction.Success(Unit)) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `present - verified user changed identity failure resolve and resend scenario`() = runTest { + val room = FakeMatrixRoom( + userDisplayNameResult = { userId -> + Result.success(userId.value) + }, + withdrawVerificationAndResendResult = { _, _ -> + Result.success(Unit) + }, + ) + val presenter = createResolveVerifiedUserSendFailurePresenter(room) + presenter.test { + val failedMessage = aVerifiedUserChangedIdentityMessage() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(A_USER_ID.value)) + state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + awaitItem().also { state -> + assertThat(state.resolveAction).isEqualTo(AsyncAction.Loading) + } + // This should move to the next user + skipItems(2) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(A_USER_ID_2.value)) + assertThat(state.resolveAction).isEqualTo(AsyncAction.Success(Unit)) + state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + + skipItems(3) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `present - verified user changed identity failure resolve and resend scenario with error`() = runTest { + val room = FakeMatrixRoom( + userDisplayNameResult = { userId -> + Result.success(userId.value) + }, + withdrawVerificationAndResendResult = { _, _ -> + Result.failure(Exception()) + }, + ) + val presenter = createResolveVerifiedUserSendFailurePresenter(room) + presenter.test { + val failedMessage = aVerifiedUserChangedIdentityMessage() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(A_USER_ID.value)) + state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + awaitItem().also { state -> + assertThat(state.resolveAction).isEqualTo(AsyncAction.Loading) + } + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(A_USER_ID.value)) + assertThat(state.resolveAction).isInstanceOf(AsyncAction.Failure::class.java) + } + ensureAllEventsConsumed() + } + } + + private fun aVerifiedUserHasUnsignedDeviceFailedMessage(): TimelineItem.Event { + return aMessageEvent( + transactionId = A_TRANSACTION_ID, + sendState = LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice( + mapOf( + A_USER_ID to emptyList(), + A_USER_ID_2 to emptyList() + ) + ) + ) + } + + private fun aVerifiedUserChangedIdentityMessage(): TimelineItem.Event { + return aMessageEvent( + transactionId = A_TRANSACTION_ID, + sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity( + listOf(A_USER_ID, A_USER_ID_2) + ) + ) + } + + private fun createResolveVerifiedUserSendFailurePresenter( + room: MatrixRoom = FakeMatrixRoom(), + ): ResolveVerifiedUserSendFailurePresenter { + return ResolveVerifiedUserSendFailurePresenter( + room = room, + verifiedUserSendFailureFactory = VerifiedUserSendFailureFactory(room), + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureViewTest.kt new file mode 100644 index 0000000000..e5d67848dd --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureViewTest.kt @@ -0,0 +1,61 @@ +/* + * 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.sendfailure.resolve + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ResolveVerifiedUserSendFailureViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on resolve and resend emit the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setResolveVerifiedUserSendFailureView( + state = aResolveVerifiedUserSendFailureState( + verifiedUserSendFailure = aChangedIdentitySendFailure(), + eventSink = eventsRecorder, + ), + ) + + rule.clickOn(res = CommonStrings.screen_resolve_send_failure_changed_identity_primary_button_title) + eventsRecorder.assertSingle(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + + @Test + fun `clicking on retry emit the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setResolveVerifiedUserSendFailureView( + state = aResolveVerifiedUserSendFailureState( + verifiedUserSendFailure = aChangedIdentitySendFailure(), + eventSink = eventsRecorder, + ), + ) + + rule.clickOn(res = CommonStrings.action_retry) + eventsRecorder.assertSingle(ResolveVerifiedUserSendFailureEvents.Retry) + } + + private fun AndroidComposeTestRule.setResolveVerifiedUserSendFailureView( + state: ResolveVerifiedUserSendFailureState, + ) { + setContent { + ResolveVerifiedUserSendFailureView(state = state) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoundedIconAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoundedIconAtom.kt index 9dc739da96..c21e1a5f38 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoundedIconAtom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoundedIconAtom.kt @@ -37,6 +37,7 @@ import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial * @param resourceId the resource id of the icon to display, exclusive with [imageVector] * @param imageVector the image vector of the icon to display, exclusive with [resourceId] * @param tint the tint to apply to the icon + * @param backgroundTint the tint to apply to the icon background */ @Composable fun RoundedIconAtom( @@ -44,13 +45,14 @@ fun RoundedIconAtom( size: RoundedIconAtomSize = RoundedIconAtomSize.Large, resourceId: Int? = null, imageVector: ImageVector? = null, - tint: Color = MaterialTheme.colorScheme.secondary + tint: Color = MaterialTheme.colorScheme.secondary, + backgroundTint: Color = ElementTheme.colors.temporaryColorBgSpecial, ) { Box( modifier = modifier .size(size.toContainerSize()) .background( - color = ElementTheme.colors.temporaryColorBgSpecial, + color = backgroundTint, shape = RoundedCornerShape(size.toCornerSize()) ) ) { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt index f4c88ab59b..4812eaddee 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt @@ -27,6 +27,7 @@ import io.element.android.libraries.designsystem.icons.CompoundDrawables import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial /** * IconTitleSubtitleMolecule is a molecule which displays an icon, a title and a subtitle. @@ -37,6 +38,7 @@ import io.element.android.libraries.designsystem.theme.components.Text * @param iconResourceId the resource id of the icon to display, exclusive with [iconImageVector] * @param iconImageVector the image vector of the icon to display, exclusive with [iconResourceId] * @param iconTint the tint to apply to the icon + * @param iconBackgroundTint the tint to apply to the icon background */ @Composable fun IconTitleSubtitleMolecule( @@ -46,6 +48,7 @@ fun IconTitleSubtitleMolecule( iconResourceId: Int? = null, iconImageVector: ImageVector? = null, iconTint: Color = MaterialTheme.colorScheme.primary, + iconBackgroundTint: Color = ElementTheme.colors.temporaryColorBgSpecial, ) { Column(modifier) { RoundedIconAtom( @@ -55,6 +58,7 @@ fun IconTitleSubtitleMolecule( resourceId = iconResourceId, imageVector = iconImageVector, tint = iconTint, + backgroundTint = iconBackgroundTint, ) Spacer(modifier = Modifier.height(16.dp)) Text( diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt index 3e2fd6dcd5..f09ccad5d9 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt @@ -80,11 +80,11 @@ fun ListItem( /** * A List Item component to be used in lists and menus with simple layouts, matching the Material 3 guidelines. * @param headlineContent The main content of the list item, usually a text. + * @param colors The colors to use for the list item. You can use [ListItemDefaults.colors] to create this. * @param modifier The modifier to be applied to the list item. * @param supportingContent The content to be displayed below the headline content. * @param leadingContent The content to be displayed before the headline content. * @param trailingContent The content to be displayed after the headline content. - * @param colors The colors to use for the list item. * @param enabled Whether the list item is enabled. When disabled, will change the color of the headline content and the leading content to use disabled tokens. * @param onClick The callback to be called when the list item is clicked. */ @@ -412,8 +412,8 @@ private object PreviewItems { ) { ElementThemedPreview { ListItem( - headlineContent = PreviewItems.headline(), - supportingContent = PreviewItems.text(), + headlineContent = headline(), + supportingContent = text(), leadingContent = leadingContent, trailingContent = trailingContent, style = style, @@ -431,8 +431,8 @@ private object PreviewItems { ) { ElementThemedPreview { ListItem( - headlineContent = PreviewItems.headline(), - supportingContent = PreviewItems.textSingleLine(), + headlineContent = headline(), + supportingContent = textSingleLine(), leadingContent = leadingContent, trailingContent = trailingContent, style = style, @@ -451,7 +451,7 @@ private object PreviewItems { ) { ElementThemedPreview { ListItem( - headlineContent = PreviewItems.headline(), + headlineContent = headline(), leadingContent = leadingContent, trailingContent = trailingContent, enabled = enabled, 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 25340684d6..1936b9f60c 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 @@ -354,5 +354,4 @@ interface MatrixRoom : Closeable { suspend fun withdrawVerificationAndResend(userIds: List, transactionId: TransactionId): Result override fun close() = destroy() - } 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 381063f0e1..b871062f70 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 @@ -647,10 +647,9 @@ class RustMatrixRoom( override suspend fun ignoreDeviceTrustAndResend(devices: Map>, transactionId: TransactionId) = runCatching { innerRoom.ignoreDeviceTrustAndResend( - devices = devices - .entries.associate { entry -> - entry.key.value to entry.value.map { it.value } - }, + devices = devices.entries.associate { entry -> + entry.key.value to entry.value.map { it.value } + }, transactionId = transactionId.value ) } 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 2290897f11..54b52c6fb7 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 @@ -80,7 +80,7 @@ class FakeMatrixRoom( private var roomPermalinkResult: () -> Result = { lambdaError() }, private var eventPermalinkResult: (EventId) -> Result = { lambdaError() }, private val sendCallNotificationIfNeededResult: () -> Result = { lambdaError() }, - private val userDisplayNameResult: () -> Result = { lambdaError() }, + private val userDisplayNameResult: (UserId) -> Result = { lambdaError() }, private val userAvatarUrlResult: () -> Result = { lambdaError() }, private val userRoleResult: () -> Result = { lambdaError() }, private val getUpdatedMemberResult: (UserId) -> Result = { lambdaError() }, @@ -137,7 +137,6 @@ class FakeMatrixRoom( private val subscribeToSyncLambda: () -> Unit = { lambdaError() }, private val ignoreDeviceTrustAndResendResult: (Map>, TransactionId) -> Result = { _, _ -> lambdaError() }, private val withdrawVerificationAndResendResult: (List, TransactionId) -> Result = { _, _ -> lambdaError() }, - ) : MatrixRoom { private val _roomInfoFlow: MutableSharedFlow = MutableSharedFlow(replay = 1) override val roomInfoFlow: Flow = _roomInfoFlow @@ -203,7 +202,7 @@ class FakeMatrixRoom( override fun destroy() = Unit override suspend fun userDisplayName(userId: UserId): Result = simulateLongTask { - userDisplayNameResult() + userDisplayNameResult(userId) } override suspend fun userAvatarUrl(userId: UserId): Result = simulateLongTask { @@ -230,7 +229,7 @@ class FakeMatrixRoom( return toggleReactionResult(emoji, uniqueId) } - override suspend fun retrySendMessage(transactionId: TransactionId): Result { + override suspend fun retrySendMessage(transactionId: TransactionId): Result = simulateLongTask { return retrySendMessageResult(transactionId) } @@ -496,11 +495,11 @@ class FakeMatrixRoom( return getWidgetDriverResult(widgetSettings) } - override suspend fun ignoreDeviceTrustAndResend(devices: Map>, transactionId: TransactionId): Result { + override suspend fun ignoreDeviceTrustAndResend(devices: Map>, transactionId: TransactionId): Result = simulateLongTask { return ignoreDeviceTrustAndResendResult(devices, transactionId) } - override suspend fun withdrawVerificationAndResend(userIds: List, transactionId: TransactionId): Result { + override suspend fun withdrawVerificationAndResend(userIds: List, transactionId: TransactionId): Result = simulateLongTask { return withdrawVerificationAndResendResult(userIds, transactionId) }