Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4eeff16
Make sure `CoilMediaFetcher` throws any exception it finds so we can …
jmartinesp Jul 6, 2026
2290435
Add `LocalEventContentValidationState` to be able to track an event c…
jmartinesp Jul 6, 2026
9af9587
Add `InvalidContentView` to be used when the media content is invalid
jmartinesp Jul 6, 2026
eeb39c8
Use `InvalidContentView` along with the cache inside the content view…
jmartinesp Jul 6, 2026
a6a0cfc
Adjust the design of the message bubble based on the content validati…
jmartinesp Jul 6, 2026
2a42b3e
Disable clicking on events with invalid content
jmartinesp Jul 6, 2026
bb3552b
Ensure `TimelineItemEventContentView` in other places also passes an …
jmartinesp Jul 6, 2026
17f8ea7
Add `ContentScannerService` for running manual scans besides those th…
jmartinesp Jul 6, 2026
28f4222
Add the option to being able to manually scan media from the timeline…
jmartinesp Jul 6, 2026
10b1170
Add `ValidateMediaHelper` to manually scan the media content in the t…
jmartinesp Jul 6, 2026
a79f2cd
Add missing blurhash for the thumbnail of the video timeline items
jmartinesp Jul 6, 2026
df46c0c
Handle content in replies too
jmartinesp Jul 6, 2026
586873e
Include `isMediaContent`
jmartinesp Jul 7, 2026
74f84a0
Add `MediaItem.blurHash` to image and video items
jmartinesp Jul 7, 2026
ced7137
Move `ContentScannerService` to be injected in `RoomScope` to match `…
jmartinesp Jul 7, 2026
421ce9c
Fix lint issues
jmartinesp Jul 7, 2026
a9ae353
Fix tests
jmartinesp Jul 7, 2026
aae38ac
Update screenshots
ElementBot Jul 7, 2026
45a1362
Add new tests and improve screenshots
jmartinesp Jul 7, 2026
f9a07a9
Update screenshots
ElementBot Jul 7, 2026
82316bf
Tweak `TimelineItemVoiceView` states
jmartinesp Jul 7, 2026
5e69861
Fix rendering of `TimelineItemVideoView`
jmartinesp Jul 7, 2026
86d30c4
Fix rebase issue
jmartinesp Jul 8, 2026
68079e4
Add support for gallery events too - refactored `ContentValidationState`
jmartinesp Jul 8, 2026
7977b28
Fix tests
jmartinesp Jul 8, 2026
143d596
Try fixing screenshot tests
jmartinesp Jul 8, 2026
dd39549
Update screenshots
ElementBot Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.features.contentscanner.api

import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState

/**
* Service to perform security scans in the contents for a given event and media source.
*
* The default implementation does nothing, unless a [ContentScanner] is provided when building the client.
*/
fun interface ContentScannerService {
fun scan(eventId: EventId, mediaSources: List<MediaSource>, contentValidationState: ContentValidationState)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.features.contentscanner.impl

import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.io.IOException

/**
* Default implementation of [ContentScannerService] that uses a [ContentScanner] to scan media sources.
*
* When a scan is requested for a given event and media source, it'll check if a scan is really needed (the media source has not been scanned before),
* then it will use a per-event mutex to avoid performing concurrent scans for thumbnail and full-size media sources.
*
* If, before starting a new scan, the cached result is already a failure (either we're scanning the full media source and the thumbnail already failed
* or the other way around), it will not perform the scan again.
*/
class DefaultContentScannerService(
private val contentScanner: ContentScanner,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers,
) : ContentScannerService {
override fun scan(eventId: EventId, mediaSources: List<MediaSource>, contentValidationState: ContentValidationState) {
for (mediaSource in mediaSources) {
sessionCoroutineScope.launch(coroutineDispatchers.io.limitedParallelism(4)) {
val url = mediaSource.safeUrl
val currentState = contentValidationState.getMediaStateFlow(url).first()
if (currentState != ContentValidationValue.Unknown) return@launch
contentValidationState.update(url, ContentValidationValue.Loading)
contentScanner.scan(mediaSource)
.onSuccess { isValid ->
val contentValidationValue = if (isValid) ContentValidationValue.Valid else ContentValidationValue.Invalid
contentValidationState.update(url, contentValidationValue)
}
.onFailure { exception ->
if (exception is IOException) {
// If it's an IO-related exception, we can retry later, so we don't cache it
contentValidationState.update(url, ContentValidationValue.Unknown)
} else {
// For other exceptions, we cache the failure
contentValidationState.update(url, ContentValidationValue.UnrecoverableError(exception))
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.features.contentscanner.impl

import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
import java.util.concurrent.ConcurrentHashMap

@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class)
class DefaultEventContentValidationCache : EventContentValidationCache {
private val cache = ConcurrentHashMap<EventId, ContentValidationState>()

override operator fun get(eventId: EventId): ContentValidationState {
return cache.getOrPut(eventId) { DefaultContentValidationState() }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.features.contentscanner.impl.di

import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.features.contentscanner.impl.DefaultContentScannerService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import kotlinx.coroutines.CoroutineScope

@BindingContainer
@ContributesTo(RoomScope::class)
object ContentScannerModule {
@Provides
@SingleIn(RoomScope::class)
fun providesContentScannerService(
contentScanner: ContentScanner?,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
coroutineDispatchers: CoroutineDispatchers,
): ContentScannerService {
return if (contentScanner != null) {
DefaultContentScannerService(
contentScanner = contentScanner,
sessionCoroutineScope = sessionCoroutineScope,
coroutineDispatchers = coroutineDispatchers,
)
} else {
NoopContentScannerService()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.features.contentscanner.impl.di

import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue

/**
* Noop implementation of [ContentScannerService] that always returns success.
*
* This is used when the content scanner feature is not enabled or available.
*/
class NoopContentScannerService : ContentScannerService {
override fun scan(eventId: EventId, mediaSources: List<MediaSource>, contentValidationState: ContentValidationState) {
// Always return success for the noop implementation
for (mediaSource in mediaSources) {
val url = mediaSource.safeUrl
contentValidationState.update(url, ContentValidationValue.Valid)
}
}
}
1 change: 1 addition & 0 deletions features/messages/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ dependencies {
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.testtags)
implementation(projects.features.networkmonitor.api)
implementation(projects.features.contentscanner.api)
implementation(projects.services.analytics.compose)
implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.blurHash
import io.element.android.features.messages.impl.timeline.model.event.duration
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
Expand Down Expand Up @@ -155,6 +156,7 @@ class MessagesFlowNode(
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val canUseOverlay: Boolean,
val blurHash: String?,
) : NavTarget

@Parcelize
Expand Down Expand Up @@ -377,6 +379,7 @@ class MessagesFlowNode(
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
blurHash = navTarget.blurHash,
)
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
Expand Down Expand Up @@ -828,6 +831,7 @@ class MessagesFlowNode(
mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource,
type = galleryItem.type.toMediaViewerType(),
blurHash = galleryItem.blurhash,
)
}.reversed()
NavTarget.GalleryViewer(
Expand Down Expand Up @@ -861,6 +865,7 @@ class MessagesFlowNode(
mediaSource = attachment.mediaSource,
thumbnailSource = attachment.thumbnailSource,
type = GalleryItemData.Type.File,
blurHash = null,
)
}.reversed()
NavTarget.GalleryViewer(
Expand Down Expand Up @@ -920,6 +925,7 @@ class MessagesFlowNode(
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
canUseOverlay = canUseOverlay,
blurHash = content.blurHash(),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings
Expand Down Expand Up @@ -97,6 +99,7 @@ class MessagesNode(
private val permalinkParser: PermalinkParser,
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val eventContentValidationCache: EventContentValidationCache,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs(
val focusedEventId: EventId?,
Expand Down Expand Up @@ -261,6 +264,7 @@ class MessagesNode(
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
LocalEventContentValidationState provides eventContentValidationCache,
) {
val state = presenter.present()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
Expand All @@ -148,6 +150,8 @@ fun MessagesView(
forceJumpToBottomVisibility: Boolean = false,
knockRequestsBannerView: @Composable () -> Unit,
) {
val eventContentValidationState = LocalEventContentValidationState.current

OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event))
}
Expand All @@ -170,6 +174,9 @@ fun MessagesView(

fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}")
val eventId = event.eventId ?: return
if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return

val hideKeyboard = onEventContentClick(state.timelineState.isLive, event)
if (hideKeyboard) {
localView.hideKeyboard()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.features.location.api.LocationService
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.attachments.Attachment
Expand Down Expand Up @@ -61,8 +62,12 @@ import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.getDirectRoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.use
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.content
import io.element.android.libraries.matrix.ui.messages.reply.eventId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
Expand Down Expand Up @@ -135,6 +140,8 @@ class MessageComposerPresenter(
private val notificationConversationService: NotificationConversationService,
private val slashCommandService: SlashCommandService,
private val featureFlagService: FeatureFlagService,
private val contentScannerService: ContentScannerService,
private val contentValidationCache: EventContentValidationCache,
) : Presenter<MessageComposerState> {
@AssistedFactory
interface Factory {
Expand Down Expand Up @@ -848,6 +855,11 @@ class MessageComposerPresenter(
setText(newComposerMode.content, markdownTextEditorState, richTextEditorState, requestFocus = true)
}
else -> {
if (currentComposerMode is MessageComposerMode.Reply) {
val mediaSources = currentComposerMode.replyToDetails.content()?.mediaSources() ?: return@launch
val contentValidationState = contentValidationCache[currentComposerMode.replyToDetails.eventId()]
contentScannerService.scan(currentComposerMode.replyToDetails.eventId(), mediaSources, contentValidationState)
}
// When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario.
if (currentComposerMode.isEditing) {
setText("", markdownTextEditorState, richTextEditorState)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import io.element.android.features.messages.impl.timeline.components.event.Timel
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.poll.api.pollcontent.PollTitleView
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
Expand Down Expand Up @@ -290,9 +289,9 @@ private fun TimelineItemEventContentViewWrapper(
)
} else {
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
timelineProtectionState = timelineProtectionState,
onGalleryItemClick = onGalleryItemClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ 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.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import kotlin.time.Duration

sealed interface TimelineEvent {
Expand Down Expand Up @@ -61,4 +63,10 @@ sealed interface TimelineEvent {
) : TimelineItemPollEvent

data object StopLiveLocationShare : TimelineItemEvent

data class ValidateMedia(
val eventId: EventId,
val mediaSources: List<MediaSource>,
val validationState: ContentValidationState
) : TimelineItemEvent
}
Loading
Loading