Display a badge for messages decrypted using shared keys (#6023)

The EXA side of element-hq/element-meta#2877: if the keys for a message have been forwarded by another user, indicate that in the UI via the text shown when tapping the event shield.
This commit is contained in:
Richard van der Hoff
2026-01-16 17:24:18 +00:00
committed by GitHub
parent a9c1da5aac
commit ae76e8b0ea
18 changed files with 124 additions and 38 deletions

View File

@@ -8,12 +8,12 @@
package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlin.time.Duration
sealed interface TimelineEvents {
@@ -31,7 +31,7 @@ sealed interface TimelineEvents {
sealed interface EventFromTimelineItem : TimelineEvents
data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : EventFromTimelineItem
data class ShowShieldDialog(val messageShield: MessageShield) : EventFromTimelineItem
data class ShowShieldDialog(val messageShieldData: MessageShieldData) : EventFromTimelineItem
data class LoadMore(val direction: Timeline.PaginationDirection) : EventFromTimelineItem
data class OpenThread(val threadRootEventId: ThreadId, val focusedEvent: EventId?) : EventFromTimelineItem

View File

@@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvents
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.model.NewEventState
@@ -52,7 +53,6 @@ import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsSta
import io.element.android.libraries.matrix.api.room.roomMembers
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.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems
@@ -133,7 +133,7 @@ class TimelinePresenter(
val prevMostRecentItemId = rememberSaveable { mutableStateOf<UniqueId?>(null) }
val newEventState = remember { mutableStateOf(NewEventState.None) }
val messageShield: MutableState<MessageShield?> = remember { mutableStateOf(null) }
val messageShieldDialogData: MutableState<MessageShieldData?> = remember { mutableStateOf(null) }
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
val isSendPublicReadReceiptsEnabled by remember {
@@ -215,8 +215,8 @@ class TimelinePresenter(
is TimelineEvents.JumpToLive -> {
timelineController.focusOnLive()
}
TimelineEvents.HideShieldDialog -> messageShield.value = null
is TimelineEvents.ShowShieldDialog -> messageShield.value = event.messageShield
TimelineEvents.HideShieldDialog -> messageShieldDialogData.value = null
is TimelineEvents.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData
is TimelineEvents.ComputeVerifiedUserSendFailure -> {
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(event.event))
}
@@ -312,7 +312,7 @@ class TimelinePresenter(
newEventState = newEventState.value,
isLive = isLive,
focusRequestState = focusRequestState.value,
messageShield = messageShield.value,
messageShieldDialogData = messageShieldDialogData.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
eventSink = ::handleEvent,

View File

@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.typing.TypingNotificationState
@@ -18,7 +19,6 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration
@@ -31,7 +31,7 @@ data class TimelineState(
val isLive: Boolean,
val focusRequestState: FocusRequestState,
// If not null, info will be rendered in a dialog
val messageShield: MessageShield?,
val messageShieldDialogData: MessageShieldData?,
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val displayThreadSummaries: Boolean,
val eventSink: (TimelineEvents) -> Unit,

View File

@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline
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.timeline.components.MessageShieldData
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
@@ -71,7 +72,7 @@ fun aTimelineState(
newEventState = NewEventState.None,
isLive = isLive,
focusRequestState = focusRequestState,
messageShield = messageShield,
messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
eventSink = eventSink,
@@ -176,7 +177,9 @@ internal fun aTimelineItemEvent(
origin = null,
timelineItemDebugInfoProvider = { debugInfo },
messageShieldProvider = { messageShield },
sendHandleProvider = { null }
sendHandleProvider = { null },
forwarder = null,
forwarderProfile = null,
)
}

View File

@@ -220,7 +220,7 @@ fun TimelineView(
@Composable
private fun MessageShieldDialog(state: TimelineState) {
val messageShield = state.messageShield ?: return
val messageShield = state.messageShieldDialogData ?: return
AlertDialog(
content = messageShield.toText(),
onDismiss = { state.eventSink.invoke(TimelineEvents.HideShieldDialog) },

View File

@@ -27,14 +27,17 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.isCritical
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun MessageShieldView(
shield: MessageShield,
modifier: Modifier = Modifier
shield: MessageShieldData,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -55,8 +58,24 @@ internal fun MessageShieldView(
}
}
data class MessageShieldData(
/**
* The message shield that the rust layer thinks we should show.
*/
val shield: MessageShield,
/**
* If the keys to this message were forwarded by another user via history sharing (MSC4268), the ID of that user.
*/
val forwarder: UserId? = null,
/** If [forwarder] is set, the profile of the forwarding user, if it was cached at the time the `EventTimelineItem` was created. */
val forwarderProfile: ProfileDetails? = null,
)
val MessageShieldData.isCritical: Boolean
get() = shield.isCritical
@Composable
internal fun MessageShield.toIconColor(): Color {
internal fun MessageShieldData.toIconColor(): Color {
return when (isCritical) {
true -> ElementTheme.colors.iconCriticalPrimary
false -> ElementTheme.colors.iconSecondary
@@ -64,7 +83,7 @@ internal fun MessageShield.toIconColor(): Color {
}
@Composable
private fun MessageShield.toTextColor(): Color {
private fun MessageShieldData.toTextColor(): Color {
return when (isCritical) {
true -> ElementTheme.colors.textCriticalPrimary
false -> ElementTheme.colors.textSecondary
@@ -72,9 +91,24 @@ private fun MessageShield.toTextColor(): Color {
}
@Composable
internal fun MessageShield.toText(): String {
internal fun MessageShieldData.toText(): String {
if (shield is MessageShield.AuthenticityNotGuaranteed && forwarder != null) {
var displayName = forwarderProfile?.getDisplayName()
return if (displayName == null) {
stringResource(
CommonStrings.crypto_event_key_forwarded_unknown_profile_dialog_content,
forwarder.toString(),
)
} else {
stringResource(
CommonStrings.crypto_event_key_forwarded_known_profile_dialog_content,
displayName,
forwarder.toString(),
)
}
}
return stringResource(
id = when (this) {
id = when (shield) {
is MessageShield.AuthenticityNotGuaranteed -> CommonStrings.event_shield_reason_authenticity_not_guaranteed
is MessageShield.UnknownDevice -> CommonStrings.event_shield_reason_unknown_device
is MessageShield.UnsignedDevice -> CommonStrings.event_shield_reason_unsigned_device
@@ -87,8 +121,8 @@ internal fun MessageShield.toText(): String {
}
@Composable
internal fun MessageShield.toIcon(): ImageVector {
return when (this) {
internal fun MessageShieldData.toIcon(): ImageVector {
return when (shield) {
is MessageShield.AuthenticityNotGuaranteed -> CompoundIcons.Info()
is MessageShield.UnknownDevice,
is MessageShield.UnsignedDevice,
@@ -108,25 +142,42 @@ internal fun MessageShieldViewPreview() {
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
MessageShieldView(
shield = MessageShield.UnknownDevice(true)
shield = MessageShieldData(MessageShield.UnknownDevice(true))
)
MessageShieldView(
shield = MessageShield.UnverifiedIdentity(true)
shield = MessageShieldData(MessageShield.UnverifiedIdentity(true))
)
MessageShieldView(
shield = MessageShield.AuthenticityNotGuaranteed(false)
shield = MessageShieldData(MessageShield.AuthenticityNotGuaranteed(false))
)
MessageShieldView(
shield = MessageShield.UnsignedDevice(false)
shield = MessageShieldData(
MessageShield.AuthenticityNotGuaranteed(false),
forwarder = UserId("@alice:example.com"),
)
)
MessageShieldView(
shield = MessageShield.SentInClear(false)
shield = MessageShieldData(
MessageShield.AuthenticityNotGuaranteed(false),
forwarder = UserId("@alice:example.com"),
forwarderProfile = ProfileDetails.Ready(
displayName = "Alice",
displayNameAmbiguous = false,
avatarUrl = null,
),
)
)
MessageShieldView(
shield = MessageShield.VerificationViolation(false)
shield = MessageShieldData(MessageShield.UnsignedDevice(false))
)
MessageShieldView(
shield = MessageShield.MismatchedSender(false)
shield = MessageShieldData(MessageShield.SentInClear(false))
)
MessageShieldView(
shield = MessageShieldData(MessageShield.VerificationViolation(false))
)
MessageShieldView(
shield = MessageShieldData(MessageShield.MismatchedSender(false))
)
}
}

View File

@@ -118,6 +118,8 @@ class TimelineItemEventFactory(
timelineItemDebugInfoProvider = currentTimelineItem.event.timelineItemDebugInfoProvider,
messageShieldProvider = currentTimelineItem.event.messageShieldProvider,
sendHandleProvider = currentTimelineItem.event.sendHandleProvider,
forwarder = currentTimelineItem.event.forwarder,
forwarderProfile = currentTimelineItem.event.forwarderProfile,
)
}

View File

@@ -9,6 +9,7 @@
package io.element.android.features.messages.impl.timeline.model
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
@@ -87,6 +88,13 @@ sealed interface TimelineItem {
val timelineItemDebugInfoProvider: TimelineItemDebugInfoProvider,
val messageShieldProvider: MessageShieldProvider,
val sendHandleProvider: SendHandleProvider,
/**
* If the keys to this message were forwarded by another user via history sharing (MSC4268), the ID of that user.
* If this is non-null, then [messageShieldProvider] will also return [MessageShield.AuthenticityNotGuaranteed].
*/
val forwarder: UserId?,
/** If [forwarder] is set, the profile of the forwarding user, if it was cached at the time the `EventTimelineItem` was created. */
val forwarderProfile: ProfileDetails?,
) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine
@@ -115,7 +123,9 @@ sealed interface TimelineItem {
get() = EventOrTransactionId.from(eventId = eventId, transactionId = transactionId)
// No need to be lazy here?
val messageShield: MessageShield? = messageShieldProvider(strict = false)
val messageShield: MessageShieldData? = messageShieldProvider(strict = false)?.let {
MessageShieldData(it, forwarder, forwarderProfile)
}
val debugInfo: TimelineItemDebugInfo
get() = timelineItemDebugInfoProvider()

View File

@@ -68,4 +68,6 @@ internal fun aMessageEvent(
timelineItemDebugInfoProvider = debugInfoProvider,
messageShieldProvider = messageShieldProvider,
sendHandleProvider = sendHandleProvider,
forwarder = null,
forwarderProfile = null,
)

View File

@@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.FakeMessagesNavigator
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -851,14 +852,15 @@ class TimelinePresenterTest {
val shield = aCriticalShield()
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.messageShield).isNull()
initialState.eventSink(TimelineEvents.ShowShieldDialog(shield))
assertThat(initialState.messageShieldDialogData).isNull()
val shieldData = MessageShieldData(shield, null, null)
initialState.eventSink(TimelineEvents.ShowShieldDialog(shieldData))
awaitItem().also { state ->
assertThat(state.messageShield).isEqualTo(shield)
assertThat(state.messageShieldDialogData).isEqualTo(shieldData)
state.eventSink(TimelineEvents.HideShieldDialog)
}
awaitItem().also { state ->
assertThat(state.messageShield).isNull()
assertThat(state.messageShieldDialogData).isNull()
}
}
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToIndex
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
@@ -122,7 +123,7 @@ class TimelineViewTest {
eventsRecorder.assertList(
listOf(
TimelineEvents.OnScrollFinished(0),
TimelineEvents.ShowShieldDialog(MessageShield.UnverifiedIdentity(true)),
TimelineEvents.ShowShieldDialog(MessageShieldData(MessageShield.UnverifiedIdentity(true))),
)
)
}

View File

@@ -47,6 +47,8 @@ class TimelineItemGrouperTest {
timelineItemDebugInfoProvider = { aTimelineItemDebugInfo() },
messageShieldProvider = { null },
sendHandleProvider = { FakeSendHandle() },
forwarder = null,
forwarderProfile = null,
)
private val aNonGroupableItem = aMessageEvent()
private val aNonGroupableItemNoEvent = TimelineItem.Virtual(UniqueId("virtual"), aTimelineItemDaySeparatorModel("Today"))

View File

@@ -99,6 +99,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
},
messageShieldProvider = { null },
sendHandleProvider = { FakeSendHandle() },
forwarder = null,
forwarderProfile = null,
),
)
)

View File

@@ -34,6 +34,13 @@ data class EventTimelineItem(
val timelineItemDebugInfoProvider: TimelineItemDebugInfoProvider,
val messageShieldProvider: MessageShieldProvider,
val sendHandleProvider: SendHandleProvider,
/**
* If the keys to this message were forwarded by another user via history sharing (MSC4268), the ID of that user.
* If this is set, then [messageShieldProvider] will also return [MessageShield.AuthenticityNotGuaranteed].
*/
val forwarder: UserId?,
/** If [forwarder] is set, the profile of the forwarding user, if it was cached at the time this `EventTimelineItem` was created. */
val forwarderProfile: ProfileDetails?,
) {
fun inReplyTo(): InReplyTo? {
return (content as? MessageContent)?.inReplyTo

View File

@@ -59,7 +59,9 @@ class EventTimelineItemMapper(
origin = origin?.map(),
timelineItemDebugInfoProvider = { lazyProvider.debugInfo().map() },
messageShieldProvider = { strict -> lazyProvider.getShields(strict).map() },
sendHandleProvider = { lazyProvider.getSendHandle()?.let(::RustSendHandle) }
sendHandleProvider = { lazyProvider.getSendHandle()?.let(::RustSendHandle) },
forwarder = forwarder?.let { UserId(it) },
forwarderProfile = forwarderProfile?.map(),
)
}
}

View File

@@ -77,6 +77,8 @@ fun anEventTimelineItem(
timelineItemDebugInfoProvider = debugInfoProvider,
messageShieldProvider = messageShieldProvider,
sendHandleProvider = sendHandleProvider,
forwarder = null,
forwarderProfile = null,
)
fun aProfileDetails(