Merge pull request #7112 from element-hq/feature/bma/galleryMessages

Support gallery messages
This commit is contained in:
Benoit Marty
2026-07-03 14:09:20 +02:00
committed by GitHub
175 changed files with 4915 additions and 590 deletions
@@ -0,0 +1,28 @@
/*
* 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.api
import android.os.Parcelable
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.parcelize.Parcelize
@Parcelize
data class GalleryItemData(
val filename: String,
val mimeType: String,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val type: Type,
) : Parcelable {
enum class Type {
Image,
Video,
Audio,
File,
}
}
@@ -13,6 +13,23 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize
@Parcelize
data class GalleryInfo(
val caption: String?,
val formattedCaption: CharSequence? = null,
val senderId: UserId?,
val senderName: String?,
val senderAvatar: String?,
val dateSent: String?,
val dateSentFull: String?,
val initialIndex: Int,
) : Parcelable
@Parcelize
data class AvatarInfo(
val filename: String,
) : Parcelable
@Parcelize
data class MediaInfo(
val filename: String,
@@ -35,18 +35,32 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean)
}
data class Params(
val mode: MediaViewerMode,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val canShowInfo: Boolean,
) : NodeInputs
sealed interface Params : NodeInputs {
data class RoomMedia(
val mode: MediaViewerMode,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
) : Params
data class EventGallery(
val eventId: EventId?,
val galleryInfo: GalleryInfo,
val galleryItems: List<GalleryItemData>,
val fromPinnedMessages: Boolean,
) : Params
data class Avatar(
val avatarInfo: AvatarInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
) : Params
}
sealed interface MediaViewerMode : Parcelable {
@Parcelize
data object SingleMedia : MediaViewerMode
data class EventGallery(val fromPinnedMessages: Boolean) : MediaViewerMode
@Parcelize
data class TimelineImagesAndVideos(val timelineMode: Timeline.Mode) : MediaViewerMode
@@ -13,40 +13,20 @@ import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.AvatarInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode
@ContributesBinding(AppScope::class)
class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint {
override fun createParamsForAvatar(filename: String, avatarUrl: String): MediaViewerEntryPoint.Params {
// We need to fake the MimeType here for the viewer to work.
val mimeType = MimeTypes.Images
return MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = null,
mediaInfo = MediaInfo(
return MediaViewerEntryPoint.Params.Avatar(
avatarInfo = AvatarInfo(
filename = filename,
fileSize = null,
caption = null,
formattedCaption = null,
mimeType = mimeType,
formattedFileSize = "",
fileExtension = "",
senderId = UserId("@dummy:server.org"),
senderName = null,
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
duration = null,
),
mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null,
canShowInfo = false,
)
}
@@ -13,6 +13,7 @@ import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
@@ -20,6 +21,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
@@ -54,7 +57,7 @@ class EventItemFactory(
) {
fun create(
currentTimelineItem: MatrixTimelineItem.Event,
): MediaItem.Event? {
): List<MediaItem.Event> {
val event = currentTimelineItem.event
val dateSent = dateFormatter.format(
currentTimelineItem.event.timestamp,
@@ -79,7 +82,7 @@ class EventItemFactory(
is LiveLocationContent,
UnknownContent -> {
Timber.w("Should not happen: ${content.javaClass.simpleName}")
null
emptyList()
}
is MessageContent -> {
when (val type = content.type) {
@@ -89,9 +92,93 @@ class EventItemFactory(
is LocationMessageType,
is TextMessageType -> {
Timber.w("Should not happen: ${content.type}")
null
emptyList()
}
is AudioMessageType -> MediaItem.Audio(
is GalleryMessageType -> {
val baseId = currentTimelineItem.uniqueId.value
type.items.mapIndexedNotNull { index, galleryItem ->
val id = UniqueId("${baseId}_$index")
when (galleryItem) {
is GalleryItemType.Image -> {
val c = galleryItem.content
MediaItem.Image(
id = id,
eventId = currentTimelineItem.eventId,
mediaInfo = createMediaInfo(
filename = c.filename,
fileSize = c.info?.size,
caption = c.caption,
mimeType = c.info?.mimetype.orEmpty(),
fileExtension = c.filename,
event = event,
dateSent = dateSent,
dateSentFull = dateSentFull,
),
mediaSource = c.source,
thumbnailSource = c.info?.thumbnailSource,
)
}
is GalleryItemType.Video -> {
val c = galleryItem.content
MediaItem.Video(
id = id,
eventId = currentTimelineItem.eventId,
mediaInfo = createMediaInfo(
filename = c.filename,
fileSize = c.info?.size,
caption = c.caption,
mimeType = c.info?.mimetype.orEmpty(),
fileExtension = c.filename,
event = event,
dateSent = dateSent,
dateSentFull = dateSentFull,
duration = c.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
),
mediaSource = c.source,
thumbnailSource = c.info?.thumbnailSource,
)
}
is GalleryItemType.Audio -> {
val c = galleryItem.content
MediaItem.Audio(
id = id,
eventId = currentTimelineItem.eventId,
mediaInfo = createMediaInfo(
filename = c.filename,
fileSize = c.info?.size,
caption = c.caption,
mimeType = c.info?.mimetype.orEmpty(),
fileExtension = c.filename,
event = event,
dateSent = dateSent,
dateSentFull = dateSentFull,
),
mediaSource = c.source,
)
}
is GalleryItemType.File -> {
val c = galleryItem.content
MediaItem.File(
id = id,
eventId = currentTimelineItem.eventId,
mediaInfo = createMediaInfo(
filename = c.filename,
fileSize = c.info?.size,
caption = c.caption,
mimeType = c.info?.mimetype.orEmpty(),
fileExtension = c.filename,
event = event,
dateSent = dateSent,
dateSentFull = dateSentFull,
),
mediaSource = c.source,
)
}
is GalleryItemType.Other -> null
}
}
}
is AudioMessageType -> listOf(MediaItem.Audio(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@@ -111,8 +198,8 @@ class EventItemFactory(
duration = null,
),
mediaSource = type.source,
)
is FileMessageType -> MediaItem.File(
))
is FileMessageType -> listOf(MediaItem.File(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@@ -133,8 +220,8 @@ class EventItemFactory(
),
mediaSource = type.source,
// TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource
)
is ImageMessageType -> MediaItem.Image(
))
is ImageMessageType -> listOf(MediaItem.Image(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@@ -155,8 +242,8 @@ class EventItemFactory(
),
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
)
is StickerMessageType -> MediaItem.Image(
))
is StickerMessageType -> listOf(MediaItem.Image(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@@ -177,8 +264,8 @@ class EventItemFactory(
),
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
)
is VideoMessageType -> MediaItem.Video(
))
is VideoMessageType -> listOf(MediaItem.Video(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@@ -199,8 +286,8 @@ class EventItemFactory(
),
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
)
is VoiceMessageType -> MediaItem.Voice(
))
is VoiceMessageType -> listOf(MediaItem.Voice(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@@ -220,9 +307,36 @@ class EventItemFactory(
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
),
mediaSource = type.source,
)
))
}
}
}
}
private fun createMediaInfo(
filename: String,
fileSize: Long?,
caption: String?,
mimeType: String,
fileExtension: String,
event: io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem,
dateSent: String,
dateSentFull: String,
waveform: List<Float>? = null,
duration: String? = null,
) = MediaInfo(
filename = filename,
fileSize = fileSize,
caption = caption,
mimeType = mimeType,
formattedFileSize = fileSize?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(fileExtension),
senderId = event.sender,
senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = waveform,
duration = duration,
)
}
@@ -9,10 +9,8 @@
package io.element.android.libraries.mediaviewer.impl.datasource
import dev.zacsweers.metro.Inject
import io.element.android.libraries.androidutils.diff.DefaultDiffCacheInvalidator
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import kotlinx.collections.immutable.ImmutableList
@@ -32,18 +30,8 @@ class TimelineMediaItemsFactory(
) {
private val _timelineItems = MutableSharedFlow<ImmutableList<MediaItem>>(replay = 1)
private val lock = Mutex()
private val diffCache = MutableListDiffCache<MediaItem>()
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, MediaItem>(
diffCache = diffCache,
detectMoves = false,
cacheInvalidator = DefaultDiffCacheInvalidator()
) { old, new ->
if (old is MatrixTimelineItem.Event && new is MatrixTimelineItem.Event) {
old.uniqueId == new.uniqueId
} else {
false
}
}
private val cache = mutableMapOf<UniqueId, List<MediaItem>>()
private var previousTimelineItems: List<MatrixTimelineItem> = emptyList()
val timelineItems: Flow<ImmutableList<MediaItem>> = _timelineItems.distinctUntilChanged()
@@ -51,39 +39,41 @@ class TimelineMediaItemsFactory(
timelineItems: List<MatrixTimelineItem>,
) = withContext(dispatchers.computation) {
lock.withLock {
diffCacheUpdater.updateWith(timelineItems)
buildAndEmitTimelineItemStates(timelineItems)
}
}
private suspend fun buildAndEmitTimelineItemStates(
timelineItems: List<MatrixTimelineItem>,
) {
val newTimelineItemStates = ArrayList<MediaItem>()
for (index in diffCache.indices().reversed()) {
val cacheItem = diffCache.get(index)
if (cacheItem == null) {
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
newTimelineItemStates.add(timelineItemState)
val newTimelineItemStates = ArrayList<MediaItem>()
for (index in timelineItems.indices.reversed()) {
when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> {
val cachedItems = cache[currentTimelineItem.uniqueId]
val items = if (cachedItems != null && currentTimelineItem.isUnchanged(previousTimelineItems)) {
cachedItems
} else {
eventItemFactory.create(currentTimelineItem).also { newItems ->
cache[currentTimelineItem.uniqueId] = newItems
}
}
newTimelineItemStates.addAll(items.asReversed())
}
is MatrixTimelineItem.Virtual -> {
virtualItemFactory.create(currentTimelineItem)?.also {
newTimelineItemStates.add(it)
}
}
MatrixTimelineItem.Other -> Unit
}
} else {
newTimelineItemStates.add(cacheItem)
}
previousTimelineItems = timelineItems
_timelineItems.emit(newTimelineItemStates.toImmutableList())
}
_timelineItems.emit(newTimelineItemStates.toImmutableList())
}
private fun buildAndCacheItem(
timelineItems: List<MatrixTimelineItem>,
index: Int,
): MediaItem? {
val timelineItem =
when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem)
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
MatrixTimelineItem.Other -> null
}
diffCache[index] = timelineItem
return timelineItem
}
}
private fun MatrixTimelineItem.Event.isUnchanged(
previousItems: List<MatrixTimelineItem>,
): Boolean {
val previousItem = previousItems
.find { (it as? MatrixTimelineItem.Event)?.uniqueId == uniqueId }
as? MatrixTimelineItem.Event
return previousItem != null &&
previousItem.event.eventId == event.eventId &&
previousItem.event.timestamp == event.timestamp
}
@@ -128,13 +128,12 @@ class MediaGalleryFlowNode(
mediaViewerEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = MediaViewerEntryPoint.Params(
params = MediaViewerEntryPoint.Params.RoomMedia(
mode = navTarget.mode,
eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
canShowInfo = true,
),
callback = callback,
)
@@ -0,0 +1,97 @@
/*
* 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.viewer
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.mediaviewer.api.GalleryInfo
import io.element.android.libraries.mediaviewer.api.GalleryItemData
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.flowOf
class GalleryMediaGalleryDataSource(
private val data: GroupedMediaItems,
) : MediaGalleryDataSource {
override val isReady: Boolean = true
override fun start(coroutineScope: CoroutineScope) = Unit
override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data))
override fun getLastData(): AsyncData<GroupedMediaItems> = AsyncData.Success(data)
override suspend fun loadMore(direction: Timeline.PaginationDirection) = Unit
override suspend fun deleteItem(eventId: EventId) = Unit
companion object {
fun createFrom(
eventId: EventId?,
galleryItems: List<GalleryItemData>,
galleryInfo: GalleryInfo,
): GalleryMediaGalleryDataSource {
val mixedItems = mutableListOf<MediaItem.Event>()
galleryItems.forEachIndexed { index, galleryItem ->
val itemMediaInfo = MediaInfo(
filename = galleryItem.filename,
fileSize = null,
caption = galleryInfo.caption,
mimeType = galleryItem.mimeType,
formattedFileSize = "",
fileExtension = galleryItem.filename.substringAfterLast('.', ""),
senderId = galleryInfo.senderId,
senderName = galleryInfo.senderName,
senderAvatar = galleryInfo.senderAvatar,
dateSent = galleryInfo.dateSent,
dateSentFull = galleryInfo.dateSentFull,
waveform = null,
duration = null,
)
val id = UniqueId("${eventId?.value ?: "gallery"}_$index")
val mediaItem: MediaItem.Event = when (galleryItem.type) {
GalleryItemData.Type.Video -> MediaItem.Video(
id = id,
eventId = eventId,
mediaInfo = itemMediaInfo,
mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource,
)
GalleryItemData.Type.Audio -> MediaItem.Audio(
id = id,
eventId = eventId,
mediaInfo = itemMediaInfo,
mediaSource = galleryItem.mediaSource,
)
GalleryItemData.Type.File -> MediaItem.File(
id = id,
eventId = eventId,
mediaInfo = itemMediaInfo,
mediaSource = galleryItem.mediaSource,
)
GalleryItemData.Type.Image -> MediaItem.Image(
id = id,
eventId = eventId,
mediaInfo = itemMediaInfo,
mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource,
)
}
mixedItems.add(mediaItem)
}
return GalleryMediaGalleryDataSource(
data = GroupedMediaItems(
imageAndVideoItems = mixedItems.toImmutableList(),
fileItems = persistentListOf(),
)
)
}
}
}
@@ -62,9 +62,9 @@ class MediaViewerDataSource(
private val mediaFiles: ConcurrentHashMap<MediaSource, MediaFile> = ConcurrentHashMap()
private val galleryMode = when (mode) {
MediaViewerMode.SingleMedia,
is MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
is MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
is MediaViewerMode.EventGallery -> MediaGalleryMode.Images
}
// Map of sourceUrl to local media state
@@ -98,9 +98,12 @@ class MediaViewerDataSource(
/**
* Find the index of the page corresponding to the given eventId, or null if not found.
*/
fun findEventIndex(eventId: EventId?): Int? {
fun findEventIndex(eventId: EventId?, mediaSource: MediaSource? = null): Int? {
if (eventId == null) return null
return dataFlow.value.indexOfFirst { (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId }.takeIf { it >= 0 }
return dataFlow.value.indexOfFirst {
val pageData = it as? MediaViewerPageData.MediaViewerData
pageData?.eventId == eventId && (mediaSource == null || pageData.mediaSource == mediaSource)
}.takeIf { it >= 0 }
}
@VisibleForTesting
@@ -74,42 +74,51 @@ class MediaViewerNode(
callback.onDone()
}
private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) {
SingleMediaGalleryDataSource.createFrom(inputs)
} else {
val eventId = inputs.eventId
if (eventId == null) {
// Should not happen
timelineMediaGalleryDataSource
} else {
// Can we use a specific timeline?
val timelineMode = inputs.mode.getTimelineMode()
when (timelineMode) {
null -> timelineMediaGalleryDataSource
Timeline.Mode.Live,
is Timeline.Mode.FocusedOnEvent,
is Timeline.Mode.Thread -> {
// Does timelineMediaGalleryDataSource knows the eventId?
val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull()
val isEventKnown = lastData?.hasEvent(eventId) == true
if (isEventKnown) {
timelineMediaGalleryDataSource
} else {
private val mediaGallerySource = when (inputs) {
is MediaViewerEntryPoint.Params.Avatar ->
SingleMediaGalleryDataSource.createFrom(inputs)
is MediaViewerEntryPoint.Params.EventGallery ->
GalleryMediaGalleryDataSource.createFrom(
eventId = inputs.eventId,
galleryItems = inputs.galleryItems,
galleryInfo = inputs.galleryInfo,
)
is MediaViewerEntryPoint.Params.RoomMedia -> {
val eventId = inputs.eventId
if (eventId == null) {
// Should not happen
timelineMediaGalleryDataSource
} else {
// Can we use a specific timeline?
val timelineMode = inputs.mode.getTimelineMode()
when (timelineMode) {
Timeline.Mode.Live,
is Timeline.Mode.FocusedOnEvent,
is Timeline.Mode.Thread -> {
// Does timelineMediaGalleryDataSource knows the eventId?
val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull()
val isEventKnown = lastData?.hasEvent(eventId) == true
if (isEventKnown) {
timelineMediaGalleryDataSource
} else {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
onlyPinnedEvents = false,
)
}
}
Timeline.Mode.PinnedEvents -> {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
onlyPinnedEvents = false,
onlyPinnedEvents = true,
)
}
Timeline.Mode.Media -> timelineMediaGalleryDataSource
// null should not happen, input should be MediaViewerEntryPoint.Params.EventGallery in this case
null -> timelineMediaGalleryDataSource
}
Timeline.Mode.PinnedEvents -> {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
onlyPinnedEvents = true,
)
}
Timeline.Mode.Media -> timelineMediaGalleryDataSource
}
}
}
@@ -118,7 +127,14 @@ class MediaViewerNode(
inputs = inputs,
navigator = this,
dataSource = MediaViewerDataSource(
mode = inputs.mode,
mode = when (inputs) {
is MediaViewerEntryPoint.Params.Avatar ->
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media)
is MediaViewerEntryPoint.Params.EventGallery ->
MediaViewerEntryPoint.MediaViewerMode.EventGallery(fromPinnedMessages = inputs.fromPinnedMessages)
is MediaViewerEntryPoint.Params.RoomMedia ->
inputs.mode
},
coroutineScope = lifecycleScope,
dispatcher = coroutineDispatchers.computation,
galleryDataSource = mediaGallerySource,
@@ -153,6 +169,6 @@ internal fun MediaViewerEntryPoint.MediaViewerMode.getTimelineMode(): Timeline.M
return when (this) {
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> timelineMode
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> timelineMode
else -> null
is MediaViewerEntryPoint.MediaViewerMode.EventGallery -> null
}
}
@@ -67,20 +67,31 @@ class MediaViewerPresenter(
// Use a local snackbarDispatcher because this presenter is used in an Overlay Node
private val snackbarDispatcher = SnackbarDispatcher()
private val eventId = inputs.eventId()
private val mediaSource = inputs.mediaSource()
@Composable
override fun present(): MediaViewerState {
val coroutineScope = rememberCoroutineScope()
val currentIndex = remember { mutableIntStateOf(dataSource.findEventIndex(inputs.eventId) ?: 0) }
val currentIndex = remember {
val firstIndex = if (inputs is MediaViewerEntryPoint.Params.EventGallery) {
// Order is reversed so we have to reverse the index
inputs.galleryItems.lastIndex - inputs.galleryInfo.initialIndex
} else {
dataSource.findEventIndex(eventId, mediaSource) ?: 0
}
mutableIntStateOf(firstIndex)
}
val data = dataSource.produceState { flow ->
flow.collectLatest { new ->
val existingItem = value.getOrNull(currentIndex.intValue)
val newItem = new.getOrNull(currentIndex.intValue)
if (existingItem is MediaViewerPageData.MediaViewerData && existingItem.eventId == inputs.eventId && newItem != existingItem) {
currentIndex.intValue = dataSource.findEventIndex(inputs.eventId) ?: 0
if (existingItem is MediaViewerPageData.MediaViewerData && existingItem.eventId == eventId && newItem != existingItem) {
currentIndex.intValue = dataSource.findEventIndex(eventId, mediaSource) ?: 0
} else if (currentIndex.intValue > 0 && value.firstOrNull() is MediaViewerPageData.Loading &&
new.firstOrNull() !is MediaViewerPageData.Loading) {
// Restore index based on the eventId after the initial items have been loaded
currentIndex.intValue = dataSource.findEventIndex(inputs.eventId) ?: 0
currentIndex.intValue = dataSource.findEventIndex(eventId, mediaSource) ?: 0
}
value = new
}
@@ -140,7 +151,15 @@ class MediaViewerPresenter(
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onForwardClick(
eventId = event.eventId,
fromPinnedEvents = inputs.mode.getTimelineMode() == Timeline.Mode.PinnedEvents,
fromPinnedEvents = when (inputs) {
is MediaViewerEntryPoint.Params.RoomMedia -> when (val myMode = inputs.mode) {
is MediaViewerEntryPoint.MediaViewerMode.EventGallery -> myMode.fromPinnedMessages
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> myMode.getTimelineMode() == Timeline.Mode.PinnedEvents
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> myMode.getTimelineMode() == Timeline.Mode.PinnedEvents
}
is MediaViewerEntryPoint.Params.EventGallery -> inputs.fromPinnedMessages
is MediaViewerEntryPoint.Params.Avatar -> false
},
)
}
is MediaViewerEvent.OpenInfo -> coroutineScope.launch {
@@ -176,11 +195,11 @@ class MediaViewerPresenter(
}
return MediaViewerState(
initiallySelectedEventId = inputs.eventId,
initiallySelectedEventId = eventId,
listData = data.value,
currentIndex = currentIndex.intValue,
snackbarMessage = snackbarMessage,
canShowInfo = inputs.canShowInfo,
canShowInfo = inputs !is MediaViewerEntryPoint.Params.Avatar,
mediaBottomSheetState = mediaBottomSheetState,
eventSink = ::handleEvent,
)
@@ -224,13 +243,16 @@ class MediaViewerPresenter(
}
private fun showNoMoreItemsSnackbar() {
val messageResId = when (inputs.mode) {
MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show
if (inputs is MediaViewerEntryPoint.Params.RoomMedia) {
val messageResId = when (inputs.mode) {
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show
// Should not happen
is MediaViewerEntryPoint.MediaViewerMode.EventGallery -> R.string.screen_media_details_no_more_media_to_show
}
val message = SnackbarMessage(messageResId)
snackbarDispatcher.post(message)
}
val message = SnackbarMessage(messageResId)
snackbarDispatcher.post(message)
}
private fun CoroutineScope.downloadMedia(
@@ -292,3 +314,15 @@ class MediaViewerPresenter(
}
}
}
private fun MediaViewerEntryPoint.Params.eventId() = when (this) {
is MediaViewerEntryPoint.Params.Avatar -> null
is MediaViewerEntryPoint.Params.EventGallery -> eventId
is MediaViewerEntryPoint.Params.RoomMedia -> eventId
}
private fun MediaViewerEntryPoint.Params.mediaSource() = when (this) {
is MediaViewerEntryPoint.Params.Avatar -> mediaSource
is MediaViewerEntryPoint.Params.EventGallery -> null
is MediaViewerEntryPoint.Params.RoomMedia -> mediaSource
}
@@ -11,6 +11,7 @@ package io.element.android.libraries.mediaviewer.impl.viewer
import dev.zacsweers.metro.Inject
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.eventId
import io.element.android.libraries.mediaviewer.impl.model.mediaSource
/**
* x and y are loading items.
@@ -50,31 +51,32 @@ class PagerKeysHandler {
if (cachedData.mediaItems.isEmpty()) {
cachedData = Data(mediaItems, 0)
} else {
// Search a common item in both lists, i.e. an item with the same eventId
val itemInCacheIndex = cachedData.mediaItems.indexOfFirst { mediaItem ->
mediaItem is MediaItem.Event && mediaItems
// Search a common item in both lists using eventId + mediaSource to handle gallery items
val itemInCacheIndex = cachedData.mediaItems.indexOfFirst { cachedItem ->
cachedItem is MediaItem.Event && mediaItems
.filterIsInstance<MediaItem.Event>()
.any { mediaItem.eventId() == it.eventId() }
.any { newItem ->
cachedItem.eventId() == newItem.eventId() &&
cachedItem.mediaSource().safeUrl == newItem.mediaSource().safeUrl
}
}
cachedData = if (itemInCacheIndex == -1) {
// If the item is not found, start with a new cache
Data(mediaItems, 0)
} else {
val cachedItem = cachedData.mediaItems[itemInCacheIndex]
val eventId = (cachedItem as? MediaItem.Event)?.eventId()
if (eventId == null) {
// Should not happen, but in this case, start with a new cache
val cachedSourceUrl = (cachedItem as? MediaItem.Event)?.mediaSource()?.safeUrl
if (eventId == null || cachedSourceUrl == null) {
Data(mediaItems, 0)
} else {
// Search the index of the item in the new list
val itemIndex = mediaItems.indexOfFirst { mediaItem ->
mediaItem is MediaItem.Event && mediaItem.eventId() == eventId
mediaItem is MediaItem.Event &&
mediaItem.eventId() == eventId &&
mediaItem.mediaSource().safeUrl == cachedSourceUrl
}
if (itemIndex == -1) {
// If the item is not found, start with a new cache
Data(mediaItems, 0)
} else {
// Update the cache with the new list and the new offset
Data(mediaItems, cachedData.keyOffset + itemInCacheIndex - itemIndex.toLong())
}
}
@@ -9,12 +9,14 @@
package io.element.android.libraries.mediaviewer.impl.viewer
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
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.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
@@ -36,17 +38,40 @@ class SingleMediaGalleryDataSource(
override suspend fun deleteItem(eventId: EventId) = Unit
companion object {
fun createFrom(params: MediaViewerEntryPoint.Params) = SingleMediaGalleryDataSource(
fun createFrom(params: MediaViewerEntryPoint.Params.Avatar) = SingleMediaGalleryDataSource(
data = GroupedMediaItems(
// Always use imageAndVideoItems, in Single mode, this is the data that will be used
imageAndVideoItems = persistentListOf(params.toMediaItem()),
imageAndVideoItems = persistentListOf(
MediaItem.Image(
id = UniqueId("dummy"),
eventId = null,
mediaInfo = MediaInfo(
filename = params.avatarInfo.filename,
caption = null,
formattedCaption = null,
mimeType = MimeTypes.Images,
fileSize = null,
formattedFileSize = "",
fileExtension = "",
senderId = null,
senderName = null,
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
duration = null,
),
mediaSource = params.mediaSource,
thumbnailSource = params.thumbnailSource,
)
),
fileItems = persistentListOf(),
)
)
}
}
fun MediaViewerEntryPoint.Params.toMediaItem() = when {
fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
mediaInfo.mimeType.isMimeTypeImage() -> {
MediaItem.Image(
id = UniqueId("dummy"),
@@ -13,14 +13,12 @@ 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.enterprise.test.FakeEnterpriseService
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
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.mediaplayer.test.FakeAudioFocus
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.AvatarInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.datasource.createTimelineMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode
@@ -128,7 +126,7 @@ class DefaultMediaViewerEntryPointTest {
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
}
val params = entryPoint.createParamsForAvatar(
filename = "fn",
filename = "avatar.png",
avatarUrl = "avatarUrl",
)
val result = entryPoint.createNode(
@@ -139,27 +137,10 @@ class DefaultMediaViewerEntryPointTest {
)
assertThat(result).isInstanceOf(MediaViewerNode::class.java)
assertThat(result.plugins).contains(
MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = null,
mediaInfo = MediaInfo(
filename = "fn",
fileSize = null,
caption = null,
mimeType = MimeTypes.Images,
formattedFileSize = "",
fileExtension = "",
senderId = UserId("@dummy:server.org"),
senderName = null,
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
duration = null,
),
MediaViewerEntryPoint.Params.Avatar(
avatarInfo = AvatarInfo(filename = "avatar.png"),
mediaSource = MediaSource(url = "avatarUrl"),
thumbnailSource = null,
canShowInfo = false,
)
)
assertThat(result.plugins).contains(callback)
@@ -12,6 +12,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.media.AudioDetails
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
@@ -26,6 +27,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
@@ -57,7 +60,8 @@ import kotlinx.collections.immutable.toImmutableList
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
class DefaultEventItemFactoryTest {
@Suppress("LargeClass")
class EventItemFactoryTest {
@Test
fun `create check all null cases`() {
val factory = createEventItemFactory()
@@ -97,7 +101,7 @@ class DefaultEventItemFactoryTest {
)
)
)
assertThat(result).isNull()
assertThat(result).isEmpty()
}
}
@@ -122,7 +126,7 @@ class DefaultEventItemFactoryTest {
)
)
)
assertThat(result).isNull()
assertThat(result).isEmpty()
}
}
@@ -150,7 +154,7 @@ class DefaultEventItemFactoryTest {
)
)
)
assertThat(result).isEqualTo(
assertThat(result).containsExactly(
MediaItem.File(
id = A_UNIQUE_ID,
eventId = AN_EVENT_ID,
@@ -201,7 +205,7 @@ class DefaultEventItemFactoryTest {
)
)
)
assertThat(result).isEqualTo(
assertThat(result).containsExactly(
MediaItem.Image(
id = A_UNIQUE_ID,
eventId = AN_EVENT_ID,
@@ -249,7 +253,7 @@ class DefaultEventItemFactoryTest {
)
)
)
assertThat(result).isEqualTo(
assertThat(result).containsExactly(
MediaItem.Audio(
id = A_UNIQUE_ID,
eventId = AN_EVENT_ID,
@@ -301,7 +305,7 @@ class DefaultEventItemFactoryTest {
)
)
)
assertThat(result).isEqualTo(
assertThat(result).containsExactly(
MediaItem.Video(
id = A_UNIQUE_ID,
eventId = AN_EVENT_ID,
@@ -353,7 +357,7 @@ class DefaultEventItemFactoryTest {
)
)
)
assertThat(result).isEqualTo(
assertThat(result).containsExactly(
MediaItem.Voice(
id = A_UNIQUE_ID,
eventId = AN_EVENT_ID,
@@ -377,6 +381,321 @@ class DefaultEventItemFactoryTest {
)
}
@Test
fun `create for GalleryMessageType with image item`() {
val factory = createEventItemFactory()
val result = factory.create(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
content = aMessageContent(
messageType = GalleryMessageType(
body = "Gallery body",
formatted = null,
items = listOf(
GalleryItemType.Image(
content = ImageMessageType(
filename = "image.jpg",
caption = "caption",
formattedCaption = null,
source = MediaSource("image_url"),
info = ImageInfo(
mimetype = MimeTypes.Jpeg,
size = 123L,
thumbnailInfo = null,
thumbnailSource = MediaSource("thumbnail_url"),
height = 1L,
width = 2L,
blurhash = null,
)
)
)
)
)
)
)
)
)
assertThat(result).containsExactly(
MediaItem.Image(
id = UniqueId("aUniqueId_0"),
eventId = AN_EVENT_ID,
mediaInfo = MediaInfo(
mimeType = MimeTypes.Jpeg,
filename = "image.jpg",
fileSize = 123L,
caption = "caption",
formattedFileSize = "123 Bytes",
fileExtension = "jpg",
senderId = A_USER_ID,
senderName = "alice",
senderAvatar = null,
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = null,
duration = null,
),
mediaSource = MediaSource("image_url"),
thumbnailSource = MediaSource("thumbnail_url"),
)
)
}
@Test
fun `create for GalleryMessageType with video item`() {
val factory = createEventItemFactory()
val result = factory.create(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
content = aMessageContent(
messageType = GalleryMessageType(
body = "Gallery body",
formatted = null,
items = listOf(
GalleryItemType.Video(
content = VideoMessageType(
filename = "video.mp4",
caption = "caption",
formattedCaption = null,
source = MediaSource("video_url"),
info = VideoInfo(
mimetype = MimeTypes.Mp4,
size = 123L,
thumbnailInfo = null,
duration = 123.seconds,
height = 1L,
width = 2L,
thumbnailSource = MediaSource("thumbnail_url"),
blurhash = null,
)
)
)
)
)
)
)
)
)
assertThat(result).containsExactly(
MediaItem.Video(
id = UniqueId("aUniqueId_0"),
eventId = AN_EVENT_ID,
mediaInfo = MediaInfo(
mimeType = MimeTypes.Mp4,
filename = "video.mp4",
fileSize = 123L,
caption = "caption",
formattedFileSize = "123 Bytes",
fileExtension = "mp4",
senderId = A_USER_ID,
senderName = "alice",
senderAvatar = null,
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = null,
duration = "2:03",
),
mediaSource = MediaSource("video_url"),
thumbnailSource = MediaSource("thumbnail_url"),
)
)
}
@Test
fun `create for GalleryMessageType with audio item`() {
val factory = createEventItemFactory()
val result = factory.create(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
content = aMessageContent(
messageType = GalleryMessageType(
body = "Gallery body",
formatted = null,
items = listOf(
GalleryItemType.Audio(
content = AudioMessageType(
filename = "audio.mp3",
caption = "caption",
formattedCaption = null,
source = MediaSource("audio_url"),
info = AudioInfo(
mimetype = MimeTypes.Mp3,
size = 123L,
duration = 456.seconds,
)
)
)
)
)
)
)
)
)
assertThat(result).containsExactly(
MediaItem.Audio(
id = UniqueId("aUniqueId_0"),
eventId = AN_EVENT_ID,
mediaInfo = MediaInfo(
mimeType = MimeTypes.Mp3,
filename = "audio.mp3",
fileSize = 123L,
caption = "caption",
formattedFileSize = "123 Bytes",
fileExtension = "mp3",
senderId = A_USER_ID,
senderName = "alice",
senderAvatar = null,
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = null,
duration = null,
),
mediaSource = MediaSource("audio_url"),
)
)
}
@Test
fun `create for GalleryMessageType with file item`() {
val factory = createEventItemFactory()
val result = factory.create(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
content = aMessageContent(
messageType = GalleryMessageType(
body = "Gallery body",
formatted = null,
items = listOf(
GalleryItemType.File(
content = FileMessageType(
filename = "document.pdf",
caption = "caption",
formattedCaption = null,
source = MediaSource("file_url"),
info = FileInfo(
mimetype = MimeTypes.Pdf,
size = 456L,
thumbnailInfo = null,
thumbnailSource = null,
)
)
)
)
)
)
)
)
)
assertThat(result).containsExactly(
MediaItem.File(
id = UniqueId("aUniqueId_0"),
eventId = AN_EVENT_ID,
mediaInfo = MediaInfo(
mimeType = MimeTypes.Pdf,
filename = "document.pdf",
fileSize = 456L,
caption = "caption",
formattedFileSize = "456 Bytes",
fileExtension = "pdf",
senderId = A_USER_ID,
senderName = "alice",
senderAvatar = null,
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = null,
duration = null,
),
mediaSource = MediaSource("file_url"),
)
)
}
@Test
fun `create for GalleryMessageType with Other item returns empty list`() {
val factory = createEventItemFactory()
val result = factory.create(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
content = aMessageContent(
messageType = GalleryMessageType(
body = "Gallery body",
formatted = null,
items = listOf(
GalleryItemType.Other(itemType = "unknown_type", body = "Some body")
)
)
)
)
)
)
assertThat(result).isEmpty()
}
@Test
fun `create for GalleryMessageType with multiple items produces indexed IDs`() {
val factory = createEventItemFactory()
val result = factory.create(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
content = aMessageContent(
messageType = GalleryMessageType(
body = "Gallery body",
formatted = null,
items = listOf(
GalleryItemType.Image(
content = ImageMessageType(
filename = "image.jpg",
caption = null,
formattedCaption = null,
source = MediaSource("image_url"),
info = ImageInfo(
mimetype = MimeTypes.Jpeg,
size = 123L,
thumbnailInfo = null,
thumbnailSource = null,
height = 1L,
width = 2L,
blurhash = null,
)
)
),
GalleryItemType.Video(
content = VideoMessageType(
filename = "video.mp4",
caption = null,
formattedCaption = null,
source = MediaSource("video_url"),
info = VideoInfo(
mimetype = MimeTypes.Mp4,
size = 456L,
thumbnailInfo = null,
duration = null,
height = 1L,
width = 2L,
thumbnailSource = null,
blurhash = null,
)
)
),
GalleryItemType.Other(itemType = "unknown_type", body = "ignored"),
)
)
)
)
)
)
assertThat(result).hasSize(2)
assertThat(result[0]).isInstanceOf(MediaItem.Image::class.java)
assertThat((result[0] as MediaItem.Image).id).isEqualTo(UniqueId("aUniqueId_0"))
assertThat(result[1]).isInstanceOf(MediaItem.Video::class.java)
assertThat((result[1] as MediaItem.Video).id).isEqualTo(UniqueId("aUniqueId_1"))
}
@Test
fun `create for StickerMessageType`() {
val factory = createEventItemFactory()
@@ -404,7 +723,7 @@ class DefaultEventItemFactoryTest {
)
)
)
assertThat(result).isEqualTo(
assertThat(result).containsExactly(
MediaItem.Image(
id = A_UNIQUE_ID,
eventId = AN_EVENT_ID,
@@ -0,0 +1,277 @@
/*
* 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.viewer
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes
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.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.mediaviewer.api.GalleryInfo
import io.element.android.libraries.mediaviewer.api.GalleryItemData
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.aGroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class GalleryMediaGalleryDataSourceTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `isReady is true`() {
val sut = GalleryMediaGalleryDataSource(aGroupedMediaItems())
assertThat(sut.isReady).isTrue()
}
@Test
fun `function start is no op`() = runTest {
val sut = GalleryMediaGalleryDataSource(aGroupedMediaItems())
sut.start(backgroundScope)
}
@Test
fun `function loadMore is no op`() = runTest {
val sut = GalleryMediaGalleryDataSource(aGroupedMediaItems())
sut.loadMore(Timeline.PaginationDirection.BACKWARDS)
sut.loadMore(Timeline.PaginationDirection.FORWARDS)
}
@Test
fun `function deleteItem is no op`() = runTest {
val sut = GalleryMediaGalleryDataSource(aGroupedMediaItems())
sut.deleteItem(AN_EVENT_ID)
}
@Test
fun `getLastData should return the data`() {
val data = aGroupedMediaItems(
imageAndVideoItems = listOf(aMediaItemImage()),
fileItems = listOf(aMediaItemFile()),
)
val sut = GalleryMediaGalleryDataSource(data)
assertThat(sut.getLastData()).isEqualTo(AsyncData.Success(data))
}
@Test
fun `groupedMediaItemsFlow emits a single item`() = runTest {
val data = aGroupedMediaItems(
imageAndVideoItems = listOf(aMediaItemImage()),
fileItems = listOf(aMediaItemFile()),
)
val sut = GalleryMediaGalleryDataSource(data)
sut.groupedMediaItemsFlow().test {
assertThat(awaitItem()).isEqualTo(AsyncData.Success(data))
awaitComplete()
}
}
@Test
fun `createFrom with image item creates MediaItem Image in imageAndVideoItems`() {
val result = GalleryMediaGalleryDataSource.createFrom(
eventId = AN_EVENT_ID,
galleryItems = listOf(
GalleryItemData(
filename = "image.jpg",
mimeType = MimeTypes.Jpeg,
mediaSource = MediaSource("image_url"),
thumbnailSource = MediaSource("thumbnail_url"),
type = GalleryItemData.Type.Image,
)
),
galleryInfo = aGalleryInfo(),
)
val data = (result.getLastData() as AsyncData.Success).data
assertThat(data.fileItems).isEmpty()
assertThat(data.imageAndVideoItems).containsExactly(
MediaItem.Image(
id = UniqueId("${AN_EVENT_ID.value}_0"),
eventId = AN_EVENT_ID,
mediaInfo = expectedMediaInfo("image.jpg", MimeTypes.Jpeg),
mediaSource = MediaSource("image_url"),
thumbnailSource = MediaSource("thumbnail_url"),
)
)
}
@Test
fun `createFrom with video item creates MediaItem Video in imageAndVideoItems`() {
val result = GalleryMediaGalleryDataSource.createFrom(
eventId = AN_EVENT_ID,
galleryItems = listOf(
GalleryItemData(
filename = "video.mp4",
mimeType = MimeTypes.Mp4,
mediaSource = MediaSource("video_url"),
thumbnailSource = MediaSource("thumbnail_url"),
type = GalleryItemData.Type.Video,
)
),
galleryInfo = aGalleryInfo(),
)
val data = (result.getLastData() as AsyncData.Success).data
assertThat(data.fileItems).isEmpty()
assertThat(data.imageAndVideoItems).containsExactly(
MediaItem.Video(
id = UniqueId("${AN_EVENT_ID.value}_0"),
eventId = AN_EVENT_ID,
mediaInfo = expectedMediaInfo("video.mp4", MimeTypes.Mp4),
mediaSource = MediaSource("video_url"),
thumbnailSource = MediaSource("thumbnail_url"),
)
)
}
@Test
fun `createFrom with audio item creates MediaItem Audio in imageAndVideoItems`() {
val result = GalleryMediaGalleryDataSource.createFrom(
eventId = AN_EVENT_ID,
galleryItems = listOf(
GalleryItemData(
filename = "audio.mp3",
mimeType = MimeTypes.Mp3,
mediaSource = MediaSource("audio_url"),
thumbnailSource = null,
type = GalleryItemData.Type.Audio,
)
),
galleryInfo = aGalleryInfo(),
)
val data = (result.getLastData() as AsyncData.Success).data
assertThat(data.fileItems).isEmpty()
assertThat(data.imageAndVideoItems).containsExactly(
MediaItem.Audio(
id = UniqueId("${AN_EVENT_ID.value}_0"),
eventId = AN_EVENT_ID,
mediaInfo = expectedMediaInfo("audio.mp3", MimeTypes.Mp3),
mediaSource = MediaSource("audio_url"),
)
)
}
@Test
fun `createFrom with file item creates MediaItem File in imageAndVideoItems`() {
val result = GalleryMediaGalleryDataSource.createFrom(
eventId = AN_EVENT_ID,
galleryItems = listOf(
GalleryItemData(
filename = "document.pdf",
mimeType = MimeTypes.Pdf,
mediaSource = MediaSource("file_url"),
thumbnailSource = null,
type = GalleryItemData.Type.File,
)
),
galleryInfo = aGalleryInfo(),
)
val data = (result.getLastData() as AsyncData.Success).data
assertThat(data.fileItems).isEmpty()
assertThat(data.imageAndVideoItems).containsExactly(
MediaItem.File(
id = UniqueId("${AN_EVENT_ID.value}_0"),
eventId = AN_EVENT_ID,
mediaInfo = expectedMediaInfo("document.pdf", MimeTypes.Pdf),
mediaSource = MediaSource("file_url"),
)
)
}
@Test
fun `createFrom uses gallery prefix when eventId is null`() {
val result = GalleryMediaGalleryDataSource.createFrom(
eventId = null,
galleryItems = listOf(
GalleryItemData(
filename = "image.jpg",
mimeType = MimeTypes.Jpeg,
mediaSource = MediaSource("image_url"),
thumbnailSource = null,
type = GalleryItemData.Type.Image,
)
),
galleryInfo = aGalleryInfo(),
)
val data = (result.getLastData() as AsyncData.Success).data
val item = data.imageAndVideoItems.single() as MediaItem.Image
assertThat(item.id).isEqualTo(UniqueId("gallery_0"))
assertThat(item.eventId).isNull()
}
@Test
fun `createFrom with multiple items produces indexed IDs and all go to imageAndVideoItems`() {
val result = GalleryMediaGalleryDataSource.createFrom(
eventId = AN_EVENT_ID,
galleryItems = listOf(
GalleryItemData(
filename = "image.jpg",
mimeType = MimeTypes.Jpeg,
mediaSource = MediaSource("image_url"),
thumbnailSource = null,
type = GalleryItemData.Type.Image,
),
GalleryItemData(
filename = "document.pdf",
mimeType = MimeTypes.Pdf,
mediaSource = MediaSource("file_url"),
thumbnailSource = null,
type = GalleryItemData.Type.File,
),
GalleryItemData(
filename = "video.mp4",
mimeType = MimeTypes.Mp4,
mediaSource = MediaSource("video_url"),
thumbnailSource = null,
type = GalleryItemData.Type.Video,
),
),
galleryInfo = aGalleryInfo(),
)
val data = (result.getLastData() as AsyncData.Success).data
assertThat(data.fileItems).isEmpty()
assertThat(data.imageAndVideoItems).hasSize(3)
assertThat((data.imageAndVideoItems[0] as MediaItem.Image).id).isEqualTo(UniqueId("${AN_EVENT_ID.value}_0"))
assertThat((data.imageAndVideoItems[1] as MediaItem.File).id).isEqualTo(UniqueId("${AN_EVENT_ID.value}_1"))
assertThat((data.imageAndVideoItems[2] as MediaItem.Video).id).isEqualTo(UniqueId("${AN_EVENT_ID.value}_2"))
}
private fun aGalleryInfo() = GalleryInfo(
caption = "A caption",
formattedCaption = null,
senderId = A_USER_ID,
senderName = "Alice",
senderAvatar = null,
dateSent = "Today",
dateSentFull = "Today at 12:00",
initialIndex = 0,
)
private fun expectedMediaInfo(filename: String, mimeType: String) = MediaInfo(
filename = filename,
fileSize = null,
caption = "A caption",
mimeType = mimeType,
formattedFileSize = "",
fileExtension = filename.substringAfterLast('.', ""),
senderId = A_USER_ID,
senderName = "Alice",
senderAvatar = null,
dateSent = "Today",
dateSentFull = "Today at 12:00",
waveform = null,
duration = null,
)
}
@@ -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.mediaviewer.api.AvatarInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
@@ -109,7 +110,11 @@ class MediaViewerPresenterTest {
fun `present - initial state cannot show info`() = runTest {
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
canShowInfo = false,
inputs = MediaViewerEntryPoint.Params.Avatar(
avatarInfo = AvatarInfo(filename = "avatar.png"),
mediaSource = aMediaSource(),
thumbnailSource = null,
),
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
@@ -639,7 +644,7 @@ class MediaViewerPresenterTest {
}
)
)
skipItems(2)
skipItems(1)
val stateWithSnackbar = awaitItem()
assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId)
}
@@ -932,29 +937,31 @@ class MediaViewerPresenterTest {
internal fun TestScope.createMediaViewerPresenter(
localMediaFactory: LocalMediaFactory,
inputs: MediaViewerEntryPoint.Params? = null,
eventId: EventId? = null,
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media),
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
),
canShowInfo: Boolean = true,
mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(),
room: JoinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline(),
),
): MediaViewerPresenter {
val actualInputs = inputs ?: createMediaViewerEntryPointParams(eventId = eventId, mode = mode)
val actualMode = when (actualInputs) {
is MediaViewerEntryPoint.Params.Avatar -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media)
is MediaViewerEntryPoint.Params.EventGallery -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media)
is MediaViewerEntryPoint.Params.RoomMedia -> actualInputs.mode
}
return MediaViewerPresenter(
inputs = createMediaViewerEntryPointParams(
eventId = eventId,
mode = mode,
canShowInfo = canShowInfo,
),
inputs = actualInputs,
navigator = mediaViewerNavigator,
dataSource = MediaViewerDataSource(
coroutineScope = backgroundScope,
mode = mode,
mode = actualMode,
dispatcher = testCoroutineDispatchers().computation,
galleryDataSource = mediaGalleryDataSource,
mediaLoader = matrixMediaLoader,
@@ -969,13 +976,11 @@ internal fun TestScope.createMediaViewerPresenter(
internal fun createMediaViewerEntryPointParams(
eventId: EventId? = null,
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
canShowInfo: Boolean = true,
) = MediaViewerEntryPoint.Params(
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.Media),
) = MediaViewerEntryPoint.Params.RoomMedia(
mode = mode,
eventId = eventId,
mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(),
thumbnailSource = null,
canShowInfo = canShowInfo,
)
@@ -160,23 +160,20 @@ class SingleMediaGalleryDataSourceTest {
private fun testFactory(
mediaInfo: MediaInfo,
expectedResult: (MediaViewerEntryPoint.Params) -> MediaItem,
expectedResult: (MediaViewerEntryPoint.Params.RoomMedia) -> MediaItem,
) {
val params = aMediaViewerEntryPointParams(mediaInfo)
val result = SingleMediaGalleryDataSource.createFrom(params)
val resultData = result.getLastData().dataOrNull()
assertThat(resultData!!.imageAndVideoItems.first()).isEqualTo(expectedResult(params))
assertThat(resultData.fileItems).isEmpty()
val result = params.toMediaItem()
assertThat(result).isEqualTo(expectedResult(params))
}
internal fun aMediaViewerEntryPointParams(
mediaInfo: MediaInfo,
) = MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
) = MediaViewerEntryPoint.Params.RoomMedia(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media),
eventId = AN_EVENT_ID,
mediaInfo = mediaInfo,
mediaSource = aMediaSource(url = "aUrl"),
thumbnailSource = aMediaSource(url = "aThumbnailUrl"),
canShowInfo = true,
)
}