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
@@ -13,6 +13,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -20,6 +21,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -35,8 +37,11 @@ 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.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView
import io.element.android.libraries.matrix.ui.messages.reply.eventId
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
@@ -134,28 +139,44 @@ private fun ReplyToModeView(
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
val shape = RoundedCornerShape(6.dp)
val contentValidationState = rememberEventContentValidationState(replyToDetails.eventId(), (replyToDetails as? InReplyToDetails.Ready)?.eventContent)
val currentValidationState by contentValidationState.collectOverallState()
val contentHasErrors = currentValidationState.hasError()
Box(
modifier
.clip(RoundedCornerShape(6.dp))
.background(ElementTheme.colors.bgCanvasDefault)
.border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp))
.clip(shape)
.background(
color = if (contentHasErrors) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault,
shape = shape,
)
.border(
width = 1.dp,
color = if (contentHasErrors) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary,
shape = shape,
)
.padding(4.dp)
) {
// Larger density DPI and font scale means less space to display the content, so we limit it to 1 line to avoid overflow issues
val currentDensity = LocalDensity.current
val hasLowResolution = currentDensity.density * currentDensity.fontScale >= MAX_SCALING_VALUE
val maxReplyContentLines = if (hasLowResolution) 1 else 2
InReplyToView(
inReplyTo = replyToDetails,
hideImage = hideImage,
contentValidationValue = currentValidationState,
maxLines = maxReplyContentLines,
modifier = Modifier.weight(1f),
modifier = Modifier.fillMaxWidth(),
)
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close),
tint = ElementTheme.colors.iconSecondary,
tint = if (contentHasErrors) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconSecondary,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(end = 4.dp, top = 4.dp, start = 8.dp, bottom = 16.dp)
.size(16.dp)
.clickable(