Content scanner (Element Pro): scan timeline events for unsafe content (#7159)

* Simplify how event content with captions are displayed

The caption component will now be added in `TimelineItemEventContentView` instead of separately in each `TimelineItem__View` component

* Make sure `CoilMediaFetcher` methods to fetch media throw errors

We need to catch them later to check if the content scanner marked some thumbnails or medias as invalid/unsafe in `AsyncImage` instances

* Add a `ContentScannerService` that allows us to manually scan a list of medias in an event and then update and store the results

The results take the form of `ContentValidationValue`, stored in a `ContentValidationState` instance tied to the event

* Create `EventContentValidationCache` to keep the `ContentValidationStates` for events in a room

* Expose `EventContentValidationCache` to Compose using `LocalEventContentValidationState`

Also add a couple of helper functions to avoid having to manually remember the states

* Use the new components to display invalid content in the timeline items

* Add `blurhash` to most media items so we can use it while validating the thumbnails/medias

* Fix custom layout issues with screenshot and UI tests

* Also check the validation state for replied-to events in the composer

* Fix and add tests

* Handle unrecoverable content validation errors too: display a 'not found' UI

* Improve the performance of `EqualWidthColumn` for the single item case

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-07-16 09:59:51 +02:00
committed by GitHub
parent 56cf00560e
commit 0d03472c60
241 changed files with 2881 additions and 828 deletions
@@ -27,6 +27,8 @@ class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint {
),
mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null,
// Maybe add avatar blurhash if available?
blurHash = null,
)
}
@@ -116,6 +116,7 @@ class EventItemFactory(
),
mediaSource = c.source,
thumbnailSource = c.info?.thumbnailSource,
blurHash = c.info?.blurhash,
)
}
is GalleryItemType.Video -> {
@@ -136,6 +137,7 @@ class EventItemFactory(
),
mediaSource = c.source,
thumbnailSource = c.info?.thumbnailSource,
blurHash = c.info?.blurhash,
)
}
is GalleryItemType.Audio -> {
@@ -242,6 +244,7 @@ class EventItemFactory(
),
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
blurHash = type.info?.blurhash,
))
is StickerMessageType -> listOf(MediaItem.Image(
id = currentTimelineItem.uniqueId,
@@ -264,6 +267,7 @@ class EventItemFactory(
),
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
blurHash = type.info?.blurhash,
))
is VideoMessageType -> listOf(MediaItem.Video(
id = currentTimelineItem.uniqueId,
@@ -286,6 +290,7 @@ class EventItemFactory(
),
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
blurHash = type.info?.blurhash,
))
is VoiceMessageType -> listOf(MediaItem.Voice(
id = currentTimelineItem.uniqueId,
@@ -34,6 +34,7 @@ import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryNode
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.blurHash
import io.element.android.libraries.mediaviewer.impl.model.eventId
import io.element.android.libraries.mediaviewer.impl.model.mediaInfo
import io.element.android.libraries.mediaviewer.impl.model.mediaSource
@@ -68,6 +69,7 @@ class MediaGalleryFlowNode(
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val blurHash: String?,
) : NavTarget
}
@@ -104,6 +106,7 @@ class MediaGalleryFlowNode(
mediaInfo = item.mediaInfo(),
mediaSource = item.mediaSource(),
thumbnailSource = item.thumbnailSource(),
blurHash = item.blurHash(),
)
)
}
@@ -134,6 +137,7 @@ class MediaGalleryFlowNode(
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
blurHash = navTarget.blurHash,
),
callback = callback,
)
@@ -34,6 +34,7 @@ import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -51,6 +52,7 @@ fun VideoItemView(
) {
Box(
modifier = modifier
.blurHashBackground(blurHash = video.blurHash)
.aspectRatio(1f)
.combinedClickable(
onClick = onClick,
@@ -35,6 +35,7 @@ sealed interface MediaItem {
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val blurHash: String?,
) : Event {
val thumbnailMediaRequestData: MediaRequestData
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
@@ -46,6 +47,7 @@ sealed interface MediaItem {
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val blurHash: String?,
) : Event {
val thumbnailMediaRequestData: MediaRequestData
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
@@ -124,3 +126,11 @@ fun MediaItem.Event.thumbnailSource(): MediaSource? {
is MediaItem.Voice -> null
}
}
fun MediaItem.Event.blurHash(): String? {
return when (this) {
is MediaItem.Image -> blurHash
is MediaItem.Video -> blurHash
else -> null
}
}
@@ -34,6 +34,7 @@ fun aMediaItemImage(
),
mediaSource = MediaSource(mediaSourceUrl),
thumbnailSource = null,
blurHash = null,
)
}
@@ -50,6 +51,7 @@ fun aMediaItemVideo(
),
mediaSource = mediaSource,
thumbnailSource = null,
blurHash = null,
)
}
@@ -63,6 +63,7 @@ class GalleryMediaGalleryDataSource(
mediaInfo = itemMediaInfo,
mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource,
blurHash = galleryItem.blurHash,
)
GalleryItemData.Type.Audio -> MediaItem.Audio(
id = id,
@@ -82,6 +83,7 @@ class GalleryMediaGalleryDataSource(
mediaInfo = itemMediaInfo,
mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource,
blurHash = galleryItem.blurHash,
)
}
mixedItems.add(mediaItem)
@@ -63,6 +63,7 @@ class SingleMediaGalleryDataSource(
),
mediaSource = params.mediaSource,
thumbnailSource = params.thumbnailSource,
blurHash = params.blurHash,
)
),
fileItems = persistentListOf(),
@@ -79,6 +80,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
mediaInfo = mediaInfo,
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
blurHash = blurHash,
)
}
mediaInfo.mimeType.isMimeTypeVideo() -> {
@@ -88,6 +90,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
mediaInfo = mediaInfo,
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
blurHash = blurHash,
)
}
mediaInfo.mimeType.isMimeTypeAudio() -> {
@@ -141,6 +141,7 @@ class DefaultMediaViewerEntryPointTest {
avatarInfo = AvatarInfo(filename = "avatar.png"),
mediaSource = MediaSource(url = "avatarUrl"),
thumbnailSource = null,
blurHash = null,
)
)
assertThat(result.plugins).contains(callback)
@@ -226,6 +226,7 @@ class EventItemFactoryTest {
),
mediaSource = MediaSource(""),
thumbnailSource = null,
blurHash = null,
)
)
}
@@ -326,6 +327,7 @@ class EventItemFactoryTest {
),
mediaSource = MediaSource(""),
thumbnailSource = null,
blurHash = null,
)
)
}
@@ -437,6 +439,7 @@ class EventItemFactoryTest {
),
mediaSource = MediaSource("image_url"),
thumbnailSource = MediaSource("thumbnail_url"),
blurHash = null,
)
)
}
@@ -498,6 +501,7 @@ class EventItemFactoryTest {
),
mediaSource = MediaSource("video_url"),
thumbnailSource = MediaSource("thumbnail_url"),
blurHash = null,
)
)
}
@@ -744,6 +748,7 @@ class EventItemFactoryTest {
),
mediaSource = MediaSource(""),
thumbnailSource = null,
blurHash = null,
)
)
}
@@ -249,6 +249,7 @@ class TimelineMediaGalleryDataSourceTest {
),
mediaSource = MediaSource("url"),
thumbnailSource = MediaSource("url_thumbnail"),
blurHash = A_BLUR_HASH,
)
),
fileItems = persistentListOf()
@@ -91,6 +91,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("image_url"),
thumbnailSource = MediaSource("thumbnail_url"),
type = GalleryItemData.Type.Image,
blurHash = null,
)
),
galleryInfo = aGalleryInfo(),
@@ -104,6 +105,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaInfo = expectedMediaInfo("image.jpg", MimeTypes.Jpeg),
mediaSource = MediaSource("image_url"),
thumbnailSource = MediaSource("thumbnail_url"),
blurHash = null,
)
)
}
@@ -119,6 +121,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("video_url"),
thumbnailSource = MediaSource("thumbnail_url"),
type = GalleryItemData.Type.Video,
blurHash = null,
)
),
galleryInfo = aGalleryInfo(),
@@ -132,6 +135,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaInfo = expectedMediaInfo("video.mp4", MimeTypes.Mp4),
mediaSource = MediaSource("video_url"),
thumbnailSource = MediaSource("thumbnail_url"),
blurHash = null,
)
)
}
@@ -147,6 +151,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("audio_url"),
thumbnailSource = null,
type = GalleryItemData.Type.Audio,
blurHash = null,
)
),
galleryInfo = aGalleryInfo(),
@@ -174,6 +179,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("file_url"),
thumbnailSource = null,
type = GalleryItemData.Type.File,
blurHash = null,
)
),
galleryInfo = aGalleryInfo(),
@@ -201,6 +207,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("image_url"),
thumbnailSource = null,
type = GalleryItemData.Type.Image,
blurHash = null,
)
),
galleryInfo = aGalleryInfo(),
@@ -222,6 +229,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("image_url"),
thumbnailSource = null,
type = GalleryItemData.Type.Image,
blurHash = null,
),
GalleryItemData(
filename = "document.pdf",
@@ -229,6 +237,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("file_url"),
thumbnailSource = null,
type = GalleryItemData.Type.File,
blurHash = null,
),
GalleryItemData(
filename = "video.mp4",
@@ -236,6 +245,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("video_url"),
thumbnailSource = null,
type = GalleryItemData.Type.Video,
blurHash = null,
),
),
galleryInfo = aGalleryInfo(),
@@ -114,6 +114,7 @@ class MediaViewerPresenterTest {
avatarInfo = AvatarInfo(filename = "avatar.png"),
mediaSource = aMediaSource(),
thumbnailSource = null,
blurHash = null,
),
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
@@ -983,4 +984,5 @@ internal fun createMediaViewerEntryPointParams(
mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(),
thumbnailSource = null,
blurHash = null,
)
@@ -89,6 +89,7 @@ class SingleMediaGalleryDataSourceTest {
mediaInfo = params.mediaInfo,
mediaSource = params.mediaSource,
thumbnailSource = params.thumbnailSource,
blurHash = null,
)
}
)
@@ -105,6 +106,7 @@ class SingleMediaGalleryDataSourceTest {
mediaInfo = params.mediaInfo,
mediaSource = params.mediaSource,
thumbnailSource = params.thumbnailSource,
blurHash = null,
)
}
)
@@ -175,5 +177,6 @@ class SingleMediaGalleryDataSourceTest {
mediaInfo = mediaInfo,
mediaSource = aMediaSource(url = "aUrl"),
thumbnailSource = aMediaSource(url = "aThumbnailUrl"),
blurHash = null,
)
}