Add content scanner support to media gallery and viewer (Element Pro) (#7233)

* Add content scanner support to media gallery and viewer

* Improve UI and previews

* Remove default content validation state in media content views

* Disable click for invalid media items in the gallery events

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-07-17 15:26:13 +02:00
committed by GitHub
parent 19724aaa78
commit 2705efad15
88 changed files with 892 additions and 441 deletions
@@ -116,6 +116,7 @@ class DefaultContentValidationState(
private fun calculateOverallState(states: Map<String, ContentValidationValue>): ContentValidationValue {
return when {
states.values.isEmpty() -> ContentValidationValue.Unknown
states.values.any { it is ContentValidationValue.Invalid } -> ContentValidationValue.Invalid
states.values.any { it is ContentValidationValue.Loading } -> ContentValidationValue.Loading
states.values.any { it is ContentValidationValue.UnrecoverableError } -> states.values.first { it is ContentValidationValue.UnrecoverableError }
@@ -26,8 +26,10 @@ val LocalEventContentValidationState = staticCompositionLocalOf<EventContentVali
/**
* A noop implementation of the [EventContentValidationCache] that immediately returns a successful validation state. This will be used in FOSS.
*/
class NoopEventContentValidationCache : EventContentValidationCache {
override operator fun get(eventId: EventId): ContentValidationState = noopValue
class NoopEventContentValidationCache(
private val provided: ContentValidationState = noopValue,
) : EventContentValidationCache {
override operator fun get(eventId: EventId): ContentValidationState = provided
}
/**
@@ -39,6 +39,7 @@ dependencies {
implementation(projects.features.enterprise.api)
implementation(projects.features.viewfolder.api)
implementation(projects.features.contentscanner.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.audio.api)
@@ -48,6 +49,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.uiUtils)
@@ -44,6 +44,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
@@ -54,6 +56,7 @@ class EventItemFactory(
private val fileSizeFormatter: FileSizeFormatter,
private val fileExtensionExtractor: FileExtensionExtractor,
private val dateFormatter: DateFormatter,
private val contentValidationCache: EventContentValidationCache,
) {
fun create(
currentTimelineItem: MatrixTimelineItem.Event,
@@ -67,6 +70,8 @@ class EventItemFactory(
timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.Full,
)
val validationState = currentTimelineItem.eventId?.let { contentValidationCache[it] }
?: NoopContentValidationState()
return when (val content = event.content) {
is CallNotifyContent,
is FailedToParseMessageLikeContent,
@@ -117,6 +122,7 @@ class EventItemFactory(
mediaSource = c.source,
thumbnailSource = c.info?.thumbnailSource,
blurHash = c.info?.blurhash,
validationState = validationState,
)
}
is GalleryItemType.Video -> {
@@ -138,6 +144,7 @@ class EventItemFactory(
mediaSource = c.source,
thumbnailSource = c.info?.thumbnailSource,
blurHash = c.info?.blurhash,
validationState = validationState,
)
}
is GalleryItemType.Audio -> {
@@ -156,6 +163,7 @@ class EventItemFactory(
dateSentFull = dateSentFull,
),
mediaSource = c.source,
validationState = validationState,
)
}
is GalleryItemType.File -> {
@@ -174,6 +182,7 @@ class EventItemFactory(
dateSentFull = dateSentFull,
),
mediaSource = c.source,
validationState = validationState,
)
}
is GalleryItemType.Other -> null
@@ -200,6 +209,7 @@ class EventItemFactory(
duration = null,
),
mediaSource = type.source,
validationState = validationState,
))
is FileMessageType -> listOf(MediaItem.File(
id = currentTimelineItem.uniqueId,
@@ -221,6 +231,7 @@ class EventItemFactory(
duration = null,
),
mediaSource = type.source,
validationState = validationState,
// TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource
))
is ImageMessageType -> listOf(MediaItem.Image(
@@ -245,6 +256,7 @@ class EventItemFactory(
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
blurHash = type.info?.blurhash,
validationState = validationState,
))
is StickerMessageType -> listOf(MediaItem.Image(
id = currentTimelineItem.uniqueId,
@@ -268,6 +280,7 @@ class EventItemFactory(
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
blurHash = type.info?.blurhash,
validationState = validationState,
))
is VideoMessageType -> listOf(MediaItem.Video(
id = currentTimelineItem.uniqueId,
@@ -291,6 +304,7 @@ class EventItemFactory(
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
blurHash = type.info?.blurhash,
validationState = validationState,
))
is VoiceMessageType -> listOf(MediaItem.Voice(
id = currentTimelineItem.uniqueId,
@@ -312,6 +326,7 @@ class EventItemFactory(
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
),
mediaSource = type.source,
validationState = validationState,
))
}
}
@@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.libraries.androidutils.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@@ -43,7 +44,11 @@ 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.mediaPermissions
import io.element.android.libraries.mediaviewer.impl.model.mediaSource
import io.element.android.libraries.mediaviewer.impl.model.thumbnailSource
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@AssistedInject
@@ -55,6 +60,7 @@ class MediaGalleryPresenter(
private val mediaLoader: MatrixMediaLoader,
private val localMediaActions: LocalMediaActions,
private val snackbarDispatcher: SnackbarDispatcher,
private val contentScannerService: ContentScannerService,
) : Presenter<MediaGalleryState> {
@AssistedFactory
interface Factory {
@@ -74,6 +80,17 @@ class MediaGalleryPresenter(
val groupedMediaItems by remember {
mediaGalleryDataSource.groupedMediaItemsFlow()
.onEach { groupedItems ->
if (groupedItems is AsyncData.Success) {
val items = groupedItems.data.getItems(mode)
items.forEach { item ->
if (item is MediaItem.Event) {
val eventId = item.eventId() ?: return@forEach
coroutineScope.validateMedia(eventId, item)
}
}
}
}
}
.collectAsState(AsyncData.Uninitialized)
@@ -220,6 +237,21 @@ class MediaGalleryPresenter(
}
}
private fun CoroutineScope.validateMedia(eventId: EventId, mediaItem: MediaItem.Event) {
launch {
val thumbnailSource = mediaItem.thumbnailSource()
val mediaSource = mediaItem.mediaSource()
val contentValidationState = mediaItem.validationState
val currentState = contentValidationState.overallStateFlow.first()
if (currentState.isLoading() || currentState.isValid()) {
return@launch
}
contentScannerService.scan(eventId, listOfNotNull(thumbnailSource, mediaSource).distinct(), contentValidationState)
}
}
private fun mediaActionsError(throwable: Throwable): Int {
return if (throwable is ActivityNotFoundException) {
R.string.error_no_compatible_app_found
@@ -13,6 +13,8 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.media.WaveFormSamples
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.aMediaBottomSheetStateDetails
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
@@ -47,9 +49,20 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
aMediaItemImage(id = UniqueId("3")),
aMediaItemVideo(id = UniqueId("4")),
aMediaItemImage(id = UniqueId("5")),
aMediaItemImage(id = UniqueId("6")),
aMediaItemImage(id = UniqueId("7")),
aMediaItemImage(id = UniqueId("8")),
aMediaItemImage(
id = UniqueId("6"),
validationState = NoopContentValidationState(ContentValidationValue.Loading),
blurHash = "LKO2?U%2Tw=w]~RBVZRi};RPxuwH"
),
aMediaItemImage(id = UniqueId("7"), validationState = NoopContentValidationState(ContentValidationValue.Invalid)),
aMediaItemImage(
id = UniqueId("8"),
validationState = NoopContentValidationState(
ContentValidationValue.UnrecoverableError(
IllegalStateException("Failed to validate media item")
)
)
),
aMediaItemImage(id = UniqueId("9")),
aMediaItemLoadingIndicator(),
).toImmutableList()
@@ -65,7 +78,7 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
aGroupedMediaItems(
fileItems = listOf(
aMediaItemDateSeparator(id = UniqueId("0")),
aMediaItemFile(id = UniqueId("1")),
aMediaItemFile(id = UniqueId("1"), validationState = NoopContentValidationState(ContentValidationValue.Loading)),
aMediaItemDateSeparator(
id = UniqueId("2"),
formattedDate = "September 2004",
@@ -75,6 +88,15 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
id = UniqueId("5"),
waveform = WaveFormSamples.realisticWaveForm,
),
aMediaItemAudio(id = UniqueId("6"), validationState = NoopContentValidationState(ContentValidationValue.Invalid)),
aMediaItemAudio(
id = UniqueId("7"),
validationState = NoopContentValidationState(
ContentValidationValue.UnrecoverableError(
IllegalStateException("Failed to validate media item")
)
)
),
aMediaItemLoadingIndicator(),
).toImmutableList()
)
@@ -9,14 +9,18 @@
package io.element.android.libraries.mediaviewer.impl.gallery
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
@@ -31,6 +35,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -49,11 +55,13 @@ import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubti
import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SegmentedButton
@@ -62,6 +70,9 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet
@@ -315,6 +326,14 @@ private fun MediaGalleryFilesList(
key = { it.id() },
contentType = { it::class.java },
) { item ->
val contentValidationState = remember(item is MediaItem.Event) {
if (item is MediaItem.Event) {
item.validationState
} else {
NoopContentValidationState()
}
}
val contentValidationValue by contentValidationState.collectOverallState()
when (item) {
is MediaItem.File -> FileItemView(
modifier = Modifier.animateItem(),
@@ -323,6 +342,7 @@ private fun MediaGalleryFilesList(
onLongClick = {
eventSink(MediaGalleryEvent.OpenInfo(item))
},
contentValidationState = contentValidationValue,
)
is MediaItem.Audio -> AudioItemView(
modifier = Modifier.animateItem(),
@@ -331,6 +351,7 @@ private fun MediaGalleryFilesList(
onLongClick = {
eventSink(MediaGalleryEvent.OpenInfo(item))
},
contentValidationValue = contentValidationValue,
)
is MediaItem.Voice -> {
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(item)
@@ -341,16 +362,17 @@ private fun MediaGalleryFilesList(
onLongClick = {
eventSink(MediaGalleryEvent.OpenInfo(item))
},
contentValidationValue = contentValidationValue,
)
}
is MediaItem.Image,
is MediaItem.Video -> {
// Should not happen
}
is MediaItem.DateSeparator -> DateItemView(
modifier = Modifier.animateItem(),
item = item
)
is MediaItem.Image,
is MediaItem.Video -> {
// Should not happen
}
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
modifier = Modifier.animateItem(),
item = item,
@@ -387,11 +409,48 @@ private fun MediaGalleryImageGrid(
key = { it.id() },
contentType = { it::class.java },
) { item ->
val blurHash = when (item) {
is MediaItem.Image -> item.blurHash
is MediaItem.Video -> item.blurHash
else -> null
}
val blurHashBackgroundModifier = if (blurHash != null) {
Modifier.blurHashBackground(blurHash)
} else {
Modifier
}
val currentValidationState by when (item) {
is MediaItem.Event -> item.validationState.collectOverallState()
else -> remember { mutableStateOf(ContentValidationValue.Unknown) }
}
when (item) {
is MediaItem.DateSeparator -> DateItemView(
modifier = Modifier.animateItem(),
item = item,
)
is MediaItem.Event if !currentValidationState.isValidated() -> {
Box(modifier = blurHashBackgroundModifier.aspectRatio(1f), contentAlignment = Alignment.Center) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
}
}
is MediaItem.Event if currentValidationState.isInvalid() -> {
Box(
modifier = Modifier
.clickable { onItemClick(item) }
.background(ElementTheme.colors.bgCriticalSubtle)
.aspectRatio(1f),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = CompoundIcons.Error(),
tint = ElementTheme.colors.iconCriticalPrimary,
contentDescription = null,
)
}
}
is MediaItem.Audio -> {
// Should not happen
}
@@ -402,7 +461,7 @@ private fun MediaGalleryImageGrid(
// Should not happen
}
is MediaItem.Image -> ImageItemView(
modifier = Modifier.animateItem(),
modifier = blurHashBackgroundModifier.animateItem(),
image = item,
onClick = { onItemClick(item) },
onLongClick = {
@@ -410,13 +469,17 @@ private fun MediaGalleryImageGrid(
},
)
is MediaItem.Video -> VideoItemView(
modifier = Modifier.animateItem(),
modifier = blurHashBackgroundModifier.animateItem(),
video = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvent.OpenInfo(item))
},
)
is MediaItem.DateSeparator -> DateItemView(
modifier = Modifier.animateItem(),
item = item,
)
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
modifier = Modifier.animateItem(),
item = item,
@@ -8,117 +8,38 @@
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
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.libraries.core.extensions.withBrackets
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
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.ContentValidationValue
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun AudioItemView(
audio: MediaItem.Audio,
onClick: () -> Unit,
onLongClick: () -> Unit,
contentValidationValue: ContentValidationValue,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
GalleryFileListItem(
modifier = modifier,
contentValidationValue = contentValidationValue,
caption = audio.mediaInfo.caption,
) {
Spacer(modifier = Modifier.height(20.dp))
FilenameRow(
audio = audio,
GalleryFileListItemContent(
name = audio.mediaInfo.filename,
formattedSize = audio.mediaInfo.formattedFileSize,
icon = CompoundIcons.Audio(),
isValidating = contentValidationValue.isLoading(),
onClick = onClick,
onLongClick = onLongClick,
)
val caption = audio.mediaInfo.caption
if (caption != null) {
CaptionView(caption)
} else {
Spacer(modifier = Modifier.height(20.dp))
}
HorizontalDivider()
}
}
@Composable
private fun FilenameRow(
audio: MediaItem.Audio,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction(onLongClick)
.fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.background(
color = ElementTheme.colors.bgActionSecondaryRest,
shape = CircleShape,
)
.size(32.dp)
.padding(6.dp),
imageVector = CompoundIcons.Audio(),
contentDescription = null,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = audio.mediaInfo.filename,
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val formattedSize = audio.mediaInfo.formattedFileSize
if (formattedSize.isNotEmpty()) {
Text(
text = formattedSize.withBrackets(),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
}
@@ -127,9 +48,22 @@ private fun FilenameRow(
internal fun AudioItemViewPreview(
@PreviewParameter(MediaItemAudioProvider::class) audio: MediaItem.Audio,
) = ElementPreview {
AudioItemView(
audio = audio,
onClick = {},
onLongClick = {},
)
val states = remember {
listOf(
ContentValidationValue.Valid,
ContentValidationValue.Loading,
ContentValidationValue.Invalid,
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
)
}
Column {
for (state in states) {
AudioItemView(
contentValidationValue = state,
audio = audio,
onClick = {},
onLongClick = {},
)
}
}
}
@@ -8,117 +8,38 @@
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
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.libraries.core.extensions.withBrackets
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
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.ContentValidationValue
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun FileItemView(
file: MediaItem.File,
onClick: () -> Unit,
onLongClick: () -> Unit,
contentValidationState: ContentValidationValue,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
GalleryFileListItem(
modifier = modifier,
contentValidationValue = contentValidationState,
caption = file.mediaInfo.caption
) {
Spacer(modifier = Modifier.height(20.dp))
FilenameRow(
file = file,
GalleryFileListItemContent(
name = file.mediaInfo.filename,
formattedSize = file.mediaInfo.formattedFileSize,
icon = CompoundIcons.Attachment(),
onClick = onClick,
isValidating = contentValidationState.isLoading(),
onLongClick = onLongClick,
)
val caption = file.mediaInfo.caption
if (caption != null) {
CaptionView(caption)
} else {
Spacer(modifier = Modifier.height(20.dp))
}
HorizontalDivider()
}
}
@Composable
private fun FilenameRow(
file: MediaItem.File,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction(onLongClick)
.fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.background(
color = ElementTheme.colors.bgActionSecondaryRest,
shape = CircleShape,
)
.size(32.dp)
.padding(6.dp),
imageVector = CompoundIcons.Attachment(),
contentDescription = null,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = file.mediaInfo.filename,
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val formattedSize = file.mediaInfo.formattedFileSize
if (formattedSize.isNotEmpty()) {
Text(
text = formattedSize.withBrackets(),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
}
@@ -127,9 +48,22 @@ private fun FilenameRow(
internal fun FileItemViewPreview(
@PreviewParameter(MediaItemFileProvider::class) file: MediaItem.File,
) = ElementPreview {
FileItemView(
file = file,
onClick = {},
onLongClick = {},
)
val states = remember {
listOf(
ContentValidationValue.Valid,
ContentValidationValue.Loading,
ContentValidationValue.Invalid,
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
)
}
Column {
for (state in states) {
FileItemView(
file = file,
onClick = {},
onLongClick = {},
contentValidationState = state,
)
}
}
}
@@ -0,0 +1,152 @@
/*
* 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.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.core.extensions.withBrackets
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
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.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun GalleryFileListItem(
contentValidationValue: ContentValidationValue,
caption: String?,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Spacer(modifier = Modifier.height(20.dp))
when (contentValidationValue) {
ContentValidationValue.Invalid -> InvalidContentView(
modifier = invalidContentModifier(),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
)
is ContentValidationValue.UnrecoverableError -> NotFoundContentView(
modifier = invalidContentModifier(),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
)
else -> {
content()
}
}
if (caption != null) {
CaptionView(caption)
} else {
Spacer(modifier = Modifier.height(20.dp))
}
HorizontalDivider()
}
}
@Composable
private fun invalidContentModifier(): Modifier {
return Modifier
.border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = RoundedCornerShape(12.dp))
.clip(RoundedCornerShape(12.dp))
.background(ElementTheme.colors.bgCriticalSubtle)
}
@Composable
internal fun GalleryFileListItemContent(
name: String,
formattedSize: String,
icon: ImageVector,
isValidating: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
)
.combinedClickable(
onClick = onClick.takeIf { !isValidating } ?: {},
onLongClick = onLongClick.takeIf { !isValidating } ?: {},
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction(onLongClick)
.fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val iconModifier = Modifier
.background(
color = ElementTheme.colors.bgActionSecondaryRest,
shape = CircleShape,
)
.size(32.dp)
.padding(6.dp)
if (isValidating) {
CircularProgressIndicator(
modifier = iconModifier,
strokeWidth = 2.dp,
)
} else {
Icon(
modifier = iconModifier,
imageVector = icon,
contentDescription = null,
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = name,
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (formattedSize.isNotEmpty()) {
Text(
text = formattedSize.withBrackets(),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
}
@@ -22,7 +22,7 @@ class MediaItemAudioProvider : PreviewParameterProvider<MediaItem.Audio> {
caption = "A caption",
),
aMediaItemAudio(
caption = loremIpsum,
caption = loremIpsum.take(80),
),
)
}
@@ -22,7 +22,7 @@ class MediaItemFileProvider : PreviewParameterProvider<MediaItem.File> {
caption = "A caption",
),
aMediaItemFile(
caption = loremIpsum,
caption = loremIpsum.take(80),
),
)
}
@@ -22,7 +22,7 @@ class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> {
caption = "A caption",
),
aMediaItemVoice(
caption = loremIpsum,
caption = loremIpsum.take(80),
),
aMediaItemVoice(
waveform = emptyList(),
@@ -45,10 +45,10 @@ import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenu
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
import io.element.android.libraries.ui.strings.CommonStrings
@@ -64,26 +64,20 @@ fun VoiceItemView(
state: VoiceMessageState,
voice: MediaItem.Voice,
onLongClick: () -> Unit,
contentValidationValue: ContentValidationValue,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
GalleryFileListItem(
modifier = modifier,
contentValidationValue = contentValidationValue,
caption = voice.mediaInfo.caption,
) {
Spacer(modifier = Modifier.height(20.dp))
VoiceInfoRow(
state = state,
voice = voice,
isValidating = contentValidationValue.isLoading(),
onLongClick = onLongClick,
)
val caption = voice.mediaInfo.caption
if (caption != null) {
CaptionView(caption)
} else {
Spacer(modifier = Modifier.height(16.dp))
}
HorizontalDivider()
}
}
@@ -91,6 +85,7 @@ fun VoiceItemView(
private fun VoiceInfoRow(
state: VoiceMessageState,
voice: MediaItem.Voice,
isValidating: Boolean,
onLongClick: () -> Unit,
) {
fun playPause() {
@@ -114,12 +109,21 @@ private fun VoiceInfoRow(
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (state.buttonType) {
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
if (isValidating) {
CustomIconButton(onClick = {}, enabled = false) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp)
)
}
} else {
when (state.buttonType) {
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
}
}
Spacer(Modifier.width(8.dp))
Column(
@@ -128,7 +132,7 @@ private fun VoiceInfoRow(
) {
PlaybackSpeedButton(
speed = state.playbackSpeed,
onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) },
onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) }.takeIf { !isValidating } ?: {},
)
Text(
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
@@ -149,7 +153,7 @@ private fun VoiceInfoRow(
onSeek = {
state.eventSink(VoiceMessageEvent.Seek(it))
},
seekEnabled = true,
seekEnabled = !isValidating,
)
}
}
@@ -277,11 +281,24 @@ private fun CustomIconButton(
internal fun VoiceItemViewPreview(
@PreviewParameter(MediaItemVoiceProvider::class) voice: MediaItem.Voice,
) = ElementPreview {
VoiceItemView(
state = aVoiceMessageState(),
voice = voice,
onLongClick = {},
)
val states = remember {
listOf(
ContentValidationValue.Valid,
ContentValidationValue.Loading,
ContentValidationValue.Invalid,
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
)
}
Column {
for (state in states) {
VoiceItemView(
state = aVoiceMessageState(),
voice = voice,
onLongClick = {},
contentValidationValue = state,
)
}
}
}
@PreviewsDayNight
@@ -289,9 +306,22 @@ internal fun VoiceItemViewPreview(
internal fun VoiceItemViewPlayPreview(
@PreviewParameter(VoiceMessageStateProvider::class) state: VoiceMessageState,
) = ElementPreview {
VoiceItemView(
state = state,
voice = aMediaItemVoice(),
onLongClick = {},
)
val validationState = remember {
listOf(
ContentValidationValue.Valid,
ContentValidationValue.Loading,
ContentValidationValue.Invalid,
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
)
}
Column {
for (validationState in validationState) {
VoiceItemView(
state = state,
voice = aMediaItemVoice(),
onLongClick = {},
contentValidationValue = validationState,
)
}
}
}
@@ -254,29 +254,31 @@ private fun ExoPlayerMediaVideoView(
},
)
}
MediaPlayerControllerView(
state = mediaPlayerControllerState,
onTogglePlay = {
autoHideController++
exoPlayer.togglePlay()
},
onSeekChange = {
autoHideController++
mediaPlayerControllerState = mediaPlayerControllerState.copy(
seekingToMillis = it.toLong(),
)
exoPlayer.seekToEnsurePlaying(it.toLong())
},
onToggleMute = {
autoHideController++
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
},
audioFocus = audioFocus,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = bottomPaddingInPixels.toDp()),
)
if (localMedia != null) {
MediaPlayerControllerView(
state = mediaPlayerControllerState,
onTogglePlay = {
autoHideController++
exoPlayer.togglePlay()
},
onSeekChange = {
autoHideController++
mediaPlayerControllerState = mediaPlayerControllerState.copy(
seekingToMillis = it.toLong(),
)
exoPlayer.seekToEnsurePlaying(it.toLong())
},
onToggleMute = {
autoHideController++
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
},
audioFocus = audioFocus,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = bottomPaddingInPixels.toDp()),
)
}
}
LaunchedEffect(exoPlayer.isPlaying) {
@@ -13,6 +13,8 @@ import io.element.android.libraries.matrix.api.core.UniqueId
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.MediaRequestData
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.mediaviewer.api.MediaInfo
sealed interface MediaItem {
@@ -27,7 +29,9 @@ sealed interface MediaItem {
val timestamp: Long,
) : MediaItem
sealed interface Event : MediaItem
sealed interface Event : MediaItem {
val validationState: ContentValidationState
}
data class Image(
val id: UniqueId,
@@ -36,6 +40,7 @@ sealed interface MediaItem {
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val blurHash: String?,
override val validationState: ContentValidationState,
) : Event {
val thumbnailMediaRequestData: MediaRequestData
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
@@ -48,6 +53,7 @@ sealed interface MediaItem {
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val blurHash: String?,
override val validationState: ContentValidationState,
) : Event {
val thumbnailMediaRequestData: MediaRequestData
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
@@ -58,6 +64,7 @@ sealed interface MediaItem {
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
override val validationState: ContentValidationState,
) : Event
data class Voice(
@@ -65,6 +72,7 @@ sealed interface MediaItem {
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
override val validationState: ContentValidationState,
) : Event
data class File(
@@ -72,6 +80,7 @@ sealed interface MediaItem {
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
override val validationState: ContentValidationState,
) : Event
}
@@ -134,3 +143,8 @@ fun MediaItem.Event.blurHash(): String? {
else -> null
}
}
fun MediaItem.isMediaValid(): Boolean? = when (this) {
is MediaItem.Event -> validationState.getCurrentOverallState() == ContentValidationValue.Valid
else -> null
}
@@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
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 io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
@@ -25,6 +27,8 @@ fun aMediaItemImage(
eventId: EventId? = null,
senderId: UserId? = null,
mediaSourceUrl: String = "",
validationState: ContentValidationState = NoopContentValidationState(),
blurHash: String? = null,
): MediaItem.Image {
return MediaItem.Image(
id = id,
@@ -34,7 +38,8 @@ fun aMediaItemImage(
),
mediaSource = MediaSource(mediaSourceUrl),
thumbnailSource = null,
blurHash = null,
blurHash = blurHash,
validationState = validationState,
)
}
@@ -42,6 +47,7 @@ fun aMediaItemVideo(
id: UniqueId = UniqueId("videoId"),
mediaSource: MediaSource = MediaSource(""),
duration: String? = "1:23",
validationState: ContentValidationState = NoopContentValidationState(),
): MediaItem.Video {
return MediaItem.Video(
id = id,
@@ -52,6 +58,7 @@ fun aMediaItemVideo(
mediaSource = mediaSource,
thumbnailSource = null,
blurHash = null,
validationState = validationState,
)
}
@@ -60,6 +67,7 @@ fun aMediaItemFile(
eventId: EventId? = null,
filename: String = "filename",
caption: String? = null,
validationState: ContentValidationState = NoopContentValidationState(),
): MediaItem.File {
return MediaItem.File(
id = id,
@@ -69,6 +77,7 @@ fun aMediaItemFile(
caption = caption,
),
mediaSource = MediaSource(""),
validationState = validationState,
)
}
@@ -77,6 +86,7 @@ fun aMediaItemAudio(
eventId: EventId? = null,
filename: String = "filename",
caption: String? = null,
validationState: ContentValidationState = NoopContentValidationState(),
): MediaItem.Audio {
return MediaItem.Audio(
id = id,
@@ -86,6 +96,7 @@ fun aMediaItemAudio(
caption = caption,
),
mediaSource = MediaSource(""),
validationState = validationState,
)
}
@@ -95,6 +106,7 @@ fun aMediaItemVoice(
caption: String? = null,
duration: String? = "1:23",
waveform: List<Float> = WaveFormSamples.realisticWaveForm,
validationState: ContentValidationState = NoopContentValidationState(),
): MediaItem.Voice {
return MediaItem.Voice(
id = id,
@@ -106,6 +118,7 @@ fun aMediaItemVoice(
waveForm = waveform,
),
mediaSource = MediaSource(""),
validationState = validationState,
)
}
@@ -11,6 +11,7 @@ import io.element.android.libraries.architecture.AsyncData
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.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.mediaviewer.api.GalleryInfo
import io.element.android.libraries.mediaviewer.api.GalleryItemData
import io.element.android.libraries.mediaviewer.api.MediaInfo
@@ -37,6 +38,7 @@ class GalleryMediaGalleryDataSource(
eventId: EventId?,
galleryItems: List<GalleryItemData>,
galleryInfo: GalleryInfo,
contentValidationState: ContentValidationState,
): GalleryMediaGalleryDataSource {
val mixedItems = mutableListOf<MediaItem.Event>()
galleryItems.forEachIndexed { index, galleryItem ->
@@ -64,18 +66,21 @@ class GalleryMediaGalleryDataSource(
mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource,
blurHash = galleryItem.blurHash,
validationState = contentValidationState,
)
GalleryItemData.Type.Audio -> MediaItem.Audio(
id = id,
eventId = eventId,
mediaInfo = itemMediaInfo,
mediaSource = galleryItem.mediaSource,
validationState = contentValidationState,
)
GalleryItemData.Type.File -> MediaItem.File(
id = id,
eventId = eventId,
mediaInfo = itemMediaInfo,
mediaSource = galleryItem.mediaSource,
validationState = contentValidationState,
)
GalleryItemData.Type.Image -> MediaItem.Image(
id = id,
@@ -84,6 +89,7 @@ class GalleryMediaGalleryDataSource(
mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource,
blurHash = galleryItem.blurHash,
validationState = contentValidationState,
)
}
mixedItems.add(mediaItem)
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.rememberUpdatedState
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.extensions.mapCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId
@@ -24,6 +25,9 @@ import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
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 io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint.MediaViewerMode
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
@@ -57,6 +61,8 @@ class MediaViewerDataSource(
private val localMediaFactory: LocalMediaFactory,
private val systemClock: SystemClock,
private val pagerKeysHandler: PagerKeysHandler,
private val contentScannerService: ContentScannerService,
private val contentValidationCache: EventContentValidationCache,
) {
// List of media files that are currently being loaded
private val mediaFiles: ConcurrentHashMap<MediaSource, MediaFile> = ConcurrentHashMap()
@@ -71,6 +77,9 @@ class MediaViewerDataSource(
private val localMediaStates: MutableMap<String, MutableState<AsyncData<LocalMedia>>> =
mutableMapOf()
private val mediaValidationState: MutableMap<String, ContentValidationState> =
mutableMapOf()
fun setup(coroutineScope: CoroutineScope) {
galleryDataSource.start(coroutineScope)
}
@@ -162,6 +171,9 @@ class MediaViewerDataSource(
val localMedia = localMediaStates.getOrPut(sourceUrl) {
mutableStateOf(AsyncData.Uninitialized)
}
val validationState = mediaValidationState.getOrPut(sourceUrl) {
mediaItem.eventId()?.let { contentValidationCache[it] } ?: DefaultContentValidationState()
}
add(
MediaViewerPageData.MediaViewerData(
eventId = mediaItem.eventId(),
@@ -170,6 +182,7 @@ class MediaViewerDataSource(
thumbnailSource = mediaItem.thumbnailSource(),
downloadedMedia = localMedia,
pagerKey = pagerKeysHandler.getKey(mediaItem),
validationState = validationState,
)
)
}
@@ -229,6 +242,20 @@ class MediaViewerDataSource(
}
}
fun validateMedia(
eventId: EventId,
mediaSource: MediaSource,
) {
val validationState = mediaValidationState[mediaSource.safeUrl] ?: return
val currentState = validationState.getCurrentMediaState(mediaSource.safeUrl)
if (currentState.isLoading() || currentState.isValid()) {
return
}
contentScannerService.scan(eventId, listOf(mediaSource), validationState)
}
fun cancelLoadingMedia(data: MediaViewerPageData.MediaViewerData) {
if (localMediaStates[data.mediaSource.safeUrl]?.value?.isLoading() == true) {
Timber.d("cancelLoadingMedia for ${data.eventId}")
@@ -9,6 +9,7 @@
package io.element.android.libraries.mediaviewer.impl.viewer
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.timeline.Timeline
sealed interface MediaViewerEvent {
@@ -30,4 +31,5 @@ sealed interface MediaViewerEvent {
data class OnNavigateTo(val index: Int) : MediaViewerEvent
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvent
data class CancelLoadingMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvent
data class ValidateMedia(val eventId: EventId, val mediaSource: MediaSource) : MediaViewerEvent
}
@@ -22,6 +22,7 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.compound.theme.ForcedDarkElementTheme
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.libraries.architecture.callback
@@ -33,6 +34,8 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.datasource.FocusedTimelineMediaGalleryDataSourceFactory
@@ -57,6 +60,8 @@ class MediaViewerNode(
private val audioFocus: AudioFocus,
private val sessionId: SessionId,
private val enterpriseService: EnterpriseService,
private val contentScannerService: ContentScannerService,
private val contentValidationCache: EventContentValidationCache,
) : Node(buildContext, plugins = plugins),
MediaViewerNavigator {
private val callback: MediaViewerEntryPoint.Callback = callback()
@@ -76,12 +81,13 @@ class MediaViewerNode(
private val mediaGallerySource = when (inputs) {
is MediaViewerEntryPoint.Params.Avatar ->
SingleMediaGalleryDataSource.createFrom(inputs)
SingleMediaGalleryDataSource.createFrom(inputs, contentValidationCache)
is MediaViewerEntryPoint.Params.EventGallery ->
GalleryMediaGalleryDataSource.createFrom(
eventId = inputs.eventId,
galleryItems = inputs.galleryItems,
galleryInfo = inputs.galleryInfo,
contentValidationState = inputs.eventId?.let { contentValidationCache[it] } ?: NoopContentValidationState(),
)
is MediaViewerEntryPoint.Params.RoomMedia -> {
val eventId = inputs.eventId
@@ -103,7 +109,7 @@ class MediaViewerNode(
} else {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
mediaItem = inputs.toMediaItem(contentValidationCache[eventId]),
onlyPinnedEvents = false,
)
}
@@ -111,7 +117,7 @@ class MediaViewerNode(
Timeline.Mode.PinnedEvents -> {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
mediaItem = inputs.toMediaItem(contentValidationCache[eventId]),
onlyPinnedEvents = true,
)
}
@@ -142,6 +148,8 @@ class MediaViewerNode(
localMediaFactory = localMediaFactory,
systemClock = systemClock,
pagerKeysHandler = pagerKeysHandler,
contentScannerService = contentScannerService,
contentValidationCache = contentValidationCache,
)
)
@@ -42,7 +42,9 @@ import io.element.android.libraries.mediaviewer.impl.model.MediaPermissions
import io.element.android.libraries.mediaviewer.impl.model.mediaPermissions
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import io.element.android.libraries.androidutils.R as UtilsR
@@ -70,6 +72,7 @@ class MediaViewerPresenter(
private val eventId = inputs.eventId()
private val mediaSource = inputs.mediaSource()
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
override fun present(): MediaViewerState {
val coroutineScope = rememberCoroutineScope()
@@ -93,7 +96,7 @@ class MediaViewerPresenter(
// Restore index based on the eventId after the initial items have been loaded
currentIndex.intValue = dataSource.findEventIndex(eventId, mediaSource) ?: 0
}
value = new
value = new.toImmutableList()
}
}
@@ -191,6 +194,7 @@ class MediaViewerPresenter(
is MediaViewerEvent.LoadMore -> coroutineScope.launch {
dataSource.loadMore(event.direction)
}
is MediaViewerEvent.ValidateMedia -> dataSource.validateMedia(event.eventId, event.mediaSource)
}
}
@@ -15,6 +15,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
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.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
@@ -51,6 +52,7 @@ sealed interface MediaViewerPageData {
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val downloadedMedia: State<AsyncData<LocalMedia>>,
val validationState: ContentValidationState,
override val pagerKey: Long,
) : MediaViewerPageData
}
@@ -17,6 +17,9 @@ import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
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.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
import io.element.android.libraries.mediaviewer.api.aTxtMediaInfo
@@ -275,6 +278,34 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
)
)
},
anImageMediaInfo(
senderName = "Frank",
dateSent = "26 NOV, 2024",
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Uninitialized,
validationState = NoopContentValidationState(ContentValidationValue.Loading),
mediaInfo = it,
)
)
)
},
anImageMediaInfo(
senderName = "Frank",
dateSent = "26 NOV, 2024",
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Uninitialized,
validationState = NoopContentValidationState(ContentValidationValue.Invalid),
mediaInfo = it,
)
)
)
},
)
}
@@ -293,6 +324,7 @@ fun aMediaViewerPageData(
downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
mediaInfo: MediaInfo = anImageMediaInfo(),
mediaSource: MediaSource = MediaSource(""),
validationState: ContentValidationState = NoopContentValidationState(),
): MediaViewerPageData.MediaViewerData = MediaViewerPageData.MediaViewerData(
eventId = null,
mediaInfo = mediaInfo,
@@ -300,6 +332,7 @@ fun aMediaViewerPageData(
thumbnailSource = null,
downloadedMedia = mutableStateOf(downloadedMedia),
pagerKey = 0L,
validationState = validationState,
)
fun aMediaViewerState(
@@ -12,11 +12,14 @@ package io.element.android.libraries.mediaviewer.impl.viewer
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -73,7 +76,10 @@ import io.element.android.libraries.androidutils.text.safeLinkify
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
@@ -176,7 +182,7 @@ fun MediaViewerView(
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
) { padding ->
val pagerState = rememberPagerState(state.currentIndex, 0f) {
state.listData.size
}
@@ -211,17 +217,33 @@ fun MediaViewerView(
)
}
is MediaViewerPageData.MediaViewerData -> {
val eventId = dataForPage.eventId
val isMediaValid = dataForPage.validationState.getCurrentMediaState(dataForPage.mediaSource.safeUrl).isValid()
// Check if the media to display is valid or dangerous
LaunchedEffect(eventId) {
if (eventId != null) {
state.eventSink(MediaViewerEvent.ValidateMedia(eventId, dataForPage.mediaSource))
}
}
var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) }
Box(
modifier = Modifier
.onVisibilityChanged(minDurationMs = 200L) { isVisible ->
val loadMediaOnVisibilityChangedModifier = remember(isMediaValid) {
if (isMediaValid == true) {
Modifier.onVisibilityChanged(minDurationMs = 200L) { isVisible ->
if (isVisible) {
state.eventSink(MediaViewerEvent.LoadMedia(dataForPage))
} else {
state.eventSink(MediaViewerEvent.CancelLoadingMedia(dataForPage))
}
}
.fillMaxSize()
} else {
Modifier
}
}
Box(
modifier = loadMediaOnVisibilityChangedModifier.fillMaxSize()
) {
val isDisplayed = remember(pagerState.settledPage) {
// This 'item provider' lambda will be called when the data source changes with an outdated `settlePage` value
@@ -232,6 +254,7 @@ fun MediaViewerView(
MediaViewerPage(
isDisplayed = isDisplayed,
showOverlay = showOverlay,
containerPadding = padding,
bottomPaddingInPixels = (bottomPaddingInPixels - navigationBarPadding).coerceAtLeast(0),
data = dataForPage,
textFileViewer = textFileViewer,
@@ -333,6 +356,7 @@ private fun MediaViewerPage(
isDisplayed: Boolean,
showOverlay: Boolean,
bottomPaddingInPixels: Int,
containerPadding: PaddingValues,
data: MediaViewerPageData.MediaViewerData,
textFileViewer: TextFileViewer,
isUserSelected: Boolean,
@@ -365,6 +389,25 @@ private fun MediaViewerPage(
.fillMaxSize()
.navigationBarsPadding()
) {
AnimatedVisibility(
visible = data.validationState.getCurrentMediaState(data.mediaSource.safeUrl).isLoading(),
modifier = Modifier.padding(top = containerPadding.calculateTopPadding()).align(Alignment.TopCenter),
enter = fadeIn(spring(stiffness = 500F)),
exit = fadeOut(spring(stiffness = 500F)),
) {
AsyncIndicator.Loading(
text = stringResource(CommonStrings.content_scanner_scanning),
)
}
if (showProgress) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
)
}
Box(contentAlignment = Alignment.Center) {
val zoomableState = rememberZoomableState(
zoomSpec = ZoomSpec(maxZoomFactor = 4f, overzoomEffect = OverzoomEffect.NoLimits)
@@ -380,29 +423,44 @@ private fun MediaViewerPage(
}
}
LocalMediaView(
modifier = Modifier.fillMaxSize(),
isDisplayed = isDisplayed,
bottomPaddingInPixels = bottomPaddingInPixels,
localMediaViewState = localMediaViewState,
localMedia = downloadedMedia.dataOrNull(),
mediaInfo = data.mediaInfo,
textFileViewer = textFileViewer,
onClick = {
if (playableState is PlayableState.NotPlayable) {
currentOnShowOverlayChange(!currentShowOverlay)
}
},
onOpenWith = onOpenWith,
isUserSelected = isUserSelected,
audioFocus = audioFocus,
forPreview = false,
)
if (showThumbnail) {
ThumbnailView(
val isMediaValid = data.validationState.getCurrentMediaState(data.mediaSource.safeUrl).isValid()
if (isMediaValid == false) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
) {
IconTitleSubtitleMolecule(
iconStyle = BigIcon.Style.AlertSolid,
title = "The file is not safe",
subTitle = "Preview and download have been disabled.",
)
}
} else {
LocalMediaView(
modifier = Modifier.fillMaxSize(),
isDisplayed = isDisplayed,
bottomPaddingInPixels = bottomPaddingInPixels,
localMediaViewState = localMediaViewState,
localMedia = downloadedMedia.dataOrNull(),
mediaInfo = data.mediaInfo,
thumbnailSource = data.thumbnailSource,
textFileViewer = textFileViewer,
onClick = {
if (playableState is PlayableState.NotPlayable) {
currentOnShowOverlayChange(!currentShowOverlay)
}
},
onOpenWith = onOpenWith,
isUserSelected = isUserSelected,
audioFocus = audioFocus,
forPreview = false,
)
if (showThumbnail) {
ThumbnailView(
mediaInfo = data.mediaInfo,
thumbnailSource = data.thumbnailSource,
)
}
}
if (showError) {
ErrorView(
@@ -412,13 +470,6 @@ private fun MediaViewerPage(
)
}
}
if (showProgress) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
)
}
}
}
}
@@ -16,6 +16,9 @@ import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
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.timeline.Timeline
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 io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
@@ -27,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
class SingleMediaGalleryDataSource(
private val data: GroupedMediaItems,
private val contentValidationCache: EventContentValidationCache,
) : MediaGalleryDataSource {
override fun start(coroutineScope: CoroutineScope) = Unit
override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data))
@@ -38,7 +42,10 @@ class SingleMediaGalleryDataSource(
override suspend fun deleteItem(eventId: EventId) = Unit
companion object {
fun createFrom(params: MediaViewerEntryPoint.Params.Avatar) = SingleMediaGalleryDataSource(
fun createFrom(
params: MediaViewerEntryPoint.Params.Avatar,
contentValidationCache: EventContentValidationCache,
) = SingleMediaGalleryDataSource(
data = GroupedMediaItems(
// Always use imageAndVideoItems, in Single mode, this is the data that will be used
imageAndVideoItems = persistentListOf(
@@ -64,15 +71,19 @@ class SingleMediaGalleryDataSource(
mediaSource = params.mediaSource,
thumbnailSource = params.thumbnailSource,
blurHash = params.blurHash,
validationState = DefaultContentValidationState(),
)
),
fileItems = persistentListOf(),
)
),
contentValidationCache = contentValidationCache,
)
}
}
fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem(
contentValidationState: ContentValidationState,
) = when {
mediaInfo.mimeType.isMimeTypeImage() -> {
MediaItem.Image(
id = UniqueId("dummy"),
@@ -80,7 +91,8 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
mediaInfo = mediaInfo,
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
blurHash = blurHash,
blurHash = null,
validationState = contentValidationState,
)
}
mediaInfo.mimeType.isMimeTypeVideo() -> {
@@ -90,7 +102,8 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
mediaInfo = mediaInfo,
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
blurHash = blurHash,
blurHash = null,
validationState = contentValidationState,
)
}
mediaInfo.mimeType.isMimeTypeAudio() -> {
@@ -100,6 +113,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
eventId = eventId,
mediaInfo = mediaInfo,
mediaSource = mediaSource,
validationState = contentValidationState,
)
} else {
MediaItem.Voice(
@@ -107,6 +121,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
eventId = eventId,
mediaInfo = mediaInfo,
mediaSource = mediaSource,
validationState = contentValidationState,
)
}
}
@@ -116,6 +131,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
eventId = eventId,
mediaInfo = mediaInfo,
mediaSource = mediaSource,
validationState = contentValidationState,
)
}
}
@@ -12,11 +12,13 @@ import android.net.Uri
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
import io.element.android.libraries.mediaplayer.test.FakeAudioFocus
import io.element.android.libraries.mediaviewer.api.AvatarInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
@@ -71,6 +73,8 @@ class DefaultMediaViewerEntryPointTest {
audioFocus = FakeAudioFocus(),
sessionId = A_SESSION_ID,
enterpriseService = FakeEnterpriseService(),
contentValidationCache = NoopEventContentValidationCache(),
contentScannerService = ContentScannerService { _, _, _ -> },
)
}
val callback = object : MediaViewerEntryPoint.Callback {
@@ -118,6 +122,8 @@ class DefaultMediaViewerEntryPointTest {
audioFocus = FakeAudioFocus(),
sessionId = A_SESSION_ID,
enterpriseService = FakeEnterpriseService(),
contentValidationCache = NoopEventContentValidationCache(),
contentScannerService = ContentScannerService { _, _, _ -> },
)
}
val callback = object : MediaViewerEntryPoint.Callback {
@@ -52,6 +52,8 @@ import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageCo
import io.element.android.libraries.matrix.test.timeline.aStickerContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
@@ -174,6 +176,7 @@ class EventItemFactoryTest {
duration = null,
),
mediaSource = MediaSource(""),
validationState = noopValidationState,
)
)
}
@@ -227,6 +230,7 @@ class EventItemFactoryTest {
mediaSource = MediaSource(""),
thumbnailSource = null,
blurHash = null,
validationState = noopValidationState,
)
)
}
@@ -274,6 +278,7 @@ class EventItemFactoryTest {
duration = null,
),
mediaSource = MediaSource(""),
validationState = noopValidationState,
)
)
}
@@ -328,6 +333,7 @@ class EventItemFactoryTest {
mediaSource = MediaSource(""),
thumbnailSource = null,
blurHash = null,
validationState = noopValidationState,
)
)
}
@@ -379,6 +385,7 @@ class EventItemFactoryTest {
duration = "7:36",
),
mediaSource = MediaSource(""),
validationState = noopValidationState,
)
)
}
@@ -440,6 +447,7 @@ class EventItemFactoryTest {
mediaSource = MediaSource("image_url"),
thumbnailSource = MediaSource("thumbnail_url"),
blurHash = null,
validationState = noopValidationState,
)
)
}
@@ -502,6 +510,7 @@ class EventItemFactoryTest {
mediaSource = MediaSource("video_url"),
thumbnailSource = MediaSource("thumbnail_url"),
blurHash = null,
validationState = noopValidationState,
)
)
}
@@ -557,6 +566,7 @@ class EventItemFactoryTest {
duration = null,
),
mediaSource = MediaSource("audio_url"),
validationState = noopValidationState,
)
)
}
@@ -613,6 +623,7 @@ class EventItemFactoryTest {
duration = null,
),
mediaSource = MediaSource("file_url"),
validationState = noopValidationState,
)
)
}
@@ -749,13 +760,16 @@ class EventItemFactoryTest {
mediaSource = MediaSource(""),
thumbnailSource = null,
blurHash = null,
validationState = noopValidationState,
)
)
}
}
private val noopValidationState = NoopContentValidationState()
private fun createEventItemFactory() = EventItemFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
dateFormatter = FakeDateFormatter(),
contentValidationCache = NoopEventContentValidationCache(noopValidationState),
)
@@ -34,6 +34,8 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
@@ -250,6 +252,7 @@ class TimelineMediaGalleryDataSourceTest {
mediaSource = MediaSource("url"),
thumbnailSource = MediaSource("url_thumbnail"),
blurHash = A_BLUR_HASH,
validationState = noopValidationState,
)
),
fileItems = persistentListOf()
@@ -260,6 +263,8 @@ class TimelineMediaGalleryDataSourceTest {
}
}
private val noopValidationState = NoopContentValidationState()
internal fun TestScope.createTimelineMediaGalleryDataSource(
room: JoinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline(),
@@ -282,5 +287,6 @@ fun TestScope.createTimelineMediaItemsFactory() = TimelineMediaItemsFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
dateFormatter = FakeDateFormatter(),
),
contentValidationCache = NoopEventContentValidationCache(noopValidationState),
)
)
@@ -534,6 +534,7 @@ class MediaGalleryPresenterTest {
mediaLoader = matrixMediaLoader,
localMediaActions = localMediaActions,
snackbarDispatcher = snackbarDispatcher,
contentScannerService = { _, _, _ -> },
)
}
}
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.mediaviewer.api.GalleryInfo
import io.element.android.libraries.mediaviewer.api.GalleryItemData
import io.element.android.libraries.mediaviewer.api.MediaInfo
@@ -95,6 +96,7 @@ class GalleryMediaGalleryDataSourceTest {
)
),
galleryInfo = aGalleryInfo(),
contentValidationState = noopValidationState,
)
val data = (result.getLastData() as AsyncData.Success).data
assertThat(data.fileItems).isEmpty()
@@ -106,6 +108,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("image_url"),
thumbnailSource = MediaSource("thumbnail_url"),
blurHash = null,
validationState = noopValidationState,
)
)
}
@@ -125,6 +128,7 @@ class GalleryMediaGalleryDataSourceTest {
)
),
galleryInfo = aGalleryInfo(),
contentValidationState = noopValidationState,
)
val data = (result.getLastData() as AsyncData.Success).data
assertThat(data.fileItems).isEmpty()
@@ -136,6 +140,7 @@ class GalleryMediaGalleryDataSourceTest {
mediaSource = MediaSource("video_url"),
thumbnailSource = MediaSource("thumbnail_url"),
blurHash = null,
validationState = noopValidationState,
)
)
}
@@ -155,6 +160,7 @@ class GalleryMediaGalleryDataSourceTest {
)
),
galleryInfo = aGalleryInfo(),
contentValidationState = noopValidationState,
)
val data = (result.getLastData() as AsyncData.Success).data
assertThat(data.fileItems).isEmpty()
@@ -164,6 +170,7 @@ class GalleryMediaGalleryDataSourceTest {
eventId = AN_EVENT_ID,
mediaInfo = expectedMediaInfo("audio.mp3", MimeTypes.Mp3),
mediaSource = MediaSource("audio_url"),
validationState = noopValidationState,
)
)
}
@@ -183,6 +190,7 @@ class GalleryMediaGalleryDataSourceTest {
)
),
galleryInfo = aGalleryInfo(),
contentValidationState = noopValidationState,
)
val data = (result.getLastData() as AsyncData.Success).data
assertThat(data.fileItems).isEmpty()
@@ -192,6 +200,7 @@ class GalleryMediaGalleryDataSourceTest {
eventId = AN_EVENT_ID,
mediaInfo = expectedMediaInfo("document.pdf", MimeTypes.Pdf),
mediaSource = MediaSource("file_url"),
validationState = noopValidationState,
)
)
}
@@ -211,6 +220,7 @@ class GalleryMediaGalleryDataSourceTest {
)
),
galleryInfo = aGalleryInfo(),
contentValidationState = noopValidationState,
)
val data = (result.getLastData() as AsyncData.Success).data
val item = data.imageAndVideoItems.single() as MediaItem.Image
@@ -249,6 +259,7 @@ class GalleryMediaGalleryDataSourceTest {
),
),
galleryInfo = aGalleryInfo(),
contentValidationState = noopValidationState,
)
val data = (result.getLastData() as AsyncData.Success).data
assertThat(data.fileItems).isEmpty()
@@ -258,6 +269,8 @@ class GalleryMediaGalleryDataSourceTest {
assertThat((data.imageAndVideoItems[2] as MediaItem.Video).id).isEqualTo(UniqueId("${AN_EVENT_ID.value}_2"))
}
private val noopValidationState = NoopContentValidationState()
private fun aGalleryInfo() = GalleryInfo(
caption = "A caption",
formattedCaption = null,
@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint.MediaViewerMode
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource
@@ -290,5 +291,7 @@ class MediaViewerDataSourceTest {
localMediaFactory = localMediaFactory,
systemClock = FakeSystemClock(),
pagerKeysHandler = PagerKeysHandler(),
contentScannerService = { _, _, _ -> },
contentValidationCache = NoopEventContentValidationCache(),
)
}
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
import io.element.android.libraries.mediaviewer.api.AvatarInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
@@ -969,6 +970,8 @@ internal fun TestScope.createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
systemClock = FakeSystemClock(),
pagerKeysHandler = PagerKeysHandler(),
contentScannerService = { _, _, _ -> },
contentValidationCache = NoopEventContentValidationCache(),
),
room = room,
localMediaActions = localMediaActions,
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
@@ -36,22 +37,24 @@ class SingleMediaGalleryDataSourceTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val contentValidationCache = NoopEventContentValidationCache()
@Test
fun `function start is no op`() = runTest {
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems(), contentValidationCache)
sut.start(backgroundScope)
}
@Test
fun `function loadMore is no op`() = runTest {
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems(), contentValidationCache)
sut.loadMore(Timeline.PaginationDirection.BACKWARDS)
sut.loadMore(Timeline.PaginationDirection.FORWARDS)
}
@Test
fun `function deleteItem is no op`() = runTest {
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems(), contentValidationCache)
sut.deleteItem(AN_EVENT_ID)
}
@@ -61,7 +64,7 @@ class SingleMediaGalleryDataSourceTest {
imageAndVideoItems = listOf(aMediaItemImage()),
fileItems = listOf(aMediaItemFile()),
)
val sut = SingleMediaGalleryDataSource(data)
val sut = SingleMediaGalleryDataSource(data, contentValidationCache)
assertThat(sut.getLastData()).isEqualTo(AsyncData.Success(data))
}
@@ -71,7 +74,7 @@ class SingleMediaGalleryDataSourceTest {
imageAndVideoItems = listOf(aMediaItemImage()),
fileItems = listOf(aMediaItemFile()),
)
val sut = SingleMediaGalleryDataSource(data)
val sut = SingleMediaGalleryDataSource(data, contentValidationCache)
sut.groupedMediaItemsFlow().test {
assertThat(awaitItem()).isEqualTo(AsyncData.Success(data))
awaitComplete()
@@ -90,6 +93,7 @@ class SingleMediaGalleryDataSourceTest {
mediaSource = params.mediaSource,
thumbnailSource = params.thumbnailSource,
blurHash = null,
validationState = contentValidationCache[AN_EVENT_ID],
)
}
)
@@ -107,6 +111,7 @@ class SingleMediaGalleryDataSourceTest {
mediaSource = params.mediaSource,
thumbnailSource = params.thumbnailSource,
blurHash = null,
validationState = contentValidationCache[AN_EVENT_ID],
)
}
)
@@ -122,6 +127,7 @@ class SingleMediaGalleryDataSourceTest {
eventId = params.eventId,
mediaInfo = params.mediaInfo,
mediaSource = params.mediaSource,
validationState = contentValidationCache[AN_EVENT_ID],
)
}
)
@@ -140,6 +146,7 @@ class SingleMediaGalleryDataSourceTest {
eventId = params.eventId,
mediaInfo = params.mediaInfo,
mediaSource = params.mediaSource,
validationState = contentValidationCache[AN_EVENT_ID],
)
}
)
@@ -155,6 +162,7 @@ class SingleMediaGalleryDataSourceTest {
eventId = params.eventId,
mediaInfo = params.mediaInfo,
mediaSource = params.mediaSource,
validationState = contentValidationCache[AN_EVENT_ID],
)
}
)
@@ -165,7 +173,7 @@ class SingleMediaGalleryDataSourceTest {
expectedResult: (MediaViewerEntryPoint.Params.RoomMedia) -> MediaItem,
) {
val params = aMediaViewerEntryPointParams(mediaInfo)
val result = params.toMediaItem()
val result = params.toMediaItem(contentValidationCache[AN_EVENT_ID],)
assertThat(result).isEqualTo(expectedResult(params))
}