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
@@ -26,7 +26,7 @@ class DefaultContentScannerService(
private val coroutineScope: CoroutineScope,
coroutineDispatchers: CoroutineDispatchers,
) : ContentScannerService {
private val context = coroutineDispatchers.io.limitedParallelism(4)
private val dispatcher = coroutineDispatchers.io.limitedParallelism(4)
override fun scan(eventId: EventId, mediaSources: List<MediaSource>, contentValidationState: ContentValidationState) {
for (mediaSource in mediaSources) {
@@ -35,7 +35,7 @@ class DefaultContentScannerService(
if (currentState != ContentValidationValue.Unknown) continue
contentValidationState.update(url, ContentValidationValue.Loading)
coroutineScope.launch(context) {
coroutineScope.launch(dispatcher) {
contentScanner.scan(mediaSource)
.onSuccess { isValid ->
val contentValidationValue = if (isValid) ContentValidationValue.Valid else ContentValidationValue.Invalid
@@ -32,7 +32,7 @@ import java.io.FileNotFoundException
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultContentScannerServiceTest {
@Test
fun `scan with no media sources returns valid`() = runTest {
fun `scan with no media sources returns unknown`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(true) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
@@ -45,7 +45,7 @@ class DefaultContentScannerServiceTest {
)
scanLambda.assertions().isNeverCalled()
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Valid)
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Unknown)
}
@Test
@@ -22,8 +22,8 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentVal
fun TimelineItemAudioView(
content: TimelineItemAudioContent,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
contentValidationValue: ContentValidationValue,
modifier: Modifier = Modifier,
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
) {
TimelineItemAttachmentView(
icon = CompoundIcons.Audio(),
@@ -43,5 +43,6 @@ internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioCon
TimelineItemAudioView(
content,
onContentLayoutChange = {},
contentValidationValue = ContentValidationValue.Valid,
)
}
@@ -27,8 +27,8 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun TimelineItemFileView(
content: TimelineItemFileContent,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
contentValidationValue: ContentValidationValue,
modifier: Modifier = Modifier,
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
) {
TimelineItemAttachmentView(
icon = CompoundIcons.Attachment(),
@@ -48,6 +48,7 @@ internal fun TimelineItemFileViewPreview(@PreviewParameter(TimelineItemFileConte
TimelineItemFileView(
content,
onContentLayoutChange = {},
contentValidationValue = ContentValidationValue.Valid,
)
}
}
@@ -288,9 +288,15 @@ private fun GalleryItemCell(
Box(
modifier = modifier
.blurHashBackground(item.blurhash, alpha = 0.9f)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
.then(
if (itemContentValidationState.isValid()) {
Modifier.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
} else {
Modifier
}
),
contentAlignment = Alignment.Center,
) {
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -43,7 +44,9 @@ 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.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
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.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
@@ -55,8 +58,8 @@ fun TimelineItemImageView(
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit,
contentValidationState: ContentValidationState,
modifier: Modifier = Modifier,
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
) {
val a11yLabel = stringResource(CommonStrings.common_image)
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
@@ -129,52 +132,56 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
contentValidationState = NoopContentValidationState(),
)
}
@PreviewsDayNight
@Composable
internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
TimelineItemImageView(
content = aTimelineItemImageContent(),
hideMediaContent = true,
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
)
TimelineItemImageView(
content = aTimelineItemImageContent(),
hideMediaContent = true,
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
contentValidationState = NoopContentValidationState(),
)
}
@PreviewsDayNight
@Composable
internal fun ATimelineItemEventRowPreview() = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
CompositionLocalProvider(LocalEventContentValidationState provides NoopEventContentValidationCache()) {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "A long caption that may wrap into several lines",
width = 40,
height = 20,
aspectRatio = 40f / 20f,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
isMine = false,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "A long caption that may wrap into several lines",
width = 40,
height = 20,
aspectRatio = 40f / 20f,
caption = "Narrow image with null aspectRatio",
width = 80,
height = 150,
aspectRatio = null,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = false,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "Narrow image with null aspectRatio",
width = 80,
height = 150,
aspectRatio = null,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
}
@@ -39,7 +39,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
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.DefaultContentValidationState
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.ui.strings.CommonStrings
@@ -52,8 +52,8 @@ fun TimelineItemStickerView(
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onShowClick: () -> Unit,
contentValidationState: ContentValidationState,
modifier: Modifier = Modifier,
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
) {
val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image)
@@ -130,5 +130,6 @@ internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemSticke
onContentClick = {},
onLongClick = {},
onShowClick = {},
contentValidationState = NoopContentValidationState(),
)
}
@@ -51,7 +51,7 @@ import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
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.DefaultContentValidationState
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.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
@@ -63,8 +63,8 @@ fun TimelineItemVideoView(
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit,
contentValidationState: ContentValidationState,
modifier: Modifier = Modifier,
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
) {
val isTalkbackActive = isTalkbackActive()
val a11yLabel = stringResource(CommonStrings.common_video)
@@ -157,6 +157,7 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
contentValidationState = NoopContentValidationState(),
)
}
@@ -169,5 +170,6 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
contentValidationState = NoopContentValidationState(),
)
}
@@ -64,8 +64,8 @@ fun TimelineItemVoiceView(
state: VoiceMessageState,
content: TimelineItemVoiceContent,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
contentValidationValue: ContentValidationValue,
modifier: Modifier = Modifier,
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
) {
fun playPause() {
state.eventSink(VoiceMessageEvent.PlayPause)
@@ -289,6 +289,7 @@ internal fun TimelineItemVoiceViewPreview(
state = timelineItemVoiceViewParameters.state,
content = timelineItemVoiceViewParameters.content,
onContentLayoutChange = {},
contentValidationValue = ContentValidationValue.Valid,
)
}
@@ -302,6 +303,7 @@ internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview {
state = it.state,
content = it.content,
onContentLayoutChange = {},
contentValidationValue = ContentValidationValue.Valid,
)
}
}
@@ -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))
}
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:de447e1f21d51bd3e21a0fe4de6817c375a408beb8225a6ad5deb85025801e4f
size 8293
oid sha256:fbcb3345c0486059b8a446e0a9b904345b899e398d07f3363449e577d2c0e9fb
size 30369
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f6c7e6ecb02d33e3849ebd2624dfd1e994d77b9f7f43e7ffe9afcf33e8436d44
size 13041
oid sha256:d149cbbffd2698eba03f4d89de3af98fb1ad7bc37849598b95dd3f02e66965a8
size 43742
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:75f5d30bc99d5b09a69b77d544aaafaf665b1c377405f7ccb04092ac0da0a114
size 35334
oid sha256:f1abb765bc08c172adefb0ebd4c78d65b5b52791f54c15d72257b7b5a7192f0e
size 79970
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7b3105694519d42956dc164a04b7642dfff4a303d7b96cfd2967ea3ad1bc19cd
size 8071
oid sha256:63449813a8ca880d291113834561224e2547546580cb10a1c4dd7e35a142c638
size 29089
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8fd9f5dfc87d0a6e65e9c10c67f4359abde2ce85265770f8a47e3329f2f64a2d
size 12443
oid sha256:a5efe93f94bd3f0c68adaa21043cbbf02ef1e6aebbbdd137e8e2a8e99f2f6193
size 41850
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:97f49b6969c5a852624558077f9fc9b0add39a12e76e744a385c85785d4489ec
size 34405
oid sha256:7c488147fde94e3c74660141125279b12435b9f037359256d70555afcd239a18
size 77333
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:31283735c33057ede4d2ba20024b00ae4a4f45510cfbb47c21f980f7151c3858
size 8931
oid sha256:70aa1b155531b531ae7f7e7ac1a5123fd065a3c9080c6319435f63f8c315c4e4
size 31421
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0a2decb05c73655390dd2cfb3d88d209b17ded46d5d6c40cc8672e5781b48348
size 13334
oid sha256:a5e462fa9302567a3ac90a4493e71ccb08ba8b924af686b251791c24c8b8553a
size 44108
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:21803c66fce41f53cd7796321fb9f3fc774e0c4fc99cd3683a3a8b22e00e2615
size 35966
oid sha256:5ecc879f8b304ffd4b95f54d1aeda7208238352af1570be23bc9d8de4fe84677
size 80846
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:11300a4c85f2e8daa06ddb4a3f8d6f72432dc581dca279366b1229450d3b973c
size 8634
oid sha256:447a27ce5bcd769a8efda39c39ef3494eaf648cbd0fa4939b6089e58b3f31ea3
size 30107
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1c8a1759a613527cfe5912f6e811121e29611ed05b8a8cecb95391ffb1bc5be2
size 12710
oid sha256:3946799c8c3faba3bfef028f6adba46919bda464b73d83d3dcee95383fec491b
size 42288
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c53798a077d020700ae58aa93aa507dd7ce88984c48aaabf11ba863e8b1773ed
size 34944
oid sha256:99a5e5fa3f4e014fd1a2b127646e88d848802d24f3150c45b1fb8e4b405d45d1
size 78217
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2ef97e6e11a66c49ed5ba1863567f24097fe8121ea34f7de5fc5cdda3f0c7a0b
size 9279
oid sha256:42a0dbb630480cda58746522b69f6368c9cc7295f98a865a3caa4535a8f024f3
size 32081
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:49bec9adfef9cf1637c722f5c96e0fec677c21a4ea3f89fd4555a9c894090be0
size 9833
oid sha256:d325f7de3a1c8517e7a924de415ab8da043ae3b44eec5e46c5150b1f58f01a50
size 32799
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a010f16ec1221a43ba2bd5266da05f3de48d354b98dfc087974ff605d32d095d
size 9576
oid sha256:ff893a90f760818bd9c4f5befabf67c90d2ecc30ce26e1f613420ec32285c588
size 32630
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c91316c96e75ba29e3d1bae38cb8b0abf255d7316ff3a616fdbddafc550c744f
size 9715
oid sha256:458bca1df07c79811b4deb874bb71bb822ae21cf85eb76ecf92c69f8ea10e84d
size 32949
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0f8945f66443988d9b36084868241079e22e61605f21cad055d8e21e8e063c0c
size 9819
oid sha256:c107c8d928bb50527a24032697dee9427dd1dde5987c0c536c52163ce3b8e2a6
size 33164
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2cc4cb7aca28fba19e6e5d9398ca7d26c9c361298137552789024f84a78a7f70
size 8713
oid sha256:0cd6406db1a8309e2f524d4de88ceed6062ac0902622d16fa73d6d8ca64f780e
size 30306
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:48a1a38461c9d2f0bb66878b977673531808eafc0aba53f17beff779a575707e
size 9337
oid sha256:8b63323a128da29de0ebee9104ce6473136e5e3e8a11394c61116c683c3d51a7
size 31109
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:98990600b7b05b8fe64404ef1f463bf5be99f48b90b6d6aab64f799d5b03f737
size 9095
oid sha256:8008dfe4f3a135203766b8107751960f159a1bb3350e1d43ede376075db8d61d
size 30978
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c662180e91e10974f7709364a07fa54194df0061425565e08644e731f5922dfa
size 9196
oid sha256:f642dd641bf49c51d09fcbbc6a644cf280efaf2e88d3c8f4f85ee51b37eeac2a
size 31190
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5c08ae126aab643824bd431aa4449a855e382e65840ebd980d4849e19ed17152
size 9310
oid sha256:e383090f2f0f06d902b220e05434045ebdd5be781d8f076e21bdc434a83c5bde
size 31393
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9a3c0e76d07f250735fd75061fb076732180b254d8e8bc145887e0d307272de0
size 9340
oid sha256:cf406dbc78412e6c4e457dfbcbabf05248f2578e938016c4a5a6b5cccf1b0c29
size 32157
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:08357309dca3a8a3cd8a38f0928522eeefd3a0cdd91990fcc590929e187b162a
size 11676
oid sha256:2506e19dfcd975a69fa533973ead95ea7923dbc67545553bad0b09ca87b3f6e9
size 40834
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d03f232373aa8cb3f0f6fceeecbd4b1ef86f8703ac6b887683cbfa5c5e5af4e4
size 36831
oid sha256:1a6bc629fad6fdd5e1285cc9ac4484683b111498f989c5fe43f0b037b5619b36
size 82000
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:886dff267c22e2ba300b81f13e3071756483c5938e9d1cfb357f633357f5ff06
size 7634
oid sha256:745ea9fca0277511ef197608025135387a985082367e52d52b780ba94498b7aa
size 28992
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5c11e7b79420af53a8b934b3ad6dbe9c68d8ea5635322389663b55f62160eade
size 8775
oid sha256:e20e92f594ead4e2d5a0beb2f290077506322ec11eb75f8d16c1374ddb217456
size 30375
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22f76d8fbeaca8d0b986b4ac17a28c16e5f757e2022466200215fca333f69ede
size 10929
oid sha256:dd0e274dc81bf5bfa6498922cb3a75721bb98af8fbeaf558d67b127248755543
size 38543
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:73cc7f1b0f8b9bff2b2e6c9a88b833baaeb9f39923c2113a006399c6a877634a
size 35118
oid sha256:e54d39cc93385c85ae9c1f8b9f5dd459811c28878f788d0e05562bfb62dfff6d
size 78927
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d4d538dd90a36eefd2f9c8f8f66847cce6bc17f60e503513bf1890e6b5403d12
size 7244
oid sha256:4485fe9b2310bde15485f78d7dd556df6453e2567f859ab3268e36d5ec901f4f
size 27453
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5832d2a257dcf613420352d10c42e058ca9df0afa323f2a6bd91ab6596240e5a
size 528252
oid sha256:b5bb8fe5d28d0df28c25c633d5e37aa6245d69e754e3ec543d855fd4a64517fb
size 448654
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:691c6ffd759b01f216128c1f7d59e7cae76e1f1a83c95a8b89e6f2f55dce3b9c
size 33009
oid sha256:a7f75b9cad16c26fdca283a79b871be4b4c41d8c15b1d4ea96c82dd37015761e
size 48065
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a5fbccba6548b6101f50f3842310e591507321817b048ec9a7310203dcd868d9
size 524645
oid sha256:ece2446eaa8417ce67a32589fa2901a8e2c73b6475b46b1bfb895ee3aacb6937
size 445037
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e218343a909a15b66c6c00f46c6bbdd783ad1365aa380ed576a9e66c6dc3c09f
size 31646
oid sha256:a4d6f6ad2d759fd2383eed4d051880851cd3b46a03812e6ce43a372dd4542be8
size 46007
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f10ca2d461e4078e46455e007eca2d1e9c9a20dbb6bc24c681fa5164e5f50efd
size 13531
oid sha256:d5c4ad66332bc73642009c013cf99b4b67dfe22d34dfe9db5d30e18dbafa2f10
size 8905
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da386980ce6a45e102727715b5d50f219e39dcb40a513abdfaab9fa725b8ea41
size 13260
oid sha256:921ac0d2d3ced6fc6c21c3fa82c49a783bf1c1ecababbac8d07364c5ee5a2573
size 8727
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:348a194ad17a0a0dffda47c7387edde99436d543e9bff1726608946e92558830
size 185553
oid sha256:be416c0da077b81dbbe3c61c750bcaa3398150ac3444f1f9a365709d2ef647d0
size 185717
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b23ef4fb29f51e308b74681017fcee257308edbd1e218e4daf5736501c70e0de
size 699637
oid sha256:06735917160f8f210909922ccf765b806c64586853fad685634a006fe59af974
size 700189
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:47779722cc50bb7181d300e5738d10c32824c87122faef5939c4774e1b6530d8
size 23583
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3c23fdfa983bc6032756dfb80149f8f39643215eab97ddb8d9c843401262bfc6
size 20022
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:515d5a47d530117ff21cb922d1cb2cb81b22d02c083c51bbff69964f92260bbe
size 197095
oid sha256:d3403da236e7e9e8a28001adcb0f5c188d79bdf83146b69e8094b37e02627b28
size 197245
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2871bd26b6c21d31ca251761eb5a203468406b4d18c13662fa815c53f015b5e1
size 210297
oid sha256:3454f97fd3811411ea5cd941b3d1e37a0f5ab98b9ef7475fa2c903c2b6b089d1
size 210375
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fc42702e3ee2b646b4763fef985568f03d3d1ad971fd426328a1d9820045cbb8
size 24235
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e2d3fd5b70d90864b581d71b064a684f1dfbaa368464c32d259dc418a4ab77f5
size 20821
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cc65f284ec05a8ed7ea496c49c52afae585f09d26986d8582c00385459823393
size 136782
oid sha256:9f3f46de75cee217874e2c57b390998136a19d32613ea33a2e303541a4fd165b
size 136770