From c8ca4d74259431cba2cba0a6081e79af92cb8a3a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 15 Jan 2025 11:59:15 +0100 Subject: [PATCH 01/38] Create MediaGalleryDataSource and extract logic from MediaGalleryPresenter. --- .../impl/gallery/MediaGalleryDataSource.kt | 86 ++++++++++++++ .../impl/gallery/MediaGalleryPresenter.kt | 107 +++--------------- .../impl/gallery/MediaItemsPostProcessor.kt | 19 +--- 3 files changed, 105 insertions(+), 107 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt new file mode 100644 index 0000000000..b83fda1c65 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2025 New Vector 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 + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject + +@SingleIn(RoomScope::class) +class MediaGalleryDataSource @Inject constructor( + private val room: MatrixRoom, + private val timelineMediaItemsFactory: TimelineMediaItemsFactory, + private val mediaItemsPostProcessor: MediaItemsPostProcessor, +) { + private var timeline: Timeline? = null + + private val groupedMediaItemsFlow = MutableSharedFlow>(replay = 1) + + fun groupedMediaItemsFlow(): Flow> = groupedMediaItemsFlow + + private val isStarted = AtomicBoolean(false) + + @OptIn(ExperimentalCoroutinesApi::class) + fun start() { + if (!isStarted.compareAndSet(false, true)) { + return + } + flow { + groupedMediaItemsFlow.emit(AsyncData.Loading()) + room.mediaTimeline().fold( + { + timeline = it + emit(it) + }, + { groupedMediaItemsFlow.emit(AsyncData.Failure(it)) }, + ) + }.flatMapLatest { timeline -> + timeline.timelineItems.onEach { + timelineMediaItemsFactory.replaceWith( + timelineItems = it, + ) + } + }.flatMapLatest { + timelineMediaItemsFactory.timelineItems + }.map { timelineItems -> + mediaItemsPostProcessor.process(mediaItems = timelineItems) + }.onEach { groupedMediaItems -> + groupedMediaItemsFlow.emit(AsyncData.Success(groupedMediaItems)) + } + .onCompletion { + timeline?.close() + } + .launchIn(room.roomCoroutineScope) + } + + suspend fun loadMore(direction: Timeline.PaginationDirection) { + timeline?.paginate(direction) + } + + suspend fun deleteItem(eventId: EventId) { + timeline?.redactEvent( + eventOrTransactionId = eventId.toEventOrTransactionId(), + reason = null, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt index f9d1e9c547..476e86c5c5 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -9,15 +9,12 @@ package io.element.android.libraries.mediaviewer.impl.gallery import android.content.ActivityNotFoundException import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -33,30 +30,21 @@ import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn -import io.element.android.libraries.matrix.api.timeline.Timeline -import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch class MediaGalleryPresenter @AssistedInject constructor( @Assisted private val navigator: MediaGalleryNavigator, private val room: MatrixRoom, - private val timelineMediaItemsFactory: TimelineMediaItemsFactory, + private val mediaGalleryDataSource: MediaGalleryDataSource, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, private val localMediaActions: LocalMediaActions, private val snackbarDispatcher: SnackbarDispatcher, - private val mediaItemsPostProcessor: MediaItemsPostProcessor, ) : Presenter { @AssistedFactory interface Factory { @@ -74,56 +62,36 @@ class MediaGalleryPresenter @AssistedInject constructor( var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } - var mediaItems by remember { - mutableStateOf>>(AsyncData.Uninitialized) - } val groupedMediaItems by remember { - derivedStateOf { - mediaItemsPostProcessor.process( - mediaItems = mediaItems, - ) - } + mediaGalleryDataSource.groupedMediaItemsFlow() } + .collectAsState(AsyncData.Uninitialized) + + LaunchedEffect(Unit) { + mediaGalleryDataSource.start() + } + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() localMediaActions.Configure() - var timeline by remember { mutableStateOf>(AsyncData.Uninitialized) } - LaunchedEffect(Unit) { - room.mediaTimeline() - .fold( - { timeline = AsyncData.Success(it) }, - { timeline = AsyncData.Failure(it) }, - ) - } - DisposableEffect(Unit) { - onDispose { - timeline.dataOrNull()?.close() - } - } - - MediaListEffect( - timeline = timeline, - onItemsChange = { newItems -> - mediaItems = newItems - } - ) - fun handleEvents(event: MediaGalleryEvents) { when (event) { is MediaGalleryEvents.ChangeMode -> { mode = event.mode } is MediaGalleryEvents.LoadMore -> coroutineScope.launch { - timeline.dataOrNull()?.paginate(event.direction) + mediaGalleryDataSource.loadMore(event.direction) + } + is MediaGalleryEvents.Delete -> coroutineScope.launch { + mediaGalleryDataSource.deleteItem(event.eventId) } - is MediaGalleryEvents.Delete -> coroutineScope.delete(timeline, event.eventId) is MediaGalleryEvents.SaveOnDisk -> coroutineScope.launch { - mediaItems.dataOrNull().find(event.eventId)?.let { + groupedMediaItems.dataOrNull().find(event.eventId)?.let { saveOnDisk(it) } } is MediaGalleryEvents.Share -> coroutineScope.launch { - mediaItems.dataOrNull().find(event.eventId)?.let { + groupedMediaItems.dataOrNull().find(event.eventId)?.let { share(it) } } @@ -172,49 +140,6 @@ class MediaGalleryPresenter @AssistedInject constructor( ) } - @Composable - private fun MediaListEffect( - timeline: AsyncData, - onItemsChange: (AsyncData>) -> Unit, - ) { - val updatedOnItemsChange by rememberUpdatedState(onItemsChange) - - LaunchedEffect(timeline) { - when (timeline) { - AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized) - is AsyncData.Failure -> flowOf(AsyncData.Failure(timeline.error)) - is AsyncData.Loading -> flowOf(AsyncData.Loading()) - is AsyncData.Success -> { - timeline.data.timelineItems - .onEach { items -> - timelineMediaItemsFactory.replaceWith( - timelineItems = items, - ) - } - .launchIn(this) - - timelineMediaItemsFactory.timelineItems.map { timelineItems -> - AsyncData.Success(timelineItems) - } - } - } - .onEach { items -> - updatedOnItemsChange(items) - } - .launchIn(this) - } - } - - private fun CoroutineScope.delete( - timeline: AsyncData, - eventId: EventId, - ) = launch { - timeline.dataOrNull()?.redactEvent( - eventOrTransactionId = eventId.toEventOrTransactionId(), - reason = null, - ) - } - private suspend fun downloadMedia(mediaItem: MediaItem.Event): Result { return mediaLoader.downloadMediaFile( source = mediaItem.mediaSource(), @@ -264,10 +189,10 @@ class MediaGalleryPresenter @AssistedInject constructor( } } -private fun List?.find(eventId: EventId?): MediaItem.Event? { +private fun GroupedMediaItems?.find(eventId: EventId?): MediaItem.Event? { if (this == null || eventId == null) { return null } - return filterIsInstance() + return (imageAndVideoItems + fileItems).filterIsInstance() .firstOrNull { it.eventId() == eventId } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt index e5de5bbd58..3fb8d81b1f 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt @@ -7,32 +7,19 @@ package io.element.android.libraries.mediaviewer.impl.gallery -import io.element.android.libraries.architecture.AsyncData -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import javax.inject.Inject class MediaItemsPostProcessor @Inject constructor() { fun process( - mediaItems: AsyncData>, - ): AsyncData { - return when (mediaItems) { - is AsyncData.Uninitialized -> AsyncData.Uninitialized - is AsyncData.Loading -> AsyncData.Loading() - is AsyncData.Failure -> AsyncData.Failure(mediaItems.error) - is AsyncData.Success -> AsyncData.Success( - mediaItems.data.process() - ) - } - } - - private fun List.process(): GroupedMediaItems { + mediaItems: List, + ): GroupedMediaItems { val imageAndVideoItems = mutableListOf() val fileItems = mutableListOf() val imageAndVideoItemsSubList = mutableListOf() val fileItemsSublist = mutableListOf() - forEach { item -> + mediaItems.forEach { item -> when (item) { is MediaItem.DateSeparator -> { if (imageAndVideoItemsSubList.isNotEmpty()) { From d691a3f6a26ce54334d8c845ce61ea54d0113a70 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 16 Jan 2025 14:46:42 +0100 Subject: [PATCH 02/38] Let MediaGalleryDataSource be an interface --- .../impl/gallery/MediaGalleryDataSource.kt | 22 +- .../impl/gallery/MediaGalleryPresenter.kt | 2 +- .../gallery/SingleMediaGalleryDataSource.kt | 109 ++++++++++ .../impl/viewer/MediaViewerEvents.kt | 22 +- .../impl/viewer/MediaViewerNode.kt | 8 + .../impl/viewer/MediaViewerPresenter.kt | 164 +++++++++++---- .../impl/viewer/MediaViewerState.kt | 21 +- .../impl/viewer/MediaViewerStateProvider.kt | 123 +++++++---- .../impl/viewer/MediaViewerView.kt | 195 ++++++++++++++---- 9 files changed, 522 insertions(+), 144 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt index b83fda1c65..a2f827636b 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt @@ -26,22 +26,32 @@ import kotlinx.coroutines.flow.onEach import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject +interface MediaGalleryDataSource { + fun start() + fun groupedMediaItemsFlow(): Flow> + fun getLastData(): AsyncData + suspend fun loadMore(direction: Timeline.PaginationDirection) + suspend fun deleteItem(eventId: EventId) +} + @SingleIn(RoomScope::class) -class MediaGalleryDataSource @Inject constructor( +class TimelineMediaGalleryDataSource @Inject constructor( private val room: MatrixRoom, private val timelineMediaItemsFactory: TimelineMediaItemsFactory, private val mediaItemsPostProcessor: MediaItemsPostProcessor, -) { +) : MediaGalleryDataSource { private var timeline: Timeline? = null private val groupedMediaItemsFlow = MutableSharedFlow>(replay = 1) - fun groupedMediaItemsFlow(): Flow> = groupedMediaItemsFlow + override fun groupedMediaItemsFlow(): Flow> = groupedMediaItemsFlow + + override fun getLastData(): AsyncData = groupedMediaItemsFlow.replayCache.firstOrNull() ?: AsyncData.Uninitialized private val isStarted = AtomicBoolean(false) @OptIn(ExperimentalCoroutinesApi::class) - fun start() { + override fun start() { if (!isStarted.compareAndSet(false, true)) { return } @@ -73,11 +83,11 @@ class MediaGalleryDataSource @Inject constructor( .launchIn(room.roomCoroutineScope) } - suspend fun loadMore(direction: Timeline.PaginationDirection) { + override suspend fun loadMore(direction: Timeline.PaginationDirection) { timeline?.paginate(direction) } - suspend fun deleteItem(eventId: EventId) { + override suspend fun deleteItem(eventId: EventId) { timeline?.redactEvent( eventOrTransactionId = eventId.toEventOrTransactionId(), reason = null, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt index 476e86c5c5..247014db01 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -40,7 +40,7 @@ import kotlinx.coroutines.launch class MediaGalleryPresenter @AssistedInject constructor( @Assisted private val navigator: MediaGalleryNavigator, private val room: MatrixRoom, - private val mediaGalleryDataSource: MediaGalleryDataSource, + private val mediaGalleryDataSource: TimelineMediaGalleryDataSource, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, private val localMediaActions: LocalMediaActions, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt new file mode 100644 index 0000000000..118f2f4559 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2025 New Vector 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 + +import io.element.android.libraries.architecture.AsyncData +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.MediaViewerEntryPoint +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +class SingleMediaGalleryDataSource @Inject constructor( + private val data: GroupedMediaItems, +) : MediaGalleryDataSource { + override fun start() = Unit + override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data)) + override fun getLastData(): AsyncData = AsyncData.Success(data) + override suspend fun loadMore(direction: Timeline.PaginationDirection) = Unit + override suspend fun deleteItem(eventId: EventId) = Unit + + companion object { + fun createFrom(params: MediaViewerEntryPoint.Params) = SingleMediaGalleryDataSource( + data = when { + params.mediaInfo.mimeType.isMimeTypeImage() -> { + GroupedMediaItems( + imageAndVideoItems = persistentListOf( + MediaItem.Image( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + thumbnailSource = params.thumbnailSource, + ) + ), + fileItems = persistentListOf(), + ) + } + params.mediaInfo.mimeType.isMimeTypeVideo() -> { + GroupedMediaItems( + imageAndVideoItems = persistentListOf( + MediaItem.Video( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + thumbnailSource = params.thumbnailSource, + duration = "TODO", // TODO Duration + ) + ), + fileItems = persistentListOf(), + ) + } + params.mediaInfo.mimeType.isMimeTypeAudio() -> { + if (params.mediaInfo.waveform == null) { + GroupedMediaItems( + imageAndVideoItems = persistentListOf( + MediaItem.Audio( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + ) + ), + fileItems = persistentListOf(), + ) + } else { + GroupedMediaItems( + imageAndVideoItems = persistentListOf( + MediaItem.Voice( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + duration = "TODO", // TODO Duration + waveform = params.mediaInfo.waveform.orEmpty().toImmutableList(), + ) + ), + fileItems = persistentListOf(), + ) + } + } + else -> { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf( + MediaItem.File( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + ) + ), + ) + } + } + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt index f9fb32d325..5c07c09da0 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt @@ -8,16 +8,24 @@ package io.element.android.libraries.mediaviewer.impl.viewer import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline sealed interface MediaViewerEvents { - data object SaveOnDisk : MediaViewerEvents - data object Share : MediaViewerEvents - data object OpenWith : MediaViewerEvents - data object RetryLoading : MediaViewerEvents - data object ClearLoadingError : MediaViewerEvents + data class LoadMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class SaveOnDisk(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class Share(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class RetryLoading(val eventId: EventId) : MediaViewerEvents + data class ClearLoadingError(val eventId: EventId) : MediaViewerEvents data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents - data object OpenInfo : MediaViewerEvents - data class ConfirmDelete(val eventId: EventId) : MediaViewerEvents + data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class ConfirmDelete( + val eventId: EventId, + val data: MediaViewerPageData.MediaViewerData, + ) : MediaViewerEvents + data object CloseBottomSheet : MediaViewerEvents data class Delete(val eventId: EventId) : MediaViewerEvents + data class OnNavigateTo(val index: Int) : MediaViewerEvents + data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvents } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index 4cd528457d..adadc3ef78 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -21,12 +21,15 @@ import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.mediaviewer.impl.gallery.SingleMediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.gallery.TimelineMediaGalleryDataSource @ContributesNode(RoomScope::class) class MediaViewerNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, presenterFactory: MediaViewerPresenter.Factory, + timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource, ) : Node(buildContext, plugins = plugins), MediaViewerNavigator { private val inputs = inputs() @@ -50,6 +53,11 @@ class MediaViewerNode @AssistedInject constructor( private val presenter = presenterFactory.create( inputs = inputs, navigator = this, + mediaGalleryDataSource = if (inputs.eventId != null) { + timelineMediaGalleryDataSource + } else { + SingleMediaGalleryDataSource.createFrom(inputs) + }, ) @Composable diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index 7a8b006da1..7bde6892be 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -10,7 +10,11 @@ package io.element.android.libraries.mediaviewer.impl.viewer import android.content.ActivityNotFoundException import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableIntState import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -31,11 +35,18 @@ import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem +import io.element.android.libraries.mediaviewer.impl.gallery.eventId +import io.element.android.libraries.mediaviewer.impl.gallery.mediaInfo +import io.element.android.libraries.mediaviewer.impl.gallery.mediaSource +import io.element.android.libraries.mediaviewer.impl.gallery.thumbnailSource import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.CoroutineScope @@ -45,6 +56,7 @@ import io.element.android.libraries.androidutils.R as UtilsR class MediaViewerPresenter @AssistedInject constructor( @Assisted private val inputs: MediaViewerEntryPoint.Params, @Assisted private val navigator: MediaViewerNavigator, + @Assisted private val mediaGalleryDataSource: MediaGalleryDataSource, private val room: MatrixRoom, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, @@ -56,83 +68,132 @@ class MediaViewerPresenter @AssistedInject constructor( fun create( inputs: MediaViewerEntryPoint.Params, navigator: MediaViewerNavigator, + mediaGalleryDataSource: MediaGalleryDataSource, ): MediaViewerPresenter } @Composable override fun present(): MediaViewerState { val coroutineScope = rememberCoroutineScope() - var loadMediaTrigger by remember { mutableIntStateOf(0) } - val mediaFile: MutableState = remember { - mutableStateOf(null) + LaunchedEffect(Unit) { + mediaGalleryDataSource.start() } - val localMedia: MutableState> = remember { - mutableStateOf(AsyncData.Uninitialized) - } - val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() - localMediaActions.Configure() - DisposableEffect(loadMediaTrigger) { - coroutineScope.downloadMedia(mediaFile, localMedia) + val groupedMediaItem by remember { mediaGalleryDataSource.groupedMediaItemsFlow() } + .collectAsState(mediaGalleryDataSource.getLastData()) + + val loadMediaTrigger: MutableMap = remember { mutableMapOf() } + val mediaFile: MutableMap> = remember { mutableMapOf() } + val localMedia: MutableMap>> = remember { mutableMapOf() } + DisposableEffect(Unit) { onDispose { - mediaFile.value?.close() + mediaFile.values.forEach { it.value?.close() } } } + + val data: List by remember { + derivedStateOf { + buildList { + val data = groupedMediaItem.dataOrNull() + if (data != null) { + if (data.imageAndVideoItems.firstOrNull() is MediaItem.LoadingIndicator) { + add(MediaViewerPageData.Loading(Timeline.PaginationDirection.FORWARDS)) + } + data.imageAndVideoItems.filterIsInstance().forEach { mediaItem -> + val eventId = mediaItem.eventId() + add( + MediaViewerPageData.MediaViewerData( + eventId = eventId, + mediaInfo = mediaItem.mediaInfo(), + mediaSource = mediaItem.mediaSource(), + thumbnailSource = mediaItem.thumbnailSource(), + downloadedMedia = localMedia.getOrPut(eventId) { + mutableStateOf(AsyncData.Uninitialized) + }.value, + ) + ) + } + if (data.imageAndVideoItems.lastOrNull() is MediaItem.LoadingIndicator) { + add(MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS)) + } + } + if (isEmpty()) { + add(MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS)) + } + } + } + } + var currentIndex by remember { mutableIntStateOf(searchIndex(data, inputs.eventId)) } + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + localMediaActions.Configure() var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } - fun handleEvents(mediaViewerEvents: MediaViewerEvents) { - when (mediaViewerEvents) { - MediaViewerEvents.RetryLoading -> loadMediaTrigger++ - MediaViewerEvents.ClearLoadingError -> localMedia.value = AsyncData.Uninitialized - MediaViewerEvents.SaveOnDisk -> { - mediaBottomSheetState = MediaBottomSheetState.Hidden - coroutineScope.saveOnDisk(localMedia.value) + fun handleEvents(event: MediaViewerEvents) { + when (event) { + is MediaViewerEvents.LoadMedia -> coroutineScope.downloadMedia( + data = event.data, + mediaFile = mediaFile.getOrPut(event.data.eventId) { mutableStateOf(null) }, + localMedia = localMedia.getOrPut(event.data.eventId) { mutableStateOf(AsyncData.Uninitialized) }, + ) + is MediaViewerEvents.RetryLoading -> { + loadMediaTrigger.getOrPut(event.eventId) { mutableIntStateOf(0) }.intValue++ } - MediaViewerEvents.Share -> { - mediaBottomSheetState = MediaBottomSheetState.Hidden - coroutineScope.share(localMedia.value) + is MediaViewerEvents.ClearLoadingError -> { + localMedia.getOrPut(event.eventId) { mutableStateOf(AsyncData.Uninitialized) }.value = AsyncData.Uninitialized } - MediaViewerEvents.OpenWith -> { + is MediaViewerEvents.SaveOnDisk -> { mediaBottomSheetState = MediaBottomSheetState.Hidden - coroutineScope.open(localMedia.value) + coroutineScope.saveOnDisk(event.data.downloadedMedia) + } + is MediaViewerEvents.Share -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + coroutineScope.share(event.data.downloadedMedia) + } + is MediaViewerEvents.OpenWith -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + coroutineScope.open(event.data.downloadedMedia) } is MediaViewerEvents.Delete -> { mediaBottomSheetState = MediaBottomSheetState.Hidden - coroutineScope.delete(mediaViewerEvents.eventId) + coroutineScope.delete(event.eventId) } is MediaViewerEvents.ViewInTimeline -> { mediaBottomSheetState = MediaBottomSheetState.Hidden - navigator.onViewInTimelineClick(mediaViewerEvents.eventId) + navigator.onViewInTimelineClick(event.eventId) } - MediaViewerEvents.OpenInfo -> coroutineScope.launch { + is MediaViewerEvents.OpenInfo -> coroutineScope.launch { mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState( - eventId = inputs.eventId, - canDelete = when (inputs.mediaInfo.senderId) { + eventId = event.data.eventId, + canDelete = when (event.data.mediaInfo.senderId) { null -> false - room.sessionId -> room.canRedactOwn().getOrElse { false } && inputs.eventId != null - else -> room.canRedactOther().getOrElse { false } && inputs.eventId != null + room.sessionId -> room.canRedactOwn().getOrElse { false } && event.data.eventId != null + else -> room.canRedactOther().getOrElse { false } && event.data.eventId != null }, - mediaInfo = inputs.mediaInfo, - thumbnailSource = inputs.thumbnailSource, + mediaInfo = event.data.mediaInfo, + thumbnailSource = event.data.thumbnailSource, ) } is MediaViewerEvents.ConfirmDelete -> { mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState( - eventId = mediaViewerEvents.eventId, - mediaInfo = inputs.mediaInfo, - thumbnailSource = inputs.thumbnailSource ?: inputs.mediaSource, + eventId = event.eventId, + mediaInfo = event.data.mediaInfo, + thumbnailSource = event.data.thumbnailSource ?: event.data.mediaSource, ) } MediaViewerEvents.CloseBottomSheet -> { mediaBottomSheetState = MediaBottomSheetState.Hidden } + is MediaViewerEvents.OnNavigateTo -> { + currentIndex = event.index + } + is MediaViewerEvents.LoadMore -> coroutineScope.launch { + mediaGalleryDataSource.loadMore(event.direction) + } } } return MediaViewerState( - eventId = inputs.eventId, - mediaInfo = inputs.mediaInfo, - thumbnailSource = inputs.thumbnailSource, - downloadedMedia = localMedia.value, + listData = data, + currentIndex = currentIndex, snackbarMessage = snackbarMessage, canShowInfo = inputs.canShowInfo, mediaBottomSheetState = mediaBottomSheetState, @@ -140,12 +201,16 @@ class MediaViewerPresenter @AssistedInject constructor( ) } - private fun CoroutineScope.downloadMedia(mediaFile: MutableState, localMedia: MutableState>) = launch { + private fun CoroutineScope.downloadMedia( + data: MediaViewerPageData.MediaViewerData, + mediaFile: MutableState, + localMedia: MutableState>, + ) = launch { localMedia.value = AsyncData.Loading() mediaLoader.downloadMediaFile( - source = inputs.mediaSource, - mimeType = inputs.mediaInfo.mimeType, - filename = inputs.mediaInfo.filename + source = data.mediaSource, + mimeType = data.mediaInfo.mimeType, + filename = data.mediaInfo.filename ) .onSuccess { mediaFile.value = it @@ -153,7 +218,7 @@ class MediaViewerPresenter @AssistedInject constructor( .mapCatching { mediaFile -> localMediaFactory.createFromMediaFile( mediaFile = mediaFile, - mediaInfo = inputs.mediaInfo + mediaInfo = data.mediaInfo ) } .onSuccess { @@ -217,3 +282,14 @@ class MediaViewerPresenter @AssistedInject constructor( } } } + +private fun searchIndex(data: List, eventId: EventId?): Int { + if (eventId == null) { + return 0 + } + return data.indexOfFirst { + (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId + } + .takeIf { it != -1 } + ?: 0 +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt index b9779b0fd4..7ad1bc9982 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt @@ -11,17 +11,30 @@ import io.element.android.libraries.architecture.AsyncData 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.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState data class MediaViewerState( - val eventId: EventId?, - val mediaInfo: MediaInfo, - val thumbnailSource: MediaSource?, - val downloadedMedia: AsyncData, + val listData: List, + val currentIndex: Int, val snackbarMessage: SnackbarMessage?, val canShowInfo: Boolean, val mediaBottomSheetState: MediaBottomSheetState, val eventSink: (MediaViewerEvents) -> Unit, ) + +sealed interface MediaViewerPageData { + data class Loading( + val direction: Timeline.PaginationDirection, + ) : MediaViewerPageData + + data class MediaViewerData( + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + val downloadedMedia: AsyncData, + ) : MediaViewerPageData +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt index 70c6e62e7e..6ba6bb74ae 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt @@ -11,6 +11,7 @@ import android.net.Uri import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.media.aWaveForm +import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo @@ -26,18 +27,22 @@ open class MediaViewerStateProvider : PreviewParameterProvider override val values: Sequence get() = sequenceOf( aMediaViewerState(), - aMediaViewerState(AsyncData.Loading()), - aMediaViewerState(AsyncData.Failure(IllegalStateException("error"))), + aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Loading()))), + aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Failure(IllegalStateException("error"))))), anImageMediaInfo( senderName = "Sally Sanderson", dateSent = "21 NOV, 2024", caption = "A caption", ).let { aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, it) - ), - mediaInfo = it, + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) ) }, aVideoMediaInfo( @@ -46,50 +51,78 @@ open class MediaViewerStateProvider : PreviewParameterProvider caption = "A caption", ).let { aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, it) - ), - mediaInfo = it, + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) ) }, aPdfMediaInfo().let { aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, it) - ), - mediaInfo = it, + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) ) }, aMediaViewerState( - downloadedMedia = AsyncData.Loading(), - mediaInfo = anApkMediaInfo(), + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Loading(), + mediaInfo = anApkMediaInfo(), + ) + ) ), anApkMediaInfo().let { aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, it) - ), - mediaInfo = it, + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) ) }, aMediaViewerState( - downloadedMedia = AsyncData.Loading(), - mediaInfo = anAudioMediaInfo(), + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Loading(), + mediaInfo = anAudioMediaInfo(), + ) + ) ), anAudioMediaInfo().let { aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, it) - ), - mediaInfo = it, + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) ) }, anImageMediaInfo().let { aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, it) + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) ), - mediaInfo = it, canShowInfo = false, ) }, @@ -103,26 +136,40 @@ open class MediaViewerStateProvider : PreviewParameterProvider waveForm = aWaveForm(), ).let { aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, it) - ), - mediaInfo = it, + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) ) }, ) } -fun aMediaViewerState( +fun aMediaViewerPageData( downloadedMedia: AsyncData = AsyncData.Uninitialized, mediaInfo: MediaInfo = anImageMediaInfo(), + mediaSource: MediaSource = MediaSource(""), +): MediaViewerPageData.MediaViewerData = MediaViewerPageData.MediaViewerData( + eventId = null, + mediaInfo = mediaInfo, + mediaSource = mediaSource, + thumbnailSource = null, + downloadedMedia = downloadedMedia, +) + +fun aMediaViewerState( + listData: List = listOf(aMediaViewerPageData()), + currentIndex: Int = 0, canShowInfo: Boolean = true, mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden, eventSink: (MediaViewerEvents) -> Unit = {}, ) = MediaViewerState( - eventId = null, - mediaInfo = mediaInfo, - thumbnailSource = null, - downloadedMedia = downloadedMedia, + listData = listData, + currentIndex = currentIndex, snackbarMessage = null, canShowInfo = canShowInfo, mediaBottomSheetState = mediaBottomSheetState, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index a05d46c72c..31c89e844d 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material3.ExperimentalMaterial3Api @@ -35,6 +37,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -52,6 +55,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +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 import io.element.android.libraries.designsystem.preview.ElementPreviewDark @@ -94,50 +98,105 @@ fun MediaViewerView( val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0 var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) } + val currentData = state.listData[state.currentIndex] BackHandler { onBackClick() } Scaffold( modifier, containerColor = Color.Transparent, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { - MediaViewerPage( - showOverlay = showOverlay, - bottomPaddingInPixels = bottomPaddingInPixels, - state = state, - onDismiss = { - onBackClick() - }, - onShowOverlayChange = { - showOverlay = it + val pagerState = rememberPagerState(state.currentIndex, 0f) { + state.listData.size + } + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { page -> + state.eventSink(MediaViewerEvents.OnNavigateTo(page)) } - ) + } + HorizontalPager( + state = pagerState, + modifier = Modifier, + // Pre-load previous and next pages + beyondViewportPageCount = 1, + ) { page -> + when (val dataForPage = state.listData[page]) { + is MediaViewerPageData.Loading -> { + LaunchedEffect(Unit) { + state.eventSink(MediaViewerEvents.LoadMore(dataForPage.direction)) + } + MediaViewerLoadingPage( + onDismiss = { + onBackClick() + }, + ) + } + is MediaViewerPageData.MediaViewerData -> { + LaunchedEffect(Unit) { + state.eventSink(MediaViewerEvents.LoadMedia(dataForPage)) + } + MediaViewerPage( + showOverlay = showOverlay, + bottomPaddingInPixels = bottomPaddingInPixels, + data = dataForPage, + state = state, + onDismiss = { + onBackClick() + }, + onShowOverlayChange = { + showOverlay = it + } + ) + // Bottom bar + AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + ) { + MediaViewerBottomBar( + modifier = Modifier.align(Alignment.BottomCenter), + showDivider = dataForPage.mediaInfo.mimeType.isMimeTypeVideo(), + caption = dataForPage.mediaInfo.caption, + onHeightChange = { bottomPaddingInPixels = it }, + ) + } + } + } + } + } + // Top bar AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { Box( modifier = Modifier .fillMaxSize() .navigationBarsPadding() ) { - MediaViewerTopBar( - actionsEnabled = state.downloadedMedia is AsyncData.Success, - mimeType = state.mediaInfo.mimeType, - senderName = state.mediaInfo.senderName, - dateSent = state.mediaInfo.dateSent, - canShowInfo = state.canShowInfo, - onBackClick = onBackClick, - onInfoClick = { - state.eventSink(MediaViewerEvents.OpenInfo) - }, - eventSink = state.eventSink - ) - MediaViewerBottomBar( - modifier = Modifier.align(Alignment.BottomCenter), - showDivider = state.mediaInfo.mimeType.isMimeTypeVideo(), - caption = state.mediaInfo.caption, - onHeightChange = { bottomPaddingInPixels = it }, - ) + when (currentData) { + is MediaViewerPageData.Loading -> { + TopAppBar( + title = {}, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent.copy(0.6f), + ), + navigationIcon = { BackButton(onClick = onBackClick) }, + ) + } + is MediaViewerPageData.MediaViewerData -> { + MediaViewerTopBar( + data = currentData, + canShowInfo = state.canShowInfo, + onBackClick = onBackClick, + onInfoClick = { + state.eventSink(MediaViewerEvents.OpenInfo(currentData)) + }, + eventSink = state.eventSink + ) + } + } } } } + when (val bottomSheetState = state.mediaBottomSheetState) { MediaBottomSheetState.Hidden -> Unit is MediaBottomSheetState.MediaDetailsBottomSheetState -> { @@ -147,13 +206,24 @@ fun MediaViewerView( state.eventSink(MediaViewerEvents.ViewInTimeline(it)) }, onShare = { - state.eventSink(MediaViewerEvents.Share) + (currentData as? MediaViewerPageData.MediaViewerData)?.let { + state.eventSink(MediaViewerEvents.Share(currentData)) + } }, onDownload = { - state.eventSink(MediaViewerEvents.SaveOnDisk) + (currentData as? MediaViewerPageData.MediaViewerData)?.let { + state.eventSink(MediaViewerEvents.SaveOnDisk(currentData)) + } }, onDelete = { eventId -> - state.eventSink(MediaViewerEvents.ConfirmDelete(eventId)) + (currentData as? MediaViewerPageData.MediaViewerData)?.let { + state.eventSink( + MediaViewerEvents.ConfirmDelete( + eventId, + currentData, + ) + ) + } }, onDismiss = { state.eventSink(MediaViewerEvents.CloseBottomSheet) @@ -179,16 +249,21 @@ private fun MediaViewerPage( showOverlay: Boolean, bottomPaddingInPixels: Int, state: MediaViewerState, + data: MediaViewerPageData.MediaViewerData, onDismiss: () -> Unit, onShowOverlayChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { fun onRetry() { - state.eventSink(MediaViewerEvents.RetryLoading) + data.eventId?.let { + state.eventSink(MediaViewerEvents.RetryLoading(it)) + } } fun onDismissError() { - state.eventSink(MediaViewerEvents.ClearLoadingError) + data.eventId?.let { + state.eventSink(MediaViewerEvents.ClearLoadingError(it)) + } } val currentShowOverlay by rememberUpdatedState(showOverlay) @@ -210,7 +285,7 @@ private fun MediaViewerPage( state = flickState, modifier = modifier.background(backgroundColorFor(flickState)) ) { - val showProgress = rememberShowProgress(state.downloadedMedia) + val showProgress = rememberShowProgress(data.downloadedMedia) Box( modifier = Modifier @@ -224,7 +299,7 @@ private fun MediaViewerPage( val localMediaViewState = rememberLocalMediaViewState(zoomableState) val showThumbnail = !localMediaViewState.isReady val playableState = localMediaViewState.playableState - val showError = state.downloadedMedia is AsyncData.Failure + val showError = data.downloadedMedia is AsyncData.Failure LaunchedEffect(playableState) { if (playableState is PlayableState.Playable) { @@ -236,8 +311,8 @@ private fun MediaViewerPage( modifier = Modifier.fillMaxSize(), bottomPaddingInPixels = bottomPaddingInPixels, localMediaViewState = localMediaViewState, - localMedia = state.downloadedMedia.dataOrNull(), - mediaInfo = state.mediaInfo, + localMedia = data.downloadedMedia.dataOrNull(), + mediaInfo = data.mediaInfo, onClick = { if (playableState is PlayableState.NotPlayable) { currentOnShowOverlayChange(!currentShowOverlay) @@ -245,8 +320,8 @@ private fun MediaViewerPage( }, ) ThumbnailView( - mediaInfo = state.mediaInfo, - thumbnailSource = state.thumbnailSource, + mediaInfo = data.mediaInfo, + thumbnailSource = data.thumbnailSource, isVisible = showThumbnail, ) if (showError) { @@ -268,6 +343,37 @@ private fun MediaViewerPage( } } +@Composable +private fun MediaViewerLoadingPage( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false) + + DismissFlickEffects( + flickState = flickState, + onDismissing = { animationDuration -> + delay(animationDuration / 3) + onDismiss() + }, + onDragging = {}, + ) + + FlickToDismiss( + state = flickState, + modifier = modifier.background(backgroundColorFor(flickState)) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + contentAlignment = Alignment.Center + ) { + AsyncLoading() + } + } +} + @Composable private fun DismissFlickEffects( flickState: FlickToDismissState, @@ -316,15 +422,16 @@ private fun rememberShowProgress(downloadedMedia: AsyncData): Boolea @OptIn(ExperimentalMaterial3Api::class) @Composable private fun MediaViewerTopBar( - actionsEnabled: Boolean, - mimeType: String, - senderName: String?, - dateSent: String?, + data: MediaViewerPageData.MediaViewerData, canShowInfo: Boolean, onBackClick: () -> Unit, onInfoClick: () -> Unit, eventSink: (MediaViewerEvents) -> Unit, ) { + val actionsEnabled = data.downloadedMedia is AsyncData.Success + val mimeType = data.mediaInfo.mimeType + val senderName = data.mediaInfo.senderName + val dateSent = data.mediaInfo.dateSent TopAppBar( title = { if (senderName != null && dateSent != null) { @@ -357,7 +464,7 @@ private fun MediaViewerTopBar( IconButton( enabled = actionsEnabled, onClick = { - eventSink(MediaViewerEvents.OpenWith) + eventSink(MediaViewerEvents.OpenWith(data)) }, ) { when (mimeType) { From 84d603d31f36c6e4b1fddcfac5cec2f57535c58c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 17 Jan 2025 11:38:03 +0100 Subject: [PATCH 03/38] Remove RetryLoading (use LoadMedia) --- .../impl/viewer/MediaViewerEvents.kt | 1 - .../impl/viewer/MediaViewerPresenter.kt | 5 --- .../impl/viewer/MediaViewerView.kt | 38 +++++++++---------- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt index 5c07c09da0..674e481b9e 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt @@ -15,7 +15,6 @@ sealed interface MediaViewerEvents { data class SaveOnDisk(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents data class Share(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents - data class RetryLoading(val eventId: EventId) : MediaViewerEvents data class ClearLoadingError(val eventId: EventId) : MediaViewerEvents data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index 7bde6892be..7a93cb009b 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -11,7 +11,6 @@ import android.content.ActivityNotFoundException import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableIntState import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -81,7 +80,6 @@ class MediaViewerPresenter @AssistedInject constructor( val groupedMediaItem by remember { mediaGalleryDataSource.groupedMediaItemsFlow() } .collectAsState(mediaGalleryDataSource.getLastData()) - val loadMediaTrigger: MutableMap = remember { mutableMapOf() } val mediaFile: MutableMap> = remember { mutableMapOf() } val localMedia: MutableMap>> = remember { mutableMapOf() } DisposableEffect(Unit) { @@ -134,9 +132,6 @@ class MediaViewerPresenter @AssistedInject constructor( mediaFile = mediaFile.getOrPut(event.data.eventId) { mutableStateOf(null) }, localMedia = localMedia.getOrPut(event.data.eventId) { mutableStateOf(AsyncData.Uninitialized) }, ) - is MediaViewerEvents.RetryLoading -> { - loadMediaTrigger.getOrPut(event.eventId) { mutableIntStateOf(0) }.intValue++ - } is MediaViewerEvents.ClearLoadingError -> { localMedia.getOrPut(event.eventId) { mutableStateOf(AsyncData.Uninitialized) }.value = AsyncData.Uninitialized } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 31c89e844d..d3c3ff9c8c 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -150,8 +150,8 @@ fun MediaViewerView( AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { Box( modifier = Modifier - .fillMaxSize() - .navigationBarsPadding() + .fillMaxSize() + .navigationBarsPadding() ) { MediaViewerBottomBar( modifier = Modifier.align(Alignment.BottomCenter), @@ -168,8 +168,8 @@ fun MediaViewerView( AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { Box( modifier = Modifier - .fillMaxSize() - .navigationBarsPadding() + .fillMaxSize() + .navigationBarsPadding() ) { when (currentData) { is MediaViewerPageData.Loading -> { @@ -255,9 +255,7 @@ private fun MediaViewerPage( modifier: Modifier = Modifier, ) { fun onRetry() { - data.eventId?.let { - state.eventSink(MediaViewerEvents.RetryLoading(it)) - } + state.eventSink(MediaViewerEvents.LoadMedia(data)) } fun onDismissError() { @@ -289,8 +287,8 @@ private fun MediaViewerPage( Box( modifier = Modifier - .fillMaxSize() - .navigationBarsPadding() + .fillMaxSize() + .navigationBarsPadding() ) { Box(contentAlignment = Alignment.Center) { val zoomableState = rememberZoomableState( @@ -335,8 +333,8 @@ private fun MediaViewerPage( if (showProgress) { LinearProgressIndicator( modifier = Modifier - .fillMaxWidth() - .height(2.dp) + .fillMaxWidth() + .height(2.dp) ) } } @@ -365,8 +363,8 @@ private fun MediaViewerLoadingPage( ) { Box( modifier = Modifier - .fillMaxSize() - .navigationBarsPadding(), + .fillMaxSize() + .navigationBarsPadding(), contentAlignment = Alignment.Center ) { AsyncLoading() @@ -502,11 +500,11 @@ private fun MediaViewerBottomBar( ) { Column( modifier = modifier - .fillMaxWidth() - .background(Color(0x99101317)) - .onSizeChanged { - onHeightChange(it.height) - }, + .fillMaxWidth() + .background(Color(0x99101317)) + .onSizeChanged { + onHeightChange(it.height) + }, ) { if (caption != null) { if (showDivider) { @@ -514,8 +512,8 @@ private fun MediaViewerBottomBar( } Text( modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + .fillMaxWidth() + .padding(16.dp), text = caption, maxLines = 5, overflow = TextOverflow.Ellipsis, From 901ad85ee65c9b5f660605be0703875a41c4145a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 17 Jan 2025 11:49:54 +0100 Subject: [PATCH 04/38] Suppress Detekt false positive (?) --- .../impl/viewer/MediaViewerPresenter.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index 7a93cb009b..a15804d365 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -127,12 +127,18 @@ class MediaViewerPresenter @AssistedInject constructor( fun handleEvents(event: MediaViewerEvents) { when (event) { - is MediaViewerEvents.LoadMedia -> coroutineScope.downloadMedia( - data = event.data, - mediaFile = mediaFile.getOrPut(event.data.eventId) { mutableStateOf(null) }, - localMedia = localMedia.getOrPut(event.data.eventId) { mutableStateOf(AsyncData.Uninitialized) }, - ) + is MediaViewerEvents.LoadMedia -> { + // It's OK to suppress the warning since mediaFile and localMedia are remembered + @Suppress("RememberMissing") + coroutineScope.downloadMedia( + data = event.data, + mediaFile = mediaFile.getOrPut(event.data.eventId) { mutableStateOf(null) }, + localMedia = localMedia.getOrPut(event.data.eventId) { mutableStateOf(AsyncData.Uninitialized) }, + ) + } is MediaViewerEvents.ClearLoadingError -> { + // It's OK to suppress the warning since localMedia is remembered + @Suppress("RememberMissing") localMedia.getOrPut(event.eventId) { mutableStateOf(AsyncData.Uninitialized) }.value = AsyncData.Uninitialized } is MediaViewerEvents.SaveOnDisk -> { From 7397dde87dd277677ae5cc650e929282f295289d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 17 Jan 2025 12:19:42 +0100 Subject: [PATCH 05/38] Add support for files navigation (when coming from the gallery) --- .../features/messages/impl/MessagesFlowNode.kt | 2 ++ .../mediaviewer/api/MediaViewerEntryPoint.kt | 7 +++++++ .../impl/DefaultMediaViewerEntryPoint.kt | 1 + .../impl/gallery/SingleMediaGalleryDataSource.kt | 8 ++++---- .../impl/gallery/root/MediaGalleryRootNode.kt | 10 ++++++++++ .../mediaviewer/impl/viewer/MediaViewerPresenter.kt | 13 ++++++++++--- 6 files changed, 34 insertions(+), 7 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 61f6426978..2b34ec0d1b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -246,6 +246,8 @@ class MessagesFlowNode @AssistedInject constructor( } is NavTarget.MediaViewer -> { val params = MediaViewerEntryPoint.Params( + // TODO When we will be able to load a media timeline from a EventId, change mode here (and use a mixed mode?) + mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, eventId = navTarget.eventId, mediaInfo = navTarget.mediaInfo, mediaSource = navTarget.mediaSource, diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt index d76cd9e2d8..a824fc5540 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt @@ -31,10 +31,17 @@ interface MediaViewerEntryPoint : FeatureEntryPoint { } data class Params( + val mode: MediaViewerMode, val eventId: EventId?, val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, val canShowInfo: Boolean, ) : NodeInputs + + enum class MediaViewerMode { + SingleMedia, + TimelineImagesAndVideos, + TimelineFilesAndAudios, + } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt index 5a2912fdbe..e6482b0c21 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt @@ -42,6 +42,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint val mimeType = MimeTypes.Images return params( MediaViewerEntryPoint.Params( + mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, eventId = null, mediaInfo = MediaInfo( filename = filename, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt index 118f2f4559..29a3fdd550 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt @@ -18,9 +18,8 @@ import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.flowOf -import javax.inject.Inject -class SingleMediaGalleryDataSource @Inject constructor( +class SingleMediaGalleryDataSource( private val data: GroupedMediaItems, ) : MediaGalleryDataSource { override fun start() = Unit @@ -91,9 +90,9 @@ class SingleMediaGalleryDataSource @Inject constructor( } } else -> { + // Always use imageAndVideoItems, in Single mode, this is the data that will be used GroupedMediaItems( - imageAndVideoItems = persistentListOf(), - fileItems = persistentListOf( + imageAndVideoItems = persistentListOf( MediaItem.File( id = UniqueId("dummy"), eventId = params.eventId, @@ -101,6 +100,7 @@ class SingleMediaGalleryDataSource @Inject constructor( mediaSource = params.mediaSource, ) ), + fileItems = persistentListOf(), ) } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt index 357e314d1c..71a4057f4b 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt @@ -60,6 +60,7 @@ class MediaGalleryRootNode @AssistedInject constructor( @Parcelize data class MediaViewer( + val mode: MediaViewerEntryPoint.MediaViewerMode, val eventId: EventId?, val mediaInfo: MediaInfo, val mediaSource: MediaSource, @@ -92,8 +93,16 @@ class MediaGalleryRootNode @AssistedInject constructor( } override fun onItemClick(item: MediaItem.Event) { + val mode = when (item) { + is MediaItem.Audio, + is MediaItem.Voice, + is MediaItem.File -> MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios + is MediaItem.Image, + is MediaItem.Video -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos + } overlay.show( NavTarget.MediaViewer( + mode = mode, eventId = item.eventId(), mediaInfo = item.mediaInfo(), mediaSource = item.mediaSource(), @@ -117,6 +126,7 @@ class MediaGalleryRootNode @AssistedInject constructor( mediaViewerEntryPoint.nodeBuilder(this, buildContext) .params( MediaViewerEntryPoint.Params( + mode = navTarget.mode, eventId = navTarget.eventId, mediaInfo = navTarget.mediaInfo, mediaSource = navTarget.mediaSource, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index a15804d365..c5e29aca12 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -41,6 +41,7 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem import io.element.android.libraries.mediaviewer.impl.gallery.eventId import io.element.android.libraries.mediaviewer.impl.gallery.mediaInfo @@ -71,6 +72,12 @@ class MediaViewerPresenter @AssistedInject constructor( ): MediaViewerPresenter } + private val galleryMode = when (inputs.mode) { + MediaViewerEntryPoint.MediaViewerMode.SingleMedia, + MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images + MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files + } + @Composable override fun present(): MediaViewerState { val coroutineScope = rememberCoroutineScope() @@ -93,10 +100,10 @@ class MediaViewerPresenter @AssistedInject constructor( buildList { val data = groupedMediaItem.dataOrNull() if (data != null) { - if (data.imageAndVideoItems.firstOrNull() is MediaItem.LoadingIndicator) { + if (data.getItems(galleryMode).firstOrNull() is MediaItem.LoadingIndicator) { add(MediaViewerPageData.Loading(Timeline.PaginationDirection.FORWARDS)) } - data.imageAndVideoItems.filterIsInstance().forEach { mediaItem -> + data.getItems(galleryMode).filterIsInstance().forEach { mediaItem -> val eventId = mediaItem.eventId() add( MediaViewerPageData.MediaViewerData( @@ -110,7 +117,7 @@ class MediaViewerPresenter @AssistedInject constructor( ) ) } - if (data.imageAndVideoItems.lastOrNull() is MediaItem.LoadingIndicator) { + if (data.getItems(galleryMode).lastOrNull() is MediaItem.LoadingIndicator) { add(MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS)) } } From 3e9528fa93c8f2e03ec021aae8137ce5f53303d1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 17 Jan 2025 12:22:58 +0100 Subject: [PATCH 06/38] Open in SingleMedia mode when coming from the timeline --- .../libraries/mediaviewer/impl/viewer/MediaViewerNode.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index adadc3ef78..359347fb31 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -53,10 +53,10 @@ class MediaViewerNode @AssistedInject constructor( private val presenter = presenterFactory.create( inputs = inputs, navigator = this, - mediaGalleryDataSource = if (inputs.eventId != null) { - timelineMediaGalleryDataSource - } else { + mediaGalleryDataSource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) { SingleMediaGalleryDataSource.createFrom(inputs) + } else { + timelineMediaGalleryDataSource }, ) From b02c12d92a09f4a5ebee07d046ebdd67fe72373d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 17 Jan 2025 12:43:41 +0100 Subject: [PATCH 07/38] If not displayed, make sure to pause the audio / video --- .../libraries/mediaviewer/impl/local/LocalMediaView.kt | 3 +++ .../mediaviewer/impl/local/audio/MediaAudioView.kt | 9 +++++++++ .../mediaviewer/impl/local/video/MediaVideoView.kt | 10 ++++++++++ .../mediaviewer/impl/viewer/MediaViewerView.kt | 3 +++ 4 files changed, 25 insertions(+) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt index 8e90e84a15..8752b19080 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt @@ -27,6 +27,7 @@ fun LocalMediaView( bottomPaddingInPixels: Int, onClick: () -> Unit, modifier: Modifier = Modifier, + isDisplayed: Boolean = true, localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(), mediaInfo: MediaInfo? = localMedia?.info, ) { @@ -39,6 +40,7 @@ fun LocalMediaView( onClick = onClick, ) mimeType.isMimeTypeVideo() -> MediaVideoView( + isDisplayed = isDisplayed, localMediaViewState = localMediaViewState, bottomPaddingInPixels = bottomPaddingInPixels, localMedia = localMedia, @@ -51,6 +53,7 @@ fun LocalMediaView( onClick = onClick, ) mimeType.isMimeTypeAudio() -> MediaAudioView( + isDisplayed = isDisplayed, localMediaViewState = localMediaViewState, bottomPaddingInPixels = bottomPaddingInPixels, localMedia = localMedia, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt index a1ffc169e3..226aa85234 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt @@ -83,9 +83,11 @@ fun MediaAudioView( localMedia: LocalMedia?, info: MediaInfo?, modifier: Modifier = Modifier, + isDisplayed: Boolean = true, ) { val exoPlayer = rememberExoPlayer() ExoPlayerMediaAudioView( + isDisplayed = isDisplayed, localMediaViewState = localMediaViewState, bottomPaddingInPixels = bottomPaddingInPixels, exoPlayer = exoPlayer, @@ -98,6 +100,7 @@ fun MediaAudioView( @SuppressLint("UnsafeOptInUsageError") @Composable private fun ExoPlayerMediaAudioView( + isDisplayed: Boolean, localMediaViewState: LocalMediaViewState, bottomPaddingInPixels: Int, exoPlayer: ExoPlayer, @@ -176,6 +179,12 @@ private fun ExoPlayerMediaAudioView( ) } } + LaunchedEffect(isDisplayed) { + // If not displayed, make sure to pause the audio + if (!isDisplayed) { + exoPlayer.pause() + } + } if (localMedia?.uri != null) { LaunchedEffect(localMedia.uri) { val mediaItem = MediaItem.fromUri(localMedia.uri) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt index 5dd7427e1a..934f7e8352 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt @@ -57,6 +57,7 @@ import kotlin.time.Duration.Companion.seconds @SuppressLint("UnsafeOptInUsageError") @Composable fun MediaVideoView( + isDisplayed: Boolean, localMediaViewState: LocalMediaViewState, bottomPaddingInPixels: Int, localMedia: LocalMedia?, @@ -64,6 +65,7 @@ fun MediaVideoView( ) { val exoPlayer = rememberExoPlayer() ExoPlayerMediaVideoView( + isDisplayed = isDisplayed, localMediaViewState = localMediaViewState, bottomPaddingInPixels = bottomPaddingInPixels, exoPlayer = exoPlayer, @@ -75,6 +77,7 @@ fun MediaVideoView( @SuppressLint("UnsafeOptInUsageError") @Composable private fun ExoPlayerMediaVideoView( + isDisplayed: Boolean, localMediaViewState: LocalMediaViewState, bottomPaddingInPixels: Int, exoPlayer: ExoPlayer, @@ -161,6 +164,12 @@ private fun ExoPlayerMediaVideoView( ) } } + LaunchedEffect(isDisplayed) { + // If not displayed, make sure to pause the video + if (!isDisplayed) { + exoPlayer.pause() + } + } if (localMedia?.uri != null) { LaunchedEffect(localMedia.uri) { val mediaItem = MediaItem.fromUri(localMedia.uri) @@ -245,6 +254,7 @@ private fun ExoPlayerMediaVideoView( @Composable internal fun MediaVideoViewPreview() = ElementPreview { MediaVideoView( + isDisplayed = true, modifier = Modifier.fillMaxSize(), bottomPaddingInPixels = 0, localMediaViewState = rememberLocalMediaViewState(), diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index d3c3ff9c8c..a30f78dfce 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -135,6 +135,7 @@ fun MediaViewerView( state.eventSink(MediaViewerEvents.LoadMedia(dataForPage)) } MediaViewerPage( + isDisplayed = page == pagerState.settledPage, showOverlay = showOverlay, bottomPaddingInPixels = bottomPaddingInPixels, data = dataForPage, @@ -246,6 +247,7 @@ fun MediaViewerView( @Composable private fun MediaViewerPage( + isDisplayed: Boolean, showOverlay: Boolean, bottomPaddingInPixels: Int, state: MediaViewerState, @@ -307,6 +309,7 @@ private fun MediaViewerPage( LocalMediaView( modifier = Modifier.fillMaxSize(), + isDisplayed = isDisplayed, bottomPaddingInPixels = bottomPaddingInPixels, localMediaViewState = localMediaViewState, localMedia = data.downloadedMedia.dataOrNull(), From 24a2458e4a943444290aa1aaa8486dcaad51569d Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 20 Jan 2025 22:35:59 +0100 Subject: [PATCH 08/38] media viewer : create MediaViewerDataSource --- .../impl/viewer/MediaViewerDataSource.kt | 151 ++++++++++++++++++ .../impl/viewer/MediaViewerEvents.kt | 2 +- .../impl/viewer/MediaViewerNode.kt | 31 +++- .../impl/viewer/MediaViewerPresenter.kt | 142 +++------------- .../impl/viewer/MediaViewerState.kt | 6 +- .../impl/viewer/MediaViewerStateProvider.kt | 6 +- .../impl/viewer/MediaViewerView.kt | 91 ++++++----- 7 files changed, 255 insertions(+), 174 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt new file mode 100644 index 0000000000..2b86ee841b --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2025 New Vector 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 androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +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.timeline.Timeline +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem +import io.element.android.libraries.mediaviewer.impl.gallery.eventId +import io.element.android.libraries.mediaviewer.impl.gallery.mediaInfo +import io.element.android.libraries.mediaviewer.impl.gallery.mediaSource +import io.element.android.libraries.mediaviewer.impl.gallery.thumbnailSource +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import timber.log.Timber + +class MediaViewerDataSource( + private val galleryMode: MediaGalleryMode, + private val dispatcher: CoroutineDispatcher, + private val galleryDataSource: MediaGalleryDataSource, + private val mediaLoader: MatrixMediaLoader, + private val localMediaFactory: LocalMediaFactory, +) { + + // List of media files that are currently being loaded + private val mediaFiles: MutableList = mutableListOf() + + // Map of sourceUrl to local media state + private val localMediaStates: MutableMap>> = + mutableMapOf() + + fun setup() { + galleryDataSource.start() + } + + fun dispose() { + mediaFiles.forEach { it.close() } + mediaFiles.clear() + localMediaStates.clear() + } + + fun initialPageIndex(eventId: EventId?): Int { + if (eventId == null) { + return 0 + } + val mediaItems = + galleryDataSource.getLastData().dataOrNull()?.getItems(galleryMode).orEmpty() + val pageList = buildMediaViewerPageList(mediaItems) + return pageList.indexOfFirst { data -> + when (data) { + is MediaViewerPageData.MediaViewerData -> data.eventId == eventId + else -> false + } + } + .takeIf { it != -1 } + ?: 0 + } + + fun dataFlow(): Flow> { + return galleryDataSource.groupedMediaItemsFlow() + .map { + val groupedItems = it.dataOrNull()?.getItems(galleryMode).orEmpty() + withContext(dispatcher) { + buildMediaViewerPageList(groupedItems) + } + } + } + + private fun buildMediaViewerPageList(groupedItems: List) = buildList { + groupedItems.forEach { mediaItem -> + when (mediaItem) { + is MediaItem.DateSeparator -> Unit + is MediaItem.Event -> { + val sourceUrl = mediaItem.mediaSource().url + val localMedia = localMediaStates.getOrPut(sourceUrl) { + mutableStateOf(AsyncData.Uninitialized) + } + add( + MediaViewerPageData.MediaViewerData( + eventId = mediaItem.eventId(), + mediaInfo = mediaItem.mediaInfo(), + mediaSource = mediaItem.mediaSource(), + thumbnailSource = mediaItem.thumbnailSource(), + downloadedMedia = localMedia, + ) + ) + } + is MediaItem.LoadingIndicator -> add( + MediaViewerPageData.Loading(mediaItem.direction) + ) + } + } + if (isEmpty()) { + MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS) + } + }.toPersistentList() + + suspend fun loadMedia(data: MediaViewerPageData.MediaViewerData) { + Timber.d("loadMedia for ${data.eventId}") + val localMediaState = localMediaStates.getOrPut(data.mediaSource.url) { + mutableStateOf(AsyncData.Uninitialized) + } + localMediaState.value = AsyncData.Loading() + mediaLoader + .downloadMediaFile( + source = data.mediaSource, + mimeType = data.mediaInfo.mimeType, + filename = data.mediaInfo.filename + ) + .onSuccess { mediaFile -> + mediaFiles.add(mediaFile) + } + .mapCatching { mediaFile -> + localMediaFactory.createFromMediaFile( + mediaFile = mediaFile, + mediaInfo = data.mediaInfo + ) + } + .onSuccess { + localMediaState.value = AsyncData.Success(it) + } + .onFailure { + localMediaState.value = AsyncData.Failure(it) + } + } + + fun clearLoadingError(data: MediaViewerPageData.MediaViewerData) { + localMediaStates[data.mediaSource.url]?.value = AsyncData.Uninitialized + } + + suspend fun loadMore(direction: Timeline.PaginationDirection) { + galleryDataSource.loadMore(direction) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt index 674e481b9e..708c423d36 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt @@ -15,7 +15,7 @@ sealed interface MediaViewerEvents { data class SaveOnDisk(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents data class Share(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents - data class ClearLoadingError(val eventId: EventId) : MediaViewerEvents + data class ClearLoadingError(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents data class ConfirmDelete( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index 359347fb31..05a5fb759c 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -18,9 +18,13 @@ import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.compound.theme.ForcedDarkElementTheme import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode import io.element.android.libraries.mediaviewer.impl.gallery.SingleMediaGalleryDataSource import io.element.android.libraries.mediaviewer.impl.gallery.TimelineMediaGalleryDataSource @@ -30,6 +34,9 @@ class MediaViewerNode @AssistedInject constructor( @Assisted plugins: List, presenterFactory: MediaViewerPresenter.Factory, timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource, + mediaLoader: MatrixMediaLoader, + localMediaFactory: LocalMediaFactory, + coroutineDispatchers: CoroutineDispatchers, ) : Node(buildContext, plugins = plugins), MediaViewerNavigator { private val inputs = inputs() @@ -50,14 +57,28 @@ class MediaViewerNode @AssistedInject constructor( onDone() } + private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) { + SingleMediaGalleryDataSource.createFrom(inputs) + } else { + timelineMediaGalleryDataSource + } + + private val galleryMode = when (inputs.mode) { + MediaViewerEntryPoint.MediaViewerMode.SingleMedia, + MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images + MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files + } + private val presenter = presenterFactory.create( inputs = inputs, navigator = this, - mediaGalleryDataSource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) { - SingleMediaGalleryDataSource.createFrom(inputs) - } else { - timelineMediaGalleryDataSource - }, + dataSource = MediaViewerDataSource( + dispatcher = coroutineDispatchers.computation, + galleryMode = galleryMode, + galleryDataSource = mediaGallerySource, + mediaLoader = mediaLoader, + localMediaFactory = localMediaFactory + ) ) @Composable diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index c5e29aca12..176288b537 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -10,10 +10,7 @@ package io.element.android.libraries.mediaviewer.impl.viewer import android.content.ActivityNotFoundException import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -29,26 +26,17 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState import io.element.android.libraries.matrix.api.core.EventId -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.room.MatrixRoom import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn -import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.local.LocalMedia -import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState -import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryDataSource -import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode -import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem -import io.element.android.libraries.mediaviewer.impl.gallery.eventId -import io.element.android.libraries.mediaviewer.impl.gallery.mediaInfo -import io.element.android.libraries.mediaviewer.impl.gallery.mediaSource -import io.element.android.libraries.mediaviewer.impl.gallery.thumbnailSource import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import io.element.android.libraries.androidutils.R as UtilsR @@ -56,10 +44,8 @@ import io.element.android.libraries.androidutils.R as UtilsR class MediaViewerPresenter @AssistedInject constructor( @Assisted private val inputs: MediaViewerEntryPoint.Params, @Assisted private val navigator: MediaViewerNavigator, - @Assisted private val mediaGalleryDataSource: MediaGalleryDataSource, + @Assisted private val dataSource: MediaViewerDataSource, private val room: MatrixRoom, - private val localMediaFactory: LocalMediaFactory, - private val mediaLoader: MatrixMediaLoader, private val localMediaActions: LocalMediaActions, private val snackbarDispatcher: SnackbarDispatcher, ) : Presenter { @@ -68,97 +54,46 @@ class MediaViewerPresenter @AssistedInject constructor( fun create( inputs: MediaViewerEntryPoint.Params, navigator: MediaViewerNavigator, - mediaGalleryDataSource: MediaGalleryDataSource, + dataSource: MediaViewerDataSource, ): MediaViewerPresenter } - private val galleryMode = when (inputs.mode) { - MediaViewerEntryPoint.MediaViewerMode.SingleMedia, - MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images - MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files - } - @Composable override fun present(): MediaViewerState { val coroutineScope = rememberCoroutineScope() - LaunchedEffect(Unit) { - mediaGalleryDataSource.start() - } - val groupedMediaItem by remember { mediaGalleryDataSource.groupedMediaItemsFlow() } - .collectAsState(mediaGalleryDataSource.getLastData()) - - val mediaFile: MutableMap> = remember { mutableMapOf() } - val localMedia: MutableMap>> = remember { mutableMapOf() } - DisposableEffect(Unit) { - onDispose { - mediaFile.values.forEach { it.value?.close() } - } - } - - val data: List by remember { - derivedStateOf { - buildList { - val data = groupedMediaItem.dataOrNull() - if (data != null) { - if (data.getItems(galleryMode).firstOrNull() is MediaItem.LoadingIndicator) { - add(MediaViewerPageData.Loading(Timeline.PaginationDirection.FORWARDS)) - } - data.getItems(galleryMode).filterIsInstance().forEach { mediaItem -> - val eventId = mediaItem.eventId() - add( - MediaViewerPageData.MediaViewerData( - eventId = eventId, - mediaInfo = mediaItem.mediaInfo(), - mediaSource = mediaItem.mediaSource(), - thumbnailSource = mediaItem.thumbnailSource(), - downloadedMedia = localMedia.getOrPut(eventId) { - mutableStateOf(AsyncData.Uninitialized) - }.value, - ) - ) - } - if (data.getItems(galleryMode).lastOrNull() is MediaItem.LoadingIndicator) { - add(MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS)) - } - } - if (isEmpty()) { - add(MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS)) - } - } - } - } - var currentIndex by remember { mutableIntStateOf(searchIndex(data, inputs.eventId)) } + val data: ImmutableList by dataSource.dataFlow().collectAsState(persistentListOf()) + var currentIndex by remember { mutableIntStateOf(dataSource.initialPageIndex(inputs.eventId)) } val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() - localMediaActions.Configure() + var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } + DisposableEffect(Unit) { + dataSource.setup() + onDispose { + dataSource.dispose() + } + } + localMediaActions.Configure() + fun handleEvents(event: MediaViewerEvents) { when (event) { is MediaViewerEvents.LoadMedia -> { - // It's OK to suppress the warning since mediaFile and localMedia are remembered - @Suppress("RememberMissing") - coroutineScope.downloadMedia( - data = event.data, - mediaFile = mediaFile.getOrPut(event.data.eventId) { mutableStateOf(null) }, - localMedia = localMedia.getOrPut(event.data.eventId) { mutableStateOf(AsyncData.Uninitialized) }, - ) + coroutineScope.downloadMedia(data = event.data) } is MediaViewerEvents.ClearLoadingError -> { - // It's OK to suppress the warning since localMedia is remembered - @Suppress("RememberMissing") - localMedia.getOrPut(event.eventId) { mutableStateOf(AsyncData.Uninitialized) }.value = AsyncData.Uninitialized + dataSource.clearLoadingError(event.data) } is MediaViewerEvents.SaveOnDisk -> { mediaBottomSheetState = MediaBottomSheetState.Hidden - coroutineScope.saveOnDisk(event.data.downloadedMedia) + coroutineScope.saveOnDisk(event.data.downloadedMedia.value) } is MediaViewerEvents.Share -> { mediaBottomSheetState = MediaBottomSheetState.Hidden - coroutineScope.share(event.data.downloadedMedia) + coroutineScope.share(event.data.downloadedMedia.value) } is MediaViewerEvents.OpenWith -> { mediaBottomSheetState = MediaBottomSheetState.Hidden - coroutineScope.open(event.data.downloadedMedia) + coroutineScope.open(event.data.downloadedMedia.value) } is MediaViewerEvents.Delete -> { mediaBottomSheetState = MediaBottomSheetState.Hidden @@ -194,7 +129,7 @@ class MediaViewerPresenter @AssistedInject constructor( currentIndex = event.index } is MediaViewerEvents.LoadMore -> coroutineScope.launch { - mediaGalleryDataSource.loadMore(event.direction) + dataSource.loadMore(event.direction) } } } @@ -211,30 +146,8 @@ class MediaViewerPresenter @AssistedInject constructor( private fun CoroutineScope.downloadMedia( data: MediaViewerPageData.MediaViewerData, - mediaFile: MutableState, - localMedia: MutableState>, ) = launch { - localMedia.value = AsyncData.Loading() - mediaLoader.downloadMediaFile( - source = data.mediaSource, - mimeType = data.mediaInfo.mimeType, - filename = data.mediaInfo.filename - ) - .onSuccess { - mediaFile.value = it - } - .mapCatching { mediaFile -> - localMediaFactory.createFromMediaFile( - mediaFile = mediaFile, - mediaInfo = data.mediaInfo - ) - } - .onSuccess { - localMedia.value = AsyncData.Success(it) - } - .onFailure { - localMedia.value = AsyncData.Failure(it) - } + dataSource.loadMedia(data) } private fun CoroutineScope.saveOnDisk(localMedia: AsyncData) = launch { @@ -290,14 +203,3 @@ class MediaViewerPresenter @AssistedInject constructor( } } } - -private fun searchIndex(data: List, eventId: EventId?): Int { - if (eventId == null) { - return 0 - } - return data.indexOfFirst { - (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId - } - .takeIf { it != -1 } - ?: 0 -} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt index 7ad1bc9982..e2b8170a78 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.mediaviewer.impl.viewer +import androidx.compose.runtime.State import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.EventId @@ -15,9 +16,10 @@ import io.element.android.libraries.matrix.api.timeline.Timeline 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 +import kotlinx.collections.immutable.ImmutableList data class MediaViewerState( - val listData: List, + val listData: ImmutableList, val currentIndex: Int, val snackbarMessage: SnackbarMessage?, val canShowInfo: Boolean, @@ -35,6 +37,6 @@ sealed interface MediaViewerPageData { val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, - val downloadedMedia: AsyncData, + val downloadedMedia: State>, ) : MediaViewerPageData } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt index 6ba6bb74ae..d37003ed60 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.mediaviewer.impl.viewer import android.net.Uri +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.media.aWaveForm @@ -22,6 +23,7 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirmationState import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState +import kotlinx.collections.immutable.toPersistentList open class MediaViewerStateProvider : PreviewParameterProvider { override val values: Sequence @@ -158,7 +160,7 @@ fun aMediaViewerPageData( mediaInfo = mediaInfo, mediaSource = mediaSource, thumbnailSource = null, - downloadedMedia = downloadedMedia, + downloadedMedia = mutableStateOf(downloadedMedia), ) fun aMediaViewerState( @@ -168,7 +170,7 @@ fun aMediaViewerState( mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden, eventSink: (MediaViewerEvents) -> Unit = {}, ) = MediaViewerState( - listData = listData, + listData = listData.toPersistentList(), currentIndex = currentIndex, snackbarMessage = null, canShowInfo = canShowInfo, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index a30f78dfce..3c2b986c98 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -85,6 +85,7 @@ import me.saket.telephoto.flick.FlickToDismissState import me.saket.telephoto.flick.rememberFlickToDismissState import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.rememberZoomableState +import timber.log.Timber import kotlin.time.Duration @Composable @@ -98,7 +99,7 @@ fun MediaViewerView( val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0 var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) } - val currentData = state.listData[state.currentIndex] + val currentData = state.listData.getOrNull(state.currentIndex) BackHandler { onBackClick() } Scaffold( modifier, @@ -113,6 +114,9 @@ fun MediaViewerView( state.eventSink(MediaViewerEvents.OnNavigateTo(page)) } } + LaunchedEffect(state.listData) { + Timber.d("MediaViewerView: state.listData: ${state.listData}") + } HorizontalPager( state = pagerState, modifier = Modifier, @@ -139,10 +143,15 @@ fun MediaViewerView( showOverlay = showOverlay, bottomPaddingInPixels = bottomPaddingInPixels, data = dataForPage, - state = state, onDismiss = { onBackClick() }, + onRetry = { + state.eventSink(MediaViewerEvents.LoadMedia(dataForPage)) + }, + onDismissError = { + state.eventSink(MediaViewerEvents.ClearLoadingError(dataForPage)) + }, onShowOverlayChange = { showOverlay = it } @@ -151,8 +160,8 @@ fun MediaViewerView( AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { Box( modifier = Modifier - .fillMaxSize() - .navigationBarsPadding() + .fillMaxSize() + .navigationBarsPadding() ) { MediaViewerBottomBar( modifier = Modifier.align(Alignment.BottomCenter), @@ -169,19 +178,10 @@ fun MediaViewerView( AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { Box( modifier = Modifier - .fillMaxSize() - .navigationBarsPadding() + .fillMaxSize() + .navigationBarsPadding() ) { when (currentData) { - is MediaViewerPageData.Loading -> { - TopAppBar( - title = {}, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent.copy(0.6f), - ), - navigationIcon = { BackButton(onClick = onBackClick) }, - ) - } is MediaViewerPageData.MediaViewerData -> { MediaViewerTopBar( data = currentData, @@ -193,6 +193,15 @@ fun MediaViewerView( eventSink = state.eventSink ) } + else -> { + TopAppBar( + title = {}, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent.copy(0.6f), + ), + navigationIcon = { BackButton(onClick = onBackClick) }, + ) + } } } } @@ -250,21 +259,13 @@ private fun MediaViewerPage( isDisplayed: Boolean, showOverlay: Boolean, bottomPaddingInPixels: Int, - state: MediaViewerState, data: MediaViewerPageData.MediaViewerData, onDismiss: () -> Unit, + onRetry: () -> Unit, + onDismissError: () -> Unit, onShowOverlayChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { - fun onRetry() { - state.eventSink(MediaViewerEvents.LoadMedia(data)) - } - - fun onDismissError() { - data.eventId?.let { - state.eventSink(MediaViewerEvents.ClearLoadingError(it)) - } - } val currentShowOverlay by rememberUpdatedState(showOverlay) val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange) @@ -285,12 +286,13 @@ private fun MediaViewerPage( state = flickState, modifier = modifier.background(backgroundColorFor(flickState)) ) { - val showProgress = rememberShowProgress(data.downloadedMedia) + val downloadedMedia by data.downloadedMedia + val showProgress = rememberShowProgress(downloadedMedia) Box( modifier = Modifier - .fillMaxSize() - .navigationBarsPadding() + .fillMaxSize() + .navigationBarsPadding() ) { Box(contentAlignment = Alignment.Center) { val zoomableState = rememberZoomableState( @@ -299,7 +301,7 @@ private fun MediaViewerPage( val localMediaViewState = rememberLocalMediaViewState(zoomableState) val showThumbnail = !localMediaViewState.isReady val playableState = localMediaViewState.playableState - val showError = data.downloadedMedia is AsyncData.Failure + val showError = downloadedMedia.isFailure() LaunchedEffect(playableState) { if (playableState is PlayableState.Playable) { @@ -312,7 +314,7 @@ private fun MediaViewerPage( isDisplayed = isDisplayed, bottomPaddingInPixels = bottomPaddingInPixels, localMediaViewState = localMediaViewState, - localMedia = data.downloadedMedia.dataOrNull(), + localMedia = downloadedMedia.dataOrNull(), mediaInfo = data.mediaInfo, onClick = { if (playableState is PlayableState.NotPlayable) { @@ -328,16 +330,16 @@ private fun MediaViewerPage( if (showError) { ErrorView( errorMessage = stringResource(id = CommonStrings.error_unknown), - onRetry = ::onRetry, - onDismiss = ::onDismissError + onRetry = onRetry, + onDismiss = onDismissError ) } } if (showProgress) { LinearProgressIndicator( modifier = Modifier - .fillMaxWidth() - .height(2.dp) + .fillMaxWidth() + .height(2.dp) ) } } @@ -366,8 +368,8 @@ private fun MediaViewerLoadingPage( ) { Box( modifier = Modifier - .fillMaxSize() - .navigationBarsPadding(), + .fillMaxSize() + .navigationBarsPadding(), contentAlignment = Alignment.Center ) { AsyncLoading() @@ -429,7 +431,8 @@ private fun MediaViewerTopBar( onInfoClick: () -> Unit, eventSink: (MediaViewerEvents) -> Unit, ) { - val actionsEnabled = data.downloadedMedia is AsyncData.Success + val downloadedMedia by data.downloadedMedia + val actionsEnabled = downloadedMedia.isSuccess() val mimeType = data.mediaInfo.mimeType val senderName = data.mediaInfo.senderName val dateSent = data.mediaInfo.dateSent @@ -503,11 +506,11 @@ private fun MediaViewerBottomBar( ) { Column( modifier = modifier - .fillMaxWidth() - .background(Color(0x99101317)) - .onSizeChanged { - onHeightChange(it.height) - }, + .fillMaxWidth() + .background(Color(0x99101317)) + .onSizeChanged { + onHeightChange(it.height) + }, ) { if (caption != null) { if (showDivider) { @@ -515,8 +518,8 @@ private fun MediaViewerBottomBar( } Text( modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + .fillMaxWidth() + .padding(16.dp), text = caption, maxLines = 5, overflow = TextOverflow.Ellipsis, From 03523c9567a083393395a0caaef4451393abbbe9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 17 Jan 2025 16:07:36 +0100 Subject: [PATCH 09/38] Provide duration --- .../messages/impl/MessagesFlowNode.kt | 3 + .../model/event/TimelineItemEventContent.kt | 10 +++ .../dateformatter/api/DurationFormatter.kt | 3 + .../libraries/mediaviewer/api/MediaInfo.kt | 10 +++ .../impl/DefaultMediaViewerEntryPoint.kt | 1 + .../impl/gallery/EventItemFactory.kt | 9 +- .../mediaviewer/impl/gallery/MediaItem.kt | 4 - .../gallery/SingleMediaGalleryDataSource.kt | 90 +++++++------------ .../impl/gallery/ui/MediaItemVideoProvider.kt | 5 +- .../impl/gallery/ui/MediaItemVoiceProvider.kt | 5 +- .../impl/gallery/ui/VideoItemView.kt | 4 +- .../impl/gallery/ui/VoiceItemView.kt | 4 +- .../impl/local/AndroidLocalMediaFactory.kt | 4 + 13 files changed, 79 insertions(+), 73 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 2b34ec0d1b..f9d9bbf27f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -48,6 +48,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +import io.element.android.features.messages.impl.timeline.model.event.duration import io.element.android.features.poll.api.create.CreatePollEntryPoint import io.element.android.features.poll.api.create.CreatePollMode import io.element.android.libraries.architecture.BackstackWithOverlayBox @@ -58,6 +59,7 @@ import io.element.android.libraries.architecture.overlay.operation.hide import io.element.android.libraries.architecture.overlay.operation.show 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.di.RoomScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.EventId @@ -449,6 +451,7 @@ class MessagesFlowNode @AssistedInject constructor( mode = DateFormatterMode.Full, ), waveform = (content as? TimelineItemVoiceContent)?.waveform, + duration = content.duration()?.toHumanReadableDuration(), ), mediaSource = mediaSource, thumbnailSource = thumbnailSource, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index aeb847b80a..9eda2e7253 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.model.event import androidx.compose.runtime.Immutable import io.element.android.libraries.matrix.api.media.MediaSource +import kotlin.time.Duration @Immutable sealed interface TimelineItemEventContent { @@ -90,3 +91,12 @@ fun TimelineItemEventContent.isEdited(): Boolean = when (this) { is TimelineItemEventMutableContent -> isEdited else -> false } + +fun TimelineItemEventContentWithAttachment.duration(): Duration? { + return when (this) { + is TimelineItemAudioContent -> duration + is TimelineItemVideoContent -> duration + is TimelineItemVoiceContent -> duration + else -> null + } +} diff --git a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt index 63e30d4928..04c6df1d02 100644 --- a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt +++ b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.dateformatter.api import java.util.Locale +import kotlin.time.Duration /** * Convert milliseconds to human readable duration. @@ -38,3 +39,5 @@ fun Long.toHumanReadableDuration(): String { String.format(Locale.US, "%d:%02d", minutes, seconds) } } + +fun Duration.toHumanReadableDuration() = inWholeMilliseconds.toHumanReadableDuration() diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt index 374bf701a6..7426251ca0 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt @@ -25,6 +25,7 @@ data class MediaInfo( val dateSent: String?, val dateSentFull: String?, val waveform: List?, + val duration: String?, ) : Parcelable fun anImageMediaInfo( @@ -45,6 +46,7 @@ fun anImageMediaInfo( dateSent = dateSent, dateSentFull = dateSentFull, waveform = null, + duration = null, ) fun aVideoMediaInfo( @@ -52,6 +54,7 @@ fun aVideoMediaInfo( senderName: String? = null, dateSent: String? = null, dateSentFull: String? = null, + duration: String? = null, ): MediaInfo = MediaInfo( filename = "a video file.mp4", caption = caption, @@ -64,6 +67,7 @@ fun aVideoMediaInfo( dateSent = dateSent, dateSentFull = dateSentFull, waveform = null, + duration = duration, ) fun aPdfMediaInfo( @@ -84,6 +88,7 @@ fun aPdfMediaInfo( dateSent = dateSent, dateSentFull = dateSentFull, waveform = null, + duration = null, ) fun anApkMediaInfo( @@ -103,6 +108,7 @@ fun anApkMediaInfo( dateSent = dateSent, dateSentFull = dateSentFull, waveform = null, + duration = null, ) fun anAudioMediaInfo( @@ -112,6 +118,7 @@ fun anAudioMediaInfo( dateSent: String? = null, dateSentFull: String? = null, waveForm: List? = null, + duration: String? = null, ): MediaInfo = MediaInfo( filename = filename, caption = caption, @@ -124,6 +131,7 @@ fun anAudioMediaInfo( dateSent = dateSent, dateSentFull = dateSentFull, waveform = waveForm, + duration = duration, ) fun aVoiceMediaInfo( @@ -133,6 +141,7 @@ fun aVoiceMediaInfo( dateSent: String? = null, dateSentFull: String? = null, waveForm: List? = null, + duration: String? = null, ): MediaInfo = MediaInfo( filename = filename, caption = caption, @@ -145,4 +154,5 @@ fun aVoiceMediaInfo( dateSent = dateSent, dateSentFull = dateSentFull, waveform = waveForm, + duration = duration, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt index e6482b0c21..64cd9093a2 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt @@ -56,6 +56,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint dateSent = null, dateSentFull = null, waveform = null, + duration = null, ), mediaSource = MediaSource(url = avatarUrl), thumbnailSource = null, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt index e8e242d2e0..75fa590b86 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt @@ -102,6 +102,7 @@ class EventItemFactory @Inject constructor( dateSent = dateSent, dateSentFull = dateSentFull, waveform = null, + duration = null, ), mediaSource = type.source, ) @@ -120,6 +121,7 @@ class EventItemFactory @Inject constructor( dateSent = dateSent, dateSentFull = dateSentFull, waveform = null, + duration = null, ), mediaSource = type.source, ) @@ -138,6 +140,7 @@ class EventItemFactory @Inject constructor( dateSent = dateSent, dateSentFull = dateSentFull, waveform = null, + duration = null, ), mediaSource = type.source, thumbnailSource = null, @@ -157,6 +160,7 @@ class EventItemFactory @Inject constructor( dateSent = dateSent, dateSentFull = dateSentFull, waveform = null, + duration = null, ), mediaSource = type.source, thumbnailSource = null, @@ -176,10 +180,10 @@ class EventItemFactory @Inject constructor( dateSent = dateSent, dateSentFull = dateSentFull, waveform = null, + duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(), ), mediaSource = type.source, thumbnailSource = type.info?.thumbnailSource, - duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(), ) is VoiceMessageType -> MediaItem.Voice( id = currentTimelineItem.uniqueId, @@ -196,10 +200,9 @@ class EventItemFactory @Inject constructor( dateSent = dateSent, dateSentFull = dateSentFull, waveform = type.details?.waveform.orEmpty(), + duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(), ), mediaSource = type.source, - duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(), - waveform = type.details?.waveform ?: persistentListOf(), ) } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt index 2f2054ebe0..e1bd4d779a 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt @@ -13,7 +13,6 @@ 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.mediaviewer.api.MediaInfo -import kotlinx.collections.immutable.ImmutableList sealed interface MediaItem { data class DateSeparator( @@ -46,7 +45,6 @@ sealed interface MediaItem { val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, - val duration: String?, ) : Event { val thumbnailMediaRequestData: MediaRequestData get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100)) @@ -64,8 +62,6 @@ sealed interface MediaItem { val eventId: EventId?, val mediaInfo: MediaInfo, val mediaSource: MediaSource, - val duration: String?, - val waveform: ImmutableList, ) : Event data class File( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt index 29a3fdd550..8e6b708a7f 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt @@ -16,7 +16,6 @@ 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.MediaViewerEntryPoint import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.flowOf class SingleMediaGalleryDataSource( @@ -32,77 +31,54 @@ class SingleMediaGalleryDataSource( fun createFrom(params: MediaViewerEntryPoint.Params) = SingleMediaGalleryDataSource( data = when { params.mediaInfo.mimeType.isMimeTypeImage() -> { - GroupedMediaItems( - imageAndVideoItems = persistentListOf( - MediaItem.Image( - id = UniqueId("dummy"), - eventId = params.eventId, - mediaInfo = params.mediaInfo, - mediaSource = params.mediaSource, - thumbnailSource = params.thumbnailSource, - ) - ), - fileItems = persistentListOf(), + MediaItem.Image( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + thumbnailSource = params.thumbnailSource, ) } params.mediaInfo.mimeType.isMimeTypeVideo() -> { - GroupedMediaItems( - imageAndVideoItems = persistentListOf( - MediaItem.Video( - id = UniqueId("dummy"), - eventId = params.eventId, - mediaInfo = params.mediaInfo, - mediaSource = params.mediaSource, - thumbnailSource = params.thumbnailSource, - duration = "TODO", // TODO Duration - ) - ), - fileItems = persistentListOf(), + MediaItem.Video( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + thumbnailSource = params.thumbnailSource, ) } params.mediaInfo.mimeType.isMimeTypeAudio() -> { if (params.mediaInfo.waveform == null) { - GroupedMediaItems( - imageAndVideoItems = persistentListOf( - MediaItem.Audio( - id = UniqueId("dummy"), - eventId = params.eventId, - mediaInfo = params.mediaInfo, - mediaSource = params.mediaSource, - ) - ), - fileItems = persistentListOf(), + MediaItem.Audio( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, ) } else { - GroupedMediaItems( - imageAndVideoItems = persistentListOf( - MediaItem.Voice( - id = UniqueId("dummy"), - eventId = params.eventId, - mediaInfo = params.mediaInfo, - mediaSource = params.mediaSource, - duration = "TODO", // TODO Duration - waveform = params.mediaInfo.waveform.orEmpty().toImmutableList(), - ) - ), - fileItems = persistentListOf(), + MediaItem.Voice( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, ) } } else -> { - // Always use imageAndVideoItems, in Single mode, this is the data that will be used - GroupedMediaItems( - imageAndVideoItems = persistentListOf( - MediaItem.File( - id = UniqueId("dummy"), - eventId = params.eventId, - mediaInfo = params.mediaInfo, - mediaSource = params.mediaSource, - ) - ), - fileItems = persistentListOf(), + MediaItem.File( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, ) } + }.let { mediaItem -> + GroupedMediaItems( + // Always use imageAndVideoItems, in Single mode, this is the data that will be used + imageAndVideoItems = persistentListOf(mediaItem), + fileItems = persistentListOf(), + ) } ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt index 47642d4837..8e59b925b7 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt @@ -31,9 +31,10 @@ fun aMediaItemVideo( return MediaItem.Video( id = id, eventId = null, - mediaInfo = aVideoMediaInfo(), + mediaInfo = aVideoMediaInfo( + duration = duration + ), mediaSource = mediaSource, thumbnailSource = null, - duration = duration, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVoiceProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVoiceProvider.kt index c84a74c7a1..43e04491de 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVoiceProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVoiceProvider.kt @@ -14,7 +14,6 @@ import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem -import kotlinx.collections.immutable.toImmutableList class MediaItemVoiceProvider : PreviewParameterProvider { override val values: Sequence @@ -46,9 +45,9 @@ fun aMediaItemVoice( mediaInfo = aVoiceMediaInfo( filename = filename, caption = caption, + duration = duration, + waveForm = waveform, ), mediaSource = MediaSource(""), - duration = duration, - waveform = waveform.toImmutableList(), ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt index 0adeabd20f..6b394e7c55 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt @@ -101,10 +101,10 @@ private fun VideoInfoRow( imageVector = CompoundIcons.VideoCallSolid(), contentDescription = null ) - if (video.duration != null) { + video.mediaInfo.duration?.let { duration -> Spacer(Modifier.weight(1f)) Text( - text = video.duration, + text = duration, style = ElementTheme.typography.fontBodySmMedium, color = ElementTheme.colors.textPrimary, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt index a5f55875bd..d34555e175 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt @@ -115,7 +115,7 @@ private fun VoiceInfoRow( } Spacer(Modifier.width(8.dp)) Text( - text = if (state.progress > 0f) state.time else voice.duration ?: state.time, + text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time, color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdMedium, maxLines = 1, @@ -128,7 +128,7 @@ private fun VoiceInfoRow( .height(34.dp), showCursor = state.showCursor, playbackProgress = state.progress, - waveform = voice.waveform.toPersistentList(), + waveform = voice.mediaInfo.waveform.orEmpty().toPersistentList(), onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) }, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt index 297ee4dac2..b7ae566ab1 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt @@ -48,6 +48,7 @@ class AndroidLocalMediaFactory @Inject constructor( dateSent = mediaInfo.dateSent, dateSentFull = mediaInfo.dateSentFull, waveform = mediaInfo.waveform, + duration = mediaInfo.duration, ) override fun createFromUri( @@ -67,6 +68,7 @@ class AndroidLocalMediaFactory @Inject constructor( dateSent = null, dateSentFull = null, waveform = null, + duration = null, ) private fun createFromUri( @@ -81,6 +83,7 @@ class AndroidLocalMediaFactory @Inject constructor( dateSent: String?, dateSentFull: String?, waveform: List?, + duration: String?, ): LocalMedia { val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream val fileName = name ?: context.getFileName(uri) ?: "" @@ -100,6 +103,7 @@ class AndroidLocalMediaFactory @Inject constructor( dateSent = dateSent, dateSentFull = dateSentFull, waveform = waveform, + duration = duration, ) ) } From 40ef05b6e6b111226c0ea4010257f566303af497 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 20 Jan 2025 17:22:40 +0100 Subject: [PATCH 10/38] Remove unused import --- .../libraries/mediaviewer/impl/gallery/EventItemFactory.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt index 75fa590b86..0486c65f07 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt @@ -40,7 +40,6 @@ 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.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor -import kotlinx.collections.immutable.persistentListOf import timber.log.Timber import javax.inject.Inject From 13defbbcc0282e3b9c7a3d9e6ec309836213393b Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 21 Jan 2025 09:57:22 +0100 Subject: [PATCH 11/38] media viewer : use collectAsState in the DataSource --- .../impl/viewer/MediaViewerDataSource.kt | 51 +++++++++---------- .../impl/viewer/MediaViewerPresenter.kt | 18 +++++-- 2 files changed, 38 insertions(+), 31 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt index 2b86ee841b..592a84c1cb 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -7,10 +7,13 @@ package io.element.android.libraries.mediaviewer.impl.viewer +import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.matrix.api.core.EventId 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.timeline.Timeline @@ -56,33 +59,27 @@ class MediaViewerDataSource( localMediaStates.clear() } - fun initialPageIndex(eventId: EventId?): Int { - if (eventId == null) { - return 0 - } - val mediaItems = - galleryDataSource.getLastData().dataOrNull()?.getItems(galleryMode).orEmpty() - val pageList = buildMediaViewerPageList(mediaItems) - return pageList.indexOfFirst { data -> - when (data) { - is MediaViewerPageData.MediaViewerData -> data.eventId == eventId - else -> false - } - } - .takeIf { it != -1 } - ?: 0 + @Composable + fun collectAsState(): State> { + return remember { dataFlow() }.collectAsState(initialData()) } - fun dataFlow(): Flow> { + private fun dataFlow(): Flow> { return galleryDataSource.groupedMediaItemsFlow() - .map { - val groupedItems = it.dataOrNull()?.getItems(galleryMode).orEmpty() + .map { groupedItems -> + val mediaItems = groupedItems.dataOrNull()?.getItems(galleryMode).orEmpty() withContext(dispatcher) { - buildMediaViewerPageList(groupedItems) + buildMediaViewerPageList(mediaItems) } } } + private fun initialData(): PersistentList { + val initialMediaItems = + galleryDataSource.getLastData().dataOrNull()?.getItems(galleryMode).orEmpty() + return buildMediaViewerPageList(initialMediaItems) + } + private fun buildMediaViewerPageList(groupedItems: List) = buildList { groupedItems.forEach { mediaItem -> when (mediaItem) { @@ -112,6 +109,14 @@ class MediaViewerDataSource( } }.toPersistentList() + fun clearLoadingError(data: MediaViewerPageData.MediaViewerData) { + localMediaStates[data.mediaSource.url]?.value = AsyncData.Uninitialized + } + + suspend fun loadMore(direction: Timeline.PaginationDirection) { + galleryDataSource.loadMore(direction) + } + suspend fun loadMedia(data: MediaViewerPageData.MediaViewerData) { Timber.d("loadMedia for ${data.eventId}") val localMediaState = localMediaStates.getOrPut(data.mediaSource.url) { @@ -141,11 +146,5 @@ class MediaViewerDataSource( } } - fun clearLoadingError(data: MediaViewerPageData.MediaViewerData) { - localMediaStates[data.mediaSource.url]?.value = AsyncData.Uninitialized - } - suspend fun loadMore(direction: Timeline.PaginationDirection) { - galleryDataSource.loadMore(direction) - } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index 176288b537..a8a22b21d8 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -10,7 +10,6 @@ package io.element.android.libraries.mediaviewer.impl.viewer import android.content.ActivityNotFoundException import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -35,8 +34,6 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import io.element.android.libraries.androidutils.R as UtilsR @@ -61,8 +58,8 @@ class MediaViewerPresenter @AssistedInject constructor( @Composable override fun present(): MediaViewerState { val coroutineScope = rememberCoroutineScope() - val data: ImmutableList by dataSource.dataFlow().collectAsState(persistentListOf()) - var currentIndex by remember { mutableIntStateOf(dataSource.initialPageIndex(inputs.eventId)) } + val data by dataSource.collectAsState() + var currentIndex by remember { mutableIntStateOf(searchIndex(data, inputs.eventId)) } val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } @@ -202,4 +199,15 @@ class MediaViewerPresenter @AssistedInject constructor( CommonStrings.error_unknown } } + + private fun searchIndex(data: List, eventId: EventId?): Int { + if (eventId == null) { + return 0 + } + return data.indexOfFirst { + (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId + } + .takeIf { it != -1 } + ?: 0 + } } From f21aeea980d43d850e4cd9010b229273e46d1f87 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 21 Jan 2025 10:57:43 +0100 Subject: [PATCH 12/38] Fix and write tests --- .../impl/gallery/EventItemFactory.kt | 5 +- .../impl/gallery/MediaGalleryDataSource.kt | 6 +- .../impl/gallery/MediaGalleryPresenter.kt | 2 +- .../impl/gallery/ui/MediaItemImageProvider.kt | 3 +- .../impl/viewer/MediaViewerDataSource.kt | 3 - .../impl/viewer/MediaViewerView.kt | 3 +- .../gallery/DefaultEventItemFactoryTest.kt | 9 +- .../gallery/FakeMediaGalleryDataSource.kt | 47 ++ .../impl/gallery/MediaGalleryPresenterTest.kt | 126 +++-- .../gallery/MediaItemsPostProcessorTest.kt | 38 +- .../TimelineMediaGalleryDataSourceTest.kt | 279 +++++++++ .../local/AndroidLocalMediaFactoryTest.kt | 1 + .../impl/viewer/MediaViewerPresenterTest.kt | 534 +++++++++++++----- .../impl/viewer/MediaViewerViewTest.kt | 160 ++++-- .../mediaviewer/test/FakeLocalMediaFactory.kt | 1 + .../src/main/res/values/localazy.xml | 1 + 16 files changed, 952 insertions(+), 266 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryDataSource.kt create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt index 0486c65f07..293329c2dd 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt @@ -123,6 +123,7 @@ class EventItemFactory @Inject constructor( duration = null, ), mediaSource = type.source, + // TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource ) is ImageMessageType -> MediaItem.Image( id = currentTimelineItem.uniqueId, @@ -142,7 +143,7 @@ class EventItemFactory @Inject constructor( duration = null, ), mediaSource = type.source, - thumbnailSource = null, + thumbnailSource = type.info?.thumbnailSource, ) is StickerMessageType -> MediaItem.Image( id = currentTimelineItem.uniqueId, @@ -162,7 +163,7 @@ class EventItemFactory @Inject constructor( duration = null, ), mediaSource = type.source, - thumbnailSource = null, + thumbnailSource = type.info?.thumbnailSource, ) is VideoMessageType -> MediaItem.Video( id = currentTimelineItem.uniqueId, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt index a2f827636b..0e12653108 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery +import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.SingleIn @@ -35,6 +36,7 @@ interface MediaGalleryDataSource { } @SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) class TimelineMediaGalleryDataSource @Inject constructor( private val room: MatrixRoom, private val timelineMediaItemsFactory: TimelineMediaItemsFactory, @@ -62,7 +64,9 @@ class TimelineMediaGalleryDataSource @Inject constructor( timeline = it emit(it) }, - { groupedMediaItemsFlow.emit(AsyncData.Failure(it)) }, + { + groupedMediaItemsFlow.emit(AsyncData.Failure(it)) + }, ) }.flatMapLatest { timeline -> timeline.timelineItems.onEach { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt index 247014db01..476e86c5c5 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -40,7 +40,7 @@ import kotlinx.coroutines.launch class MediaGalleryPresenter @AssistedInject constructor( @Assisted private val navigator: MediaGalleryNavigator, private val room: MatrixRoom, - private val mediaGalleryDataSource: TimelineMediaGalleryDataSource, + private val mediaGalleryDataSource: MediaGalleryDataSource, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, private val localMediaActions: LocalMediaActions, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt index 2ed781c7ae..ceb934fbe2 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt @@ -18,6 +18,7 @@ fun aMediaItemImage( id: UniqueId = UniqueId("imageId"), eventId: EventId? = null, senderId: UserId? = null, + mediaSourceUrl: String = "", ): MediaItem.Image { return MediaItem.Image( id = id, @@ -25,7 +26,7 @@ fun aMediaItemImage( mediaInfo = anImageMediaInfo( senderId = senderId, ), - mediaSource = MediaSource(""), + mediaSource = MediaSource(mediaSourceUrl), thumbnailSource = null, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt index 592a84c1cb..89c3fe943d 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -41,7 +41,6 @@ class MediaViewerDataSource( private val mediaLoader: MatrixMediaLoader, private val localMediaFactory: LocalMediaFactory, ) { - // List of media files that are currently being loaded private val mediaFiles: MutableList = mutableListOf() @@ -145,6 +144,4 @@ class MediaViewerDataSource( localMediaState.value = AsyncData.Failure(it) } } - - } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 3c2b986c98..c75bf1eacc 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -266,7 +266,6 @@ private fun MediaViewerPage( onShowOverlayChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { - val currentShowOverlay by rememberUpdatedState(showOverlay) val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange) val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false) @@ -489,7 +488,7 @@ private fun MediaViewerTopBar( ) { Icon( imageVector = CompoundIcons.Info(), - contentDescription = null, + contentDescription = stringResource(id = CommonStrings.a11y_view_details), ) } } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt index 9b9a6092b5..a0bc4c1f0f 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt @@ -165,6 +165,7 @@ class DefaultEventItemFactoryTest { dateSent = "0 Day false", dateSentFull = "0 Full false", waveform = null, + duration = null, ), mediaSource = MediaSource(""), ) @@ -214,6 +215,7 @@ class DefaultEventItemFactoryTest { dateSent = "0 Day false", dateSentFull = "0 Full false", waveform = null, + duration = null, ), mediaSource = MediaSource(""), thumbnailSource = null, @@ -260,6 +262,7 @@ class DefaultEventItemFactoryTest { dateSent = "0 Day false", dateSentFull = "0 Full false", waveform = null, + duration = null, ), mediaSource = MediaSource(""), ) @@ -310,10 +313,10 @@ class DefaultEventItemFactoryTest { dateSent = "0 Day false", dateSentFull = "0 Full false", waveform = null, + duration = "2:03", ), mediaSource = MediaSource(""), thumbnailSource = null, - duration = "2:03", ) ) } @@ -361,10 +364,9 @@ class DefaultEventItemFactoryTest { dateSent = "0 Day false", dateSentFull = "0 Full false", waveform = listOf(1f, 2f).toImmutableList(), + duration = "7:36", ), mediaSource = MediaSource(""), - duration = "7:36", - waveform = listOf(1f, 2f).toImmutableList(), ) ) } @@ -412,6 +414,7 @@ class DefaultEventItemFactoryTest { dateSent = "0 Day false", dateSentFull = "0 Full false", waveform = null, + duration = null, ), mediaSource = MediaSource(""), thumbnailSource = null, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryDataSource.kt new file mode 100644 index 0000000000..419c2c568a --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryDataSource.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2025 New Vector 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 + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +class FakeMediaGalleryDataSource( + private val startLambda: () -> Unit = { lambdaError() }, + private val loadMoreLambda: (Timeline.PaginationDirection) -> Unit = { lambdaError() }, + private val deleteItemLambda: (EventId) -> Unit = { lambdaError() }, + ) : MediaGalleryDataSource { + override fun start() = startLambda() + + private val groupedMediaItemsFlow = MutableSharedFlow>( + replay = 1 + ) + + override fun groupedMediaItemsFlow(): Flow> { + return groupedMediaItemsFlow + } + + suspend fun emitGroupedMediaItems(groupedMediaItems: AsyncData) { + groupedMediaItemsFlow.emit(groupedMediaItems) + } + + override fun getLastData(): AsyncData { + return groupedMediaItemsFlow.replayCache.firstOrNull() ?: AsyncData.Uninitialized + } + + override suspend fun loadMore(direction: Timeline.PaginationDirection) { + loadMoreLambda(direction) + } + + override suspend fun deleteItem(eventId: EventId) { + deleteItemLambda(eventId) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt index 3a206dc244..0f304f209e 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt @@ -8,12 +8,12 @@ package io.element.android.libraries.mediaviewer.impl.gallery import android.net.Uri +import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter -import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoom +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_ROOM_NAME import io.element.android.libraries.matrix.test.A_USER_ID @@ -25,15 +25,11 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetSta import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory -import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test -import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk -import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -47,49 +43,37 @@ class MediaGalleryPresenterTest { @Test fun `present - initial state`() = runTest { - val onViewInTimelineClickLambda = lambdaRecorder { } - val navigator = FakeMediaGalleryNavigator( - onViewInTimelineClickLambda = onViewInTimelineClickLambda, - ) + val startLambda = lambdaRecorder { } val presenter = createMediaGalleryPresenter( - navigator = navigator, + mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = startLambda, + ), room = FakeMatrixRoom( displayName = A_ROOM_NAME, mediaTimelineResult = { Result.success(FakeTimeline()) }, ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() + val initialState = awaitFirstItem() assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images) assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) assertThat(initialState.roomName).isEqualTo(A_ROOM_NAME) - assertThat(initialState.groupedMediaItems.dataOrNull()).isEqualTo( - GroupedMediaItems( - imageAndVideoItems = persistentListOf(), - fileItems = persistentListOf(), - ) - ) + assertThat(initialState.groupedMediaItems.isUninitialized()).isTrue() assertThat(initialState.snackbarMessage).isNull() } + startLambda.assertions().isCalledOnce() } @Test fun `present - change mode`() = runTest { - val onViewInTimelineClickLambda = lambdaRecorder { } - val navigator = FakeMediaGalleryNavigator( - onViewInTimelineClickLambda = onViewInTimelineClickLambda, - ) val presenter = createMediaGalleryPresenter( - navigator = navigator, room = FakeMatrixRoom( displayName = A_ROOM_NAME, mediaTimelineResult = { Result.success(FakeTimeline()) }, ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() + val initialState = awaitFirstItem() assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images) initialState.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Files)) val state = awaitItem() @@ -110,7 +94,7 @@ class MediaGalleryPresenterTest { `present - bottom sheet state - own message`(canDeleteOwn = false) } - private suspend fun TestScope.`present - bottom sheet state - own message`(canDeleteOwn: Boolean) { + private suspend fun `present - bottom sheet state - own message`(canDeleteOwn: Boolean) { val presenter = createMediaGalleryPresenter( room = FakeMatrixRoom( sessionId = A_USER_ID, @@ -120,8 +104,7 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() + val initialState = awaitFirstItem() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) val item = aMediaItemImage( eventId = AN_EVENT_ID, @@ -154,7 +137,7 @@ class MediaGalleryPresenterTest { `present - bottom sheet state - other message`(canDeleteOther = false) } - private suspend fun TestScope.`present - bottom sheet state - other message`(canDeleteOther: Boolean) { + private suspend fun `present - bottom sheet state - other message`(canDeleteOther: Boolean) { val presenter = createMediaGalleryPresenter( room = FakeMatrixRoom( sessionId = A_USER_ID, @@ -164,8 +147,7 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() + val initialState = awaitFirstItem() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) val item = aMediaItemImage( eventId = AN_EVENT_ID, @@ -197,8 +179,7 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() + val initialState = awaitFirstItem() // Delete bottom sheet val item = aMediaItemImage() initialState.eventSink(MediaGalleryEvents.ConfirmDelete(AN_EVENT_ID, item.mediaInfo, item.thumbnailSource)) @@ -217,6 +198,42 @@ class MediaGalleryPresenterTest { } } + @Test + fun `present - delete item`() = runTest { + val deleteItemLambda = lambdaRecorder { } + val presenter = createMediaGalleryPresenter( + mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + deleteItemLambda = deleteItemLambda, + ), + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.Delete(AN_EVENT_ID)) + deleteItemLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) + } + } + + @Test + fun `present - share item`() = runTest { + val presenter = createMediaGalleryPresenter() + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.Share(AN_EVENT_ID)) + } + // TODO Add more test on this part + } + + @Test + fun `present - save on disk`() = runTest { + val presenter = createMediaGalleryPresenter() + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.SaveOnDisk(AN_EVENT_ID)) + } + // TODO Add more test on this part + } + @Test fun `present - view in timeline invokes the navigator`() = runTest { val onViewInTimelineClickLambda = lambdaRecorder { } @@ -230,15 +247,37 @@ class MediaGalleryPresenterTest { navigator = navigator, ) presenter.test { - skipItems(2) - val initialState = awaitItem() + val initialState = awaitFirstItem() initialState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID)) onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) } } - private fun TestScope.createMediaGalleryPresenter( + @Test + fun `present - load more`() = runTest { + val loadMoreLambda = lambdaRecorder { } + val presenter = createMediaGalleryPresenter( + mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + loadMoreLambda = loadMoreLambda, + ), + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) + loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS)) + } + } + + private suspend fun ReceiveTurbine.awaitFirstItem(): T { + return awaitItem() + } + + private fun createMediaGalleryPresenter( matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(), + mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ), localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), navigator: MediaGalleryNavigator = FakeMediaGalleryNavigator(), @@ -249,22 +288,11 @@ class MediaGalleryPresenterTest { return MediaGalleryPresenter( navigator = navigator, room = room, - timelineMediaItemsFactory = TimelineMediaItemsFactory( - dispatchers = testCoroutineDispatchers(), - virtualItemFactory = VirtualItemFactory( - dateFormatter = FakeDateFormatter(), - ), - eventItemFactory = EventItemFactory( - fileSizeFormatter = FakeFileSizeFormatter(), - fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), - dateFormatter = FakeDateFormatter(), - ), - ), + mediaGalleryDataSource = mediaGalleryDataSource, localMediaFactory = localMediaFactory, mediaLoader = matrixMediaLoader, localMediaActions = localMediaActions, snackbarDispatcher = snackbarDispatcher, - mediaItemsPostProcessor = MediaItemsPostProcessor(), ) } } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt index 9a8fc615ff..4c823350ce 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt @@ -8,9 +8,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.UniqueId -import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemAudio import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile @@ -42,27 +40,6 @@ class MediaItemsPostProcessorTest { private val date3 = aMediaItemDateSeparator(id = UniqueId("3")) private val loading1 = aMediaItemLoadingIndicator(id = UniqueId("1")) - @Test - fun `process Uninitialized`() { - val sut = MediaItemsPostProcessor() - val result = sut.process(AsyncData.Uninitialized) - assertThat(result).isEqualTo(AsyncData.Uninitialized) - } - - @Test - fun `process Loading`() { - val sut = MediaItemsPostProcessor() - val result = sut.process(AsyncData.Loading()) - assertThat(result).isEqualTo(AsyncData.Loading()) - } - - @Test - fun `process Failure`() { - val sut = MediaItemsPostProcessor() - val result = sut.process(AsyncData.Failure(AN_EXCEPTION)) - assertThat(result).isEqualTo(AsyncData.Failure(AN_EXCEPTION)) - } - @Test fun `process Empty`() { test( @@ -215,19 +192,16 @@ class MediaItemsPostProcessorTest { expectedFileItems: List, ) { val sut = MediaItemsPostProcessor() - val result = sut.process(AsyncData.Success(mediaItems.toImmutableList())) - val data = result.dataOrNull()!! + val result = sut.process(mediaItems.toImmutableList()) // Compare the lists to have better failure info - assertThat(data.imageAndVideoItems.toList()).isEqualTo(expectedImageAndVideoItems) - assertThat(data.fileItems.toList()).isEqualTo(expectedFileItems) + assertThat(result.imageAndVideoItems.toList()).isEqualTo(expectedImageAndVideoItems) + assertThat(result.fileItems.toList()).isEqualTo(expectedFileItems) assertThat(result).isEqualTo( - AsyncData.Success( - GroupedMediaItems( - imageAndVideoItems = expectedImageAndVideoItems.toImmutableList(), - fileItems = expectedFileItems.toImmutableList(), - ) + GroupedMediaItems( + imageAndVideoItems = expectedImageAndVideoItems.toImmutableList(), + fileItems = expectedFileItems.toImmutableList(), ) ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt new file mode 100644 index 0000000000..5c21299f4d --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt @@ -0,0 +1,279 @@ +/* + * Copyright 2025 New Vector 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 + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.media.ThumbnailInfo +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_UNIQUE_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +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.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class TimelineMediaGalleryDataSourceTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `test - not started TimelineMediaGalleryDataSource emits no events`() { + val fakeTimeline = FakeTimeline() + runTest { + val sut = createTimelineMediaGalleryDataSource( + room = FakeMatrixRoom( + mediaTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.groupedMediaItemsFlow().test { + // Also, loadMore and deleteItem should be no-op + sut.loadMore(Timeline.PaginationDirection.BACKWARDS) + sut.deleteItem(AN_EVENT_ID) + expectNoEvents() + } + } + } + + @Test + fun `test - getLastData should return the previous emitted data`() { + val fakeTimeline = FakeTimeline() + runTest { + val sut = createTimelineMediaGalleryDataSource( + room = FakeMatrixRoom( + mediaTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.start() + assertThat(sut.getLastData()).isEqualTo(AsyncData.Uninitialized) + sut.groupedMediaItemsFlow().test { + assertThat(awaitItem().isLoading()).isTrue() + assertThat(sut.getLastData().isLoading()).isTrue() + assertThat(awaitItem()).isEqualTo( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(), + ) + ) + ) + assertThat(sut.getLastData().isSuccess()).isTrue() + // Also test that starting again should have no effect + sut.start() + } + } + // Ensure that the timeline has been closed on flow completion + assertThat(fakeTimeline.closeCounter).isEqualTo(1) + } + + @Test + fun `test - load more should call the timeline paginate method`() = runTest { + val paginateLambdaRecorder = + lambdaRecorder> { _ -> + Result.success(true) + } + val fakeTimeline = FakeTimeline().apply { + paginateLambda = paginateLambdaRecorder + } + val sut = createTimelineMediaGalleryDataSource( + room = FakeMatrixRoom( + mediaTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.start() + sut.groupedMediaItemsFlow().test { + skipItems(2) + sut.loadMore(Timeline.PaginationDirection.BACKWARDS) + paginateLambdaRecorder.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS)) + } + } + + @Test + fun `test - delete item should call the timeline redact method`() = runTest { + val redactEventLambdaRecorder = + lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val fakeTimeline = FakeTimeline().apply { + redactEventLambda = redactEventLambdaRecorder + } + val sut = createTimelineMediaGalleryDataSource( + room = FakeMatrixRoom( + mediaTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.start() + sut.groupedMediaItemsFlow().test { + skipItems(2) + sut.deleteItem(AN_EVENT_ID) + redactEventLambdaRecorder.assertions().isCalledOnce().with( + value(AN_EVENT_ID.toEventOrTransactionId()), + value(null), + ) + } + } + + @Test + fun `test - failing to load timeline should emit an error`() = runTest { + val sut = createTimelineMediaGalleryDataSource( + room = FakeMatrixRoom( + mediaTimelineResult = { Result.failure(AN_EXCEPTION) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.start() + sut.groupedMediaItemsFlow().test { + assertThat(awaitItem().isLoading()).isTrue() + assertThat(sut.getLastData().isLoading()).isTrue() + assertThat(awaitItem()).isEqualTo( + AsyncData.Failure(AN_EXCEPTION) + ) + } + } + + @Test + fun `test - when timeline emits new data, the flow emits the data`() = runTest { + val timelineItems = MutableStateFlow>(emptyList()) + val fakeTimeline = FakeTimeline( + timelineItems = timelineItems, + ) + val sut = createTimelineMediaGalleryDataSource( + room = FakeMatrixRoom( + mediaTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.start() + sut.groupedMediaItemsFlow().test { + assertThat(awaitItem().isLoading()).isTrue() + assertThat(sut.getLastData().isLoading()).isTrue() + assertThat(awaitItem()).isEqualTo( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(), + ) + ) + ) + timelineItems.emit( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = ImageMessageType( + filename = "body.jpg", + caption = "body.jpg caption", + formattedCaption = FormattedBody(MessageFormat.HTML, "formatted"), + source = MediaSource("url"), + info = ImageInfo( + height = 10L, + width = 5L, + mimetype = MimeTypes.Jpeg, + size = 888L, + thumbnailInfo = ThumbnailInfo( + height = 10L, + width = 5L, + mimetype = MimeTypes.Jpeg, + size = 111L, + ), + thumbnailSource = MediaSource("url_thumbnail"), + blurhash = A_BLUR_HASH, + ) + ) + ) + ), + ) + ) + ) + assertThat(awaitItem()).isEqualTo( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf( + MediaItem.Image( + id = A_UNIQUE_ID, + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + filename = "body.jpg", + caption = "body.jpg caption", + mimeType = MimeTypes.Jpeg, + formattedFileSize = "888 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("url"), + thumbnailSource = MediaSource("url_thumbnail"), + ) + ), + fileItems = persistentListOf() + ) + ) + ) + } + } + + private fun TestScope.createTimelineMediaGalleryDataSource( + room: MatrixRoom = FakeMatrixRoom( + liveTimeline = FakeTimeline(), + ), + ): TimelineMediaGalleryDataSource { + return TimelineMediaGalleryDataSource( + room = room, + timelineMediaItemsFactory = TimelineMediaItemsFactory( + dispatchers = testCoroutineDispatchers(), + virtualItemFactory = VirtualItemFactory( + dateFormatter = FakeDateFormatter(), + ), + eventItemFactory = EventItemFactory( + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + dateFormatter = FakeDateFormatter(), + ), + ), + mediaItemsPostProcessor = MediaItemsPostProcessor(), + ) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt index 829efd7a15..48c636297b 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt @@ -50,6 +50,7 @@ class AndroidLocalMediaFactoryTest { dateSent = "12:34", dateSentFull = "full", waveform = null, + duration = null, ) ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index 3f62fe463c..3b408cbab5 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -10,14 +10,14 @@ package io.element.android.libraries.mediaviewer.impl.viewer import android.net.Uri -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test +import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher 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.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -30,14 +30,22 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.anApkMediaInfo import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.gallery.FakeMediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.gallery.GroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -52,6 +60,7 @@ class MediaViewerPresenterTest { private val mockMediaUri: Uri = mockk("localMediaUri") private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) + private val aUrl = "aUrl" @Test fun `present - initial state null Event`() = runTest { @@ -61,9 +70,9 @@ class MediaViewerPresenterTest { ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + val initialState = awaitFirstItem() + assertThat(initialState.listData).isEmpty() + assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isTrue() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) @@ -79,9 +88,9 @@ class MediaViewerPresenterTest { ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + val initialState = awaitFirstItem() + assertThat(initialState.listData).isEmpty() + assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isFalse() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) @@ -97,9 +106,9 @@ class MediaViewerPresenterTest { ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + val initialState = awaitFirstItem() + assertThat(initialState.listData).isEmpty() + assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isTrue() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) @@ -116,9 +125,9 @@ class MediaViewerPresenterTest { ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + val initialState = awaitFirstItem() + assertThat(initialState.listData).isEmpty() + assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isTrue() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) @@ -126,114 +135,280 @@ class MediaViewerPresenterTest { } @Test - fun `present - download media success scenario`() = runTest { - val presenter = createMediaViewerPresenter( - room = FakeMatrixRoom( - canRedactOwnResult = { Result.success(true) }, - ) + fun `present - data source update`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - var state = awaitItem() - assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized) - assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) - state = awaitItem() - assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) - state = awaitItem() - val successData = state.downloadedMedia.dataOrNull() - assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) - assertThat(successData).isNotNull() + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage() + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.listData).isEmpty() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitFirstItem() + assertThat(updatedState.listData).hasSize(1) + val item = updatedState.listData.first() as MediaViewerPageData.MediaViewerData + assertThat(item.eventId).isNull() + assertThat(item.mediaInfo).isEqualTo(anImage.mediaInfo) + assertThat(item.mediaSource).isEqualTo(anImage.mediaSource) + assertThat(item.thumbnailSource).isEqualTo(anImage.thumbnailSource) + assertThat(item.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized) } } @Test - fun `present - check all actions`() = runTest { - val mediaActions = FakeLocalMediaActions() - val snackbarDispatcher = SnackbarDispatcher() - val presenter = createMediaViewerPresenter( - localMediaActions = mediaActions, - snackbarDispatcher = snackbarDispatcher, - room = FakeMatrixRoom( - canRedactOwnResult = { Result.success(true) }, - ) + fun `present - load media`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - var state = awaitItem() - assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized) - state = awaitItem() - assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) - // no state changes while media is loading - state.eventSink(MediaViewerEvents.OpenWith) - state.eventSink(MediaViewerEvents.Share) - state.eventSink(MediaViewerEvents.SaveOnDisk) - state = awaitItem() - assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) - // Should succeed without change of state - state.eventSink(MediaViewerEvents.OpenWith) - // Should succeed without change of state - state.eventSink(MediaViewerEvents.Share) - state.eventSink(MediaViewerEvents.SaveOnDisk) - state = awaitItem() - assertThat(state.snackbarMessage).isNotNull() - snackbarDispatcher.clear() - assertThat(awaitItem().snackbarMessage).isNull() - - // Check failures - mediaActions.shouldFail = true - state.eventSink(MediaViewerEvents.OpenWith) - state = awaitItem() - assertThat(state.snackbarMessage).isNotNull() - snackbarDispatcher.clear() - assertThat(awaitItem().snackbarMessage).isNull() - state.eventSink(MediaViewerEvents.Share) - state = awaitItem() - assertThat(state.snackbarMessage).isNotNull() - snackbarDispatcher.clear() - assertThat(awaitItem().snackbarMessage).isNull() - state.eventSink(MediaViewerEvents.SaveOnDisk) - state = awaitItem() - assertThat(state.snackbarMessage).isNotNull() + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.LoadMedia( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) } } @Test - fun `present - download media failure then retry with success scenario`() = runTest { - val matrixMediaLoader = FakeMatrixMediaLoader() + fun `present - open info`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) val presenter = createMediaViewerPresenter( - matrixMediaLoader = matrixMediaLoader, + mediaGalleryDataSource = mediaGalleryDataSource, room = FakeMatrixRoom( canRedactOwnResult = { Result.success(true) }, ) ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - matrixMediaLoader.shouldFail = true - val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized) - assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) - val loadingState = awaitItem() - assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) - val failureState = awaitItem() - assertThat(failureState.downloadedMedia).isInstanceOf(AsyncData.Failure::class.java) - matrixMediaLoader.shouldFail = false - failureState.eventSink(MediaViewerEvents.RetryLoading) - // There is one recomposition because of the retry mechanism - skipItems(1) - val retryLoadingState = awaitItem() - assertThat(retryLoadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - val successData = successState.downloadedMedia.dataOrNull() - assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) - assertThat(successData).isNotNull() + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.OpenInfo( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + val withInfoState = awaitItem() + assertThat(withInfoState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java) + withInfoState.eventSink( + MediaViewerEvents.CloseBottomSheet + ) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) } } @Test - fun `present - delete media success scenario`() = runTest { + fun `present - clear loading error`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.ClearLoadingError( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + } + } + + @Test + fun `present - share`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.Share( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + } + } + + @Test + fun `present - save on disk`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.SaveOnDisk( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + } + } + + @Test + fun `present - open with`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.OpenWith( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + } + } + + @Test + fun `present - delete and cancel`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.ConfirmDelete( + eventId = AN_EVENT_ID, + data = aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + val withBottomSheetState = awaitItem() + assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java) + withBottomSheetState.eventSink( + MediaViewerEvents.CloseBottomSheet + ) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + } + } + + @Test + fun `present - delete`() = runTest { val redactEventLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } @@ -241,26 +416,51 @@ class MediaViewerPresenterTest { this.redactEventLambda = redactEventLambda } val onItemDeletedLambda = lambdaRecorder { } - val navigator = FakeMediaViewerNavigator( - onItemDeletedLambda = onItemDeletedLambda, + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, ) - val presenter = createMediaViewerPresenter( room = FakeMatrixRoom( liveTimeline = timeline, canRedactOwnResult = { Result.success(true) }, ), - mediaViewerNavigator = navigator, + mediaGalleryDataSource = mediaGalleryDataSource, + mediaViewerNavigator = FakeMediaViewerNavigator( + onItemDeletedLambda = onItemDeletedLambda + ) + ) + val anImage = aMediaItemImage( + eventId = AN_EVENT_ID, + mediaSourceUrl = aUrl, ) presenter.test { - val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized) - assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) - val loadingState = awaitItem() - assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) - successState.eventSink(MediaViewerEvents.Delete(AN_EVENT_ID)) + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.ConfirmDelete( + eventId = AN_EVENT_ID, + data = aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + val withBottomSheetState = awaitItem() + assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java) + updatedState.eventSink( + MediaViewerEvents.Delete( + eventId = AN_EVENT_ID, + ) + ) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) redactEventLambda.assertions() .isCalledOnce() .with( @@ -272,7 +472,71 @@ class MediaViewerPresenterTest { } @Test - fun `present - view in timeline invokes the navigator`() = runTest { + fun `present - on navigate to`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + val anImage2 = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage, anImage2), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.OnNavigateTo(1) + ) + val finalState = awaitItem() + assertThat(finalState.currentIndex).isEqualTo(1) + } + } + + @Test + fun `present - load more`() = runTest { + val loadMoreLambda = lambdaRecorder { } + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + loadMoreLambda = loadMoreLambda, + ) + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS) + ) + loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS)) + } + } + + @Test + fun `present - view in timeline hide the bottom sheet and invokes the navigator`() = runTest { val onViewInTimelineClickLambda = lambdaRecorder { } val navigator = FakeMediaViewerNavigator( onViewInTimelineClickLambda = onViewInTimelineClickLambda, @@ -285,22 +549,28 @@ class MediaViewerPresenterTest { ) presenter.test { val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized) - assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) - val loadingState = awaitItem() - assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) - successState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID)) + initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData())) + val withBottomSheetState = awaitItem() + assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java) + initialState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID)) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) } } - private fun createMediaViewerPresenter( + private suspend fun ReceiveTurbine.awaitFirstItem(): T { + return awaitItem() + } + + private fun TestScope.createMediaViewerPresenter( eventId: EventId? = null, matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(), localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), + mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ), canShowInfo: Boolean = true, mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(), room: MatrixRoom = FakeMatrixRoom( @@ -309,18 +579,24 @@ class MediaViewerPresenterTest { ): MediaViewerPresenter { return MediaViewerPresenter( inputs = MediaViewerEntryPoint.Params( + mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, eventId = eventId, mediaInfo = TESTED_MEDIA_INFO, mediaSource = aMediaSource(), thumbnailSource = null, canShowInfo = canShowInfo, ), - localMediaFactory = localMediaFactory, - mediaLoader = matrixMediaLoader, + navigator = mediaViewerNavigator, + dataSource = MediaViewerDataSource( + galleryMode = MediaGalleryMode.Images, + dispatcher = testCoroutineDispatchers().computation, + galleryDataSource = mediaGalleryDataSource, + mediaLoader = matrixMediaLoader, + localMediaFactory = localMediaFactory, + ), + room = room, localMediaActions = localMediaActions, snackbarDispatcher = snackbarDispatcher, - navigator = mediaViewerNavigator, - room = room, ) } } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt index 89dfc0dd91..bfc294098b 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt @@ -18,15 +18,15 @@ import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeDown import androidx.test.ext.junit.runners.AndroidJUnit4 import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.mediaviewer.api.anImageMediaInfo -import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState +import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.pressBack +import io.mockk.mockk import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -36,78 +36,127 @@ import org.junit.runner.RunWith class MediaViewerViewTest { @get:Rule val rule = createAndroidComposeRule() + private val mockMediaUrl: Uri = mockk("localMediaUri") + @Test fun `clicking on back invokes expected callback`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder() + val state = aMediaViewerState( + eventSink = eventsRecorder + ) ensureCalledOnce { callback -> rule.setMediaViewerView( - aMediaViewerState( - eventSink = eventsRecorder - ), + state = state, onBackClick = callback, ) rule.pressBack() } + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData), + ) + ) } @Test fun `clicking on open emit expected Event`() { - testMenuAction(CommonStrings.action_open_with, MediaViewerEvents.OpenWith) + val data = aMediaViewerPageData( + downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)), + ) + testMenuAction( + data, + CommonStrings.action_open_with, + MediaViewerEvents.OpenWith(data), + ) } - private fun testMenuAction(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) { + @Test + fun `clicking on info emit expected Event`() { + val data = aMediaViewerPageData( + downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)), + ) + testMenuAction( + data, + CommonStrings.a11y_view_details, + MediaViewerEvents.OpenInfo(data), + ) + } + + private fun testMenuAction( + data: MediaViewerPageData.MediaViewerData, + contentDescriptionRes: Int, + expectedEvent: MediaViewerEvents, + ) { val eventsRecorder = EventsRecorder() rule.setMediaViewerView( aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, anImageMediaInfo()) - ), - mediaInfo = anImageMediaInfo(), + listData = listOf(data), eventSink = eventsRecorder ), ) val contentDescription = rule.activity.getString(contentDescriptionRes) rule.onNodeWithContentDescription(contentDescription).performClick() - eventsRecorder.assertSingle(expectedEvent) + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(data), + expectedEvent, + ) + ) } @Test fun `clicking on save emit expected Event`() { - testBottomSheetAction(CommonStrings.action_save, MediaViewerEvents.SaveOnDisk) + val data = aMediaViewerPageData() + testBottomSheetAction( + data, + CommonStrings.action_save, + MediaViewerEvents.SaveOnDisk(data), + ) } @Test fun `clicking on share emit expected Event`() { - testBottomSheetAction(CommonStrings.action_share, MediaViewerEvents.Share) + val data = aMediaViewerPageData() + testBottomSheetAction( + data, + CommonStrings.action_share, + MediaViewerEvents.Share(data), + ) } - private fun testBottomSheetAction(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) { + private fun testBottomSheetAction( + data: MediaViewerPageData.MediaViewerData, + contentDescriptionRes: Int, + expectedEvent: MediaViewerEvents, + ) { val eventsRecorder = EventsRecorder() rule.setMediaViewerView( aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, anImageMediaInfo()) - ), - mediaInfo = anImageMediaInfo(), + listData = listOf(data), mediaBottomSheetState = aMediaDetailsBottomSheetState(), eventSink = eventsRecorder ), ) rule.clickOn(contentDescriptionRes) - eventsRecorder.assertSingle(expectedEvent) + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(data), + expectedEvent, + ) + ) } @Test fun `clicking on image hides the overlay`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder() + val state = aMediaViewerState( + eventSink = eventsRecorder + ) rule.setMediaViewerView( - aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, anImageMediaInfo()) - ), - mediaInfo = anImageMediaInfo(), - eventSink = eventsRecorder - ), + state = state, ) // Ensure that the action are visible val contentDescription = rule.activity.getString(CommonStrings.action_open_with) @@ -120,54 +169,79 @@ class MediaViewerViewTest { rule.mainClock.advanceTimeBy(1_000) rule.onNodeWithContentDescription(contentDescription) .assertDoesNotExist() + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData), + ) + ) } @Test fun `clicking swipe on the image invokes the expected callback`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder() + val state = aMediaViewerState( + eventSink = eventsRecorder + ) ensureCalledOnce { callback -> rule.setMediaViewerView( - aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, anImageMediaInfo()) - ), - mediaInfo = anImageMediaInfo(), - eventSink = eventsRecorder - ), + state = state, onBackClick = callback, ) val imageContentDescription = rule.activity.getString(CommonStrings.common_image) rule.onNodeWithContentDescription(imageContentDescription).performTouchInput { swipeDown(startY = centerY) } rule.mainClock.advanceTimeBy(1_000) } + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData), + ) + ) } @Test fun `error case, click on retry emits the expected Event`() { val eventsRecorder = EventsRecorder() + val data = aMediaViewerPageData( + downloadedMedia = AsyncData.Failure(IllegalStateException("error")), + ) rule.setMediaViewerView( aMediaViewerState( - downloadedMedia = AsyncData.Failure(IllegalStateException("error")), - mediaInfo = anImageMediaInfo(), + listData = listOf(data), eventSink = eventsRecorder ), ) rule.clickOn(CommonStrings.action_retry) - eventsRecorder.assertSingle(MediaViewerEvents.RetryLoading) + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(data), + MediaViewerEvents.LoadMedia(data), + ) + ) } @Test fun `error case, click on cancel emits the expected Event`() { val eventsRecorder = EventsRecorder() + val data = aMediaViewerPageData( + downloadedMedia = AsyncData.Failure(IllegalStateException("error")), + ) rule.setMediaViewerView( aMediaViewerState( - downloadedMedia = AsyncData.Failure(IllegalStateException("error")), - mediaInfo = anImageMediaInfo(), + listData = listOf(data), eventSink = eventsRecorder ), ) rule.clickOn(CommonStrings.action_cancel) - eventsRecorder.assertSingle(MediaViewerEvents.ClearLoadingError) + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(data), + MediaViewerEvents.ClearLoadingError(data) + ) + ) } } diff --git a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt index 1b93856bde..f1ebbd04e9 100644 --- a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt +++ b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt @@ -43,6 +43,7 @@ class FakeLocalMediaFactory( dateSent = null, dateSentFull = null, waveform = null, + duration = null, ) return aLocalMedia(uri, mediaInfo) } diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 4a31e05852..f2d4f51872 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -29,6 +29,7 @@ "Show password" "Start a call" "User menu" + "View details" "Record voice message." "Stop recording" "Accept" From 76892197322c7ac1ba8257163bd79ae861572d41 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 21 Jan 2025 20:34:01 +0100 Subject: [PATCH 13/38] Improve loading state and add preview. --- .../mediaviewer/impl/viewer/MediaViewerStateProvider.kt | 6 ++++++ .../libraries/mediaviewer/impl/viewer/MediaViewerView.kt | 8 +++++++- libraries/ui-strings/src/main/res/values/localazy.xml | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt index d37003ed60..77f3698302 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.media.aWaveForm import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo @@ -148,6 +149,11 @@ open class MediaViewerStateProvider : PreviewParameterProvider ) ) }, + aMediaViewerState( + listOf( + MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS) + ), + ), ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index c75bf1eacc..416a2aadfd 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -195,7 +195,13 @@ fun MediaViewerView( } else -> { TopAppBar( - title = {}, + title = { + Text( + text = stringResource(id = CommonStrings.common_loading_more), + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + ) + }, colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent.copy(0.6f), ), diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index f2d4f51872..26a87bde89 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -182,6 +182,7 @@ Reason: %1$s." "Light" "Link copied to clipboard" "Loading…" + "Loading more…" "%1$d member" "%1$d members" From a1b027a8ab397b4f0b455ff73a6baa164e3793b2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 21 Jan 2025 20:39:32 +0100 Subject: [PATCH 14/38] Add exception for Konsist --- .../io/element/android/tests/konsist/KonsistClassNameTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt index 0e8a347ef2..d140d3fea4 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt @@ -122,6 +122,7 @@ class KonsistClassNameTest { .withoutName( "Factory", "TimelineController", + "TimelineMediaGalleryDataSource", ) .withoutNameStartingWith( "Accompanist", From 03c508cea0d46c8075352730be62039b2407a84e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 21 Jan 2025 20:54:56 +0100 Subject: [PATCH 15/38] sync strings --- libraries/mediaviewer/impl/src/main/res/values/localazy.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libraries/mediaviewer/impl/src/main/res/values/localazy.xml b/libraries/mediaviewer/impl/src/main/res/values/localazy.xml index 11d6219565..52a2218331 100644 --- a/libraries/mediaviewer/impl/src/main/res/values/localazy.xml +++ b/libraries/mediaviewer/impl/src/main/res/values/localazy.xml @@ -14,6 +14,8 @@ "Media and files" "File format" "File name" + "No more files to show" + "No more media to show" "This file will be removed from the room and members won’t have access to it." "Delete file?" "Uploaded by" From 8137c68154e7cd112bf043158780aba7a32d926c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 22 Jan 2025 08:34:59 +0100 Subject: [PATCH 16/38] Restore caption rendering --- .../impl/viewer/MediaViewerView.kt | 68 ++++++++++--------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 416a2aadfd..b059805b01 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -98,7 +98,6 @@ fun MediaViewerView( var showOverlay by remember { mutableStateOf(true) } val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0 - var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) } val currentData = state.listData.getOrNull(state.currentIndex) BackHandler { onBackClick() } Scaffold( @@ -135,40 +134,45 @@ fun MediaViewerView( ) } is MediaViewerPageData.MediaViewerData -> { + var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) } LaunchedEffect(Unit) { state.eventSink(MediaViewerEvents.LoadMedia(dataForPage)) } - MediaViewerPage( - isDisplayed = page == pagerState.settledPage, - showOverlay = showOverlay, - bottomPaddingInPixels = bottomPaddingInPixels, - data = dataForPage, - onDismiss = { - onBackClick() - }, - onRetry = { - state.eventSink(MediaViewerEvents.LoadMedia(dataForPage)) - }, - onDismissError = { - state.eventSink(MediaViewerEvents.ClearLoadingError(dataForPage)) - }, - onShowOverlayChange = { - showOverlay = it - } - ) - // Bottom bar - AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { - Box( - modifier = Modifier - .fillMaxSize() - .navigationBarsPadding() - ) { - MediaViewerBottomBar( - modifier = Modifier.align(Alignment.BottomCenter), - showDivider = dataForPage.mediaInfo.mimeType.isMimeTypeVideo(), - caption = dataForPage.mediaInfo.caption, - onHeightChange = { bottomPaddingInPixels = it }, - ) + Box( + modifier = Modifier.fillMaxSize() + ) { + MediaViewerPage( + isDisplayed = page == pagerState.settledPage, + showOverlay = showOverlay, + bottomPaddingInPixels = bottomPaddingInPixels, + data = dataForPage, + onDismiss = { + onBackClick() + }, + onRetry = { + state.eventSink(MediaViewerEvents.LoadMedia(dataForPage)) + }, + onDismissError = { + state.eventSink(MediaViewerEvents.ClearLoadingError(dataForPage)) + }, + onShowOverlayChange = { + showOverlay = it + } + ) + // Bottom bar + AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + ) { + MediaViewerBottomBar( + modifier = Modifier.align(Alignment.BottomCenter), + showDivider = dataForPage.mediaInfo.mimeType.isMimeTypeVideo(), + caption = dataForPage.mediaInfo.caption, + onHeightChange = { bottomPaddingInPixels = it }, + ) + } } } } From eb9676294b6ccd28a151d206fb727c01cd82a369 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 22 Jan 2025 08:55:17 +0100 Subject: [PATCH 17/38] Small cleanup --- .../TimelineMediaGalleryDataSourceTest.kt | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt index 5c21299f4d..2f8cd634ec 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt @@ -51,21 +51,19 @@ class TimelineMediaGalleryDataSourceTest { val warmUpRule = WarmUpRule() @Test - fun `test - not started TimelineMediaGalleryDataSource emits no events`() { + fun `test - not started TimelineMediaGalleryDataSource emits no events`() = runTest { val fakeTimeline = FakeTimeline() - runTest { - val sut = createTimelineMediaGalleryDataSource( - room = FakeMatrixRoom( - mediaTimelineResult = { Result.success(fakeTimeline) }, - roomCoroutineScope = backgroundScope, - ) + val sut = createTimelineMediaGalleryDataSource( + room = FakeMatrixRoom( + mediaTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, ) - sut.groupedMediaItemsFlow().test { - // Also, loadMore and deleteItem should be no-op - sut.loadMore(Timeline.PaginationDirection.BACKWARDS) - sut.deleteItem(AN_EVENT_ID) - expectNoEvents() - } + ) + sut.groupedMediaItemsFlow().test { + // Also, loadMore and deleteItem should be no-op + sut.loadMore(Timeline.PaginationDirection.BACKWARDS) + sut.deleteItem(AN_EVENT_ID) + expectNoEvents() } } From 36924070dfe113ee6aace0b5e5c4ab0f49f39eff Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 22 Jan 2025 09:03:33 +0100 Subject: [PATCH 18/38] Add test on SingleMediaGalleryDataSource --- .../impl/gallery/MediaGalleryStateProvider.kt | 2 +- .../SingleMediaGalleryDataSourceTest.kt | 179 ++++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSourceTest.kt diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt index 949c881814..5a1b5fcca8 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt @@ -122,7 +122,7 @@ private fun aMediaGalleryState( eventSink = {} ) -private fun aGroupedMediaItems( +fun aGroupedMediaItems( imageAndVideoItems: List = emptyList(), fileItems: List = emptyList(), ) = GroupedMediaItems( diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSourceTest.kt new file mode 100644 index 0000000000..d616322ddc --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSourceTest.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2025 New Vector 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 + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.media.createFakeWaveform +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.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo +import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo +import io.element.android.libraries.mediaviewer.api.anApkMediaInfo +import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class SingleMediaGalleryDataSourceTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `function start is no op`() { + val sut = SingleMediaGalleryDataSource(aGroupedMediaItems()) + sut.start() + } + + @Test + fun `function loadMore is no op`() = runTest { + val sut = SingleMediaGalleryDataSource(aGroupedMediaItems()) + sut.loadMore(Timeline.PaginationDirection.BACKWARDS) + sut.loadMore(Timeline.PaginationDirection.FORWARDS) + } + + @Test + fun `function deleteItem is no op`() = runTest { + val sut = SingleMediaGalleryDataSource(aGroupedMediaItems()) + sut.deleteItem(AN_EVENT_ID) + } + + @Test + fun `getLastData should return the data`() { + val data = aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage()), + fileItems = listOf(aMediaItemFile()), + ) + val sut = SingleMediaGalleryDataSource(data) + assertThat(sut.getLastData()).isEqualTo(AsyncData.Success(data)) + } + + @Test + fun `groupedMediaItemsFlow emit a single item`() = runTest { + val data = aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage()), + fileItems = listOf(aMediaItemFile()), + ) + val sut = SingleMediaGalleryDataSource(data) + sut.groupedMediaItemsFlow().test { + assertThat(awaitItem()).isEqualTo(AsyncData.Success(data)) + awaitComplete() + } + } + + @Test + fun `createFrom should create a SingleMediaGalleryDataSource with an image item`() { + testFactory( + mediaInfo = anImageMediaInfo(), + expectedResult = { params -> + MediaItem.Image( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + thumbnailSource = params.thumbnailSource, + ) + } + ) + } + + @Test + fun `createFrom should create a SingleMediaGalleryDataSource with a video item`() { + testFactory( + mediaInfo = aVideoMediaInfo(), + expectedResult = { params -> + MediaItem.Video( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + thumbnailSource = params.thumbnailSource, + ) + } + ) + } + + @Test + fun `createFrom should create a SingleMediaGalleryDataSource with an audio item`() { + testFactory( + mediaInfo = anAudioMediaInfo(), + expectedResult = { params -> + MediaItem.Audio( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + ) + } + ) + } + + @Test + fun `createFrom should create a SingleMediaGalleryDataSource with a voice item`() { + testFactory( + mediaInfo = aVoiceMediaInfo( + waveForm = createFakeWaveform(), + duration = "12:34", + ), + expectedResult = { params -> + MediaItem.Voice( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + ) + } + ) + } + + @Test + fun `createFrom should create a SingleMediaGalleryDataSource with a file item`() { + testFactory( + mediaInfo = anApkMediaInfo(), + expectedResult = { params -> + MediaItem.File( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + ) + } + ) + } + + private fun testFactory( + mediaInfo: MediaInfo, + expectedResult: (MediaViewerEntryPoint.Params) -> 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() + } + + private fun aMediaViewerEntryPointParams( + mediaInfo: MediaInfo, + ) = MediaViewerEntryPoint.Params( + mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, + eventId = AN_EVENT_ID, + mediaInfo = mediaInfo, + mediaSource = aMediaSource(url = "aUrl"), + thumbnailSource = aMediaSource(url = "aThumbnailUrl"), + canShowInfo = true, + ) +} From b0bfea9cc4c3cd0b701e8c9d62fa5f5c191319a8 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 22 Jan 2025 10:25:13 +0100 Subject: [PATCH 19/38] Add test on MediaViewerDataSource --- .../impl/gallery/ui/MediaItemFileProvider.kt | 4 +- .../ui/MediaItemLoadingIndicatorProvider.kt | 3 +- .../impl/viewer/MediaViewerDataSource.kt | 6 +- .../impl/viewer/MediaViewerDataSourceTest.kt | 275 ++++++++++++++++++ 4 files changed, 284 insertions(+), 4 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt index 99fcb790c4..c80e287c30 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.core.preview.loremIpsum +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.media.MediaSource import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo @@ -30,12 +31,13 @@ class MediaItemFileProvider : PreviewParameterProvider { fun aMediaItemFile( id: UniqueId = UniqueId("fileId"), + eventId: EventId? = null, filename: String = "filename", caption: String? = null, ): MediaItem.File { return MediaItem.File( id = id, - eventId = null, + eventId = eventId, mediaInfo = aPdfMediaInfo( filename = filename, caption = caption, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt index 546d2f0127..2c78898325 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt @@ -13,10 +13,11 @@ import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem fun aMediaItemLoadingIndicator( id: UniqueId = UniqueId("loadingId"), + direction: Timeline.PaginationDirection = Timeline.PaginationDirection.BACKWARDS, ): MediaItem.LoadingIndicator { return MediaItem.LoadingIndicator( id = id, - direction = Timeline.PaginationDirection.BACKWARDS, + direction = direction, timestamp = 123, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt index 89c3fe943d..d03b003440 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.mediaviewer.impl.viewer +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.State @@ -63,7 +64,8 @@ class MediaViewerDataSource( return remember { dataFlow() }.collectAsState(initialData()) } - private fun dataFlow(): Flow> { + @VisibleForTesting + fun dataFlow(): Flow> { return galleryDataSource.groupedMediaItemsFlow() .map { groupedItems -> val mediaItems = groupedItems.dataOrNull()?.getItems(galleryMode).orEmpty() @@ -104,7 +106,7 @@ class MediaViewerDataSource( } } if (isEmpty()) { - MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS) + add(MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS)) } }.toPersistentList() diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt new file mode 100644 index 0000000000..092467a63d --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt @@ -0,0 +1,275 @@ +/* + * Copyright 2025 New Vector 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 android.net.Uri +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +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.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.mediaviewer.api.local.LocalMediaFactory +import io.element.android.libraries.mediaviewer.impl.gallery.FakeMediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode +import io.element.android.libraries.mediaviewer.impl.gallery.aGroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator +import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class MediaViewerDataSourceTest { + private val mockMediaUrl: Uri = mockk("localMediaUri") + + @Test + fun `setup should start the gallery data source`() = runTest { + val startLambda = lambdaRecorder { } + val galleryDataSource = FakeMediaGalleryDataSource( + startLambda = startLambda + ) + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + ) + sut.setup() + startLambda.assertions().isCalledOnce() + } + + @Test + fun `test dispose`() = runTest { + val sut = createMediaViewerDataSource() + sut.dispose() + } + + @Test + fun `test dataFlow uninitialized, loading and error`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems(AsyncData.Uninitialized) + assertThat(awaitItem().first()).isInstanceOf(MediaViewerPageData.Loading::class.java) + galleryDataSource.emitGroupedMediaItems(AsyncData.Loading()) + assertThat(awaitItem().first()).isInstanceOf(MediaViewerPageData.Loading::class.java) + galleryDataSource.emitGroupedMediaItems(AsyncData.Failure(AN_EXCEPTION)) + // TODO Add an error screen in the ui + assertThat(awaitItem().first()).isInstanceOf(MediaViewerPageData.Loading::class.java) + } + } + + @Test + fun `test dataFlow empty`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(), + fileItems = listOf(), + ) + ) + ) + val result = awaitItem() + assertThat(result).hasSize(1) + assertThat(result.first()).isEqualTo(MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS)) + } + } + + @Test + fun `test dataFlow loading items`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf( + aMediaItemLoadingIndicator( + direction = Timeline.PaginationDirection.BACKWARDS, + ), + aMediaItemLoadingIndicator( + direction = Timeline.PaginationDirection.FORWARDS, + ), + ), + fileItems = listOf(), + ) + ) + ) + val result = awaitItem() + assertThat(result).containsExactly( + MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS), + MediaViewerPageData.Loading(Timeline.PaginationDirection.FORWARDS), + ) + } + } + + @Test + fun `test dataFlow with data galleryMode image`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val sut = createMediaViewerDataSource( + galleryMode = MediaGalleryMode.Images, + galleryDataSource = galleryDataSource, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)), + fileItems = listOf(aMediaItemFile(eventId = AN_EVENT_ID_2)), + ) + ) + ) + val result = awaitItem() + assertThat(result).hasSize(1) + assertThat((result.first() as MediaViewerPageData.MediaViewerData).eventId).isEqualTo(AN_EVENT_ID) + } + } + + @Test + fun `test dataFlow with data galleryMode files`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val sut = createMediaViewerDataSource( + galleryMode = MediaGalleryMode.Files, + galleryDataSource = galleryDataSource, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)), + fileItems = listOf(aMediaItemFile(eventId = AN_EVENT_ID_2)), + ) + ) + ) + val result = awaitItem() + assertThat(result).hasSize(1) + assertThat((result.first() as MediaViewerPageData.MediaViewerData).eventId).isEqualTo(AN_EVENT_ID_2) + } + } + + @Test + fun `test dataFlow - date separator are filtered out`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemDateSeparator(), aMediaItemImage(), aMediaItemDateSeparator()), + fileItems = emptyList(), + ) + ) + ) + val result = awaitItem() + assertThat(result).hasSize(1) + } + } + + @Test + fun `loadMore invokes the gallery data source loadMore`() = runTest { + val loadMoreLambda = lambdaRecorder { } + val galleryDataSource = FakeMediaGalleryDataSource( + loadMoreLambda = loadMoreLambda + ) + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + ) + sut.loadMore(Timeline.PaginationDirection.BACKWARDS) + loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS)) + } + + @Test + fun `test dataFlow with data galleryMode image and load media`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)), + ) + ) + ) + val result = awaitItem() + val mediaViewerData = result.first() as MediaViewerPageData.MediaViewerData + assertThat(mediaViewerData.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized) + sut.loadMedia(mediaViewerData) + assertThat(mediaViewerData.downloadedMedia.value.isSuccess()).isTrue() + } + } + + @Test + fun `test dataFlow with data galleryMode image and load media with failure then success`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val mediaLoader = FakeMatrixMediaLoader() + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + mediaLoader = mediaLoader, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)), + ) + ) + ) + val result = awaitItem() + val mediaViewerData = result.first() as MediaViewerPageData.MediaViewerData + assertThat(mediaViewerData.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized) + mediaLoader.shouldFail = true + sut.loadMedia(mediaViewerData) + assertThat(mediaViewerData.downloadedMedia.value.isFailure()).isTrue() + // clear the error + sut.clearLoadingError(mediaViewerData) + assertThat(mediaViewerData.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized) + // load again with success + mediaLoader.shouldFail = false + sut.loadMedia(mediaViewerData) + assertThat(mediaViewerData.downloadedMedia.value.isSuccess()).isTrue() + } + } + + @Test + fun clearLoadingError() { + } + + private fun TestScope.createMediaViewerDataSource( + galleryMode: MediaGalleryMode = MediaGalleryMode.Images, + galleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(), + mediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(), + localMediaFactory: LocalMediaFactory = FakeLocalMediaFactory(mockMediaUrl), + ) = MediaViewerDataSource( + galleryMode = galleryMode, + dispatcher = testCoroutineDispatchers().computation, + galleryDataSource = galleryDataSource, + mediaLoader = mediaLoader, + localMediaFactory = localMediaFactory, + ) +} From 2267a4b7872f14255697410ebbb6cf573aed6bec Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 22 Jan 2025 11:15:53 +0100 Subject: [PATCH 20/38] MediaViewer: add error case in the UI. --- .../impl/viewer/MediaViewerDataSource.kt | 13 ++++- .../impl/viewer/MediaViewerState.kt | 4 ++ .../impl/viewer/MediaViewerStateProvider.kt | 5 ++ .../impl/viewer/MediaViewerView.kt | 56 +++++++++++++++++-- .../impl/viewer/MediaViewerDataSourceTest.kt | 3 +- 5 files changed, 71 insertions(+), 10 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt index d03b003440..97d5f626b0 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -28,6 +28,7 @@ import io.element.android.libraries.mediaviewer.impl.gallery.mediaInfo import io.element.android.libraries.mediaviewer.impl.gallery.mediaSource import io.element.android.libraries.mediaviewer.impl.gallery.thumbnailSource import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -68,9 +69,15 @@ class MediaViewerDataSource( fun dataFlow(): Flow> { return galleryDataSource.groupedMediaItemsFlow() .map { groupedItems -> - val mediaItems = groupedItems.dataOrNull()?.getItems(galleryMode).orEmpty() - withContext(dispatcher) { - buildMediaViewerPageList(mediaItems) + if (groupedItems is AsyncData.Failure) { + persistentListOf( + MediaViewerPageData.Failure(groupedItems.error), + ) + } else { + val mediaItems = groupedItems.dataOrNull()?.getItems(galleryMode).orEmpty() + withContext(dispatcher) { + buildMediaViewerPageList(mediaItems) + } } } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt index e2b8170a78..60c4889f60 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt @@ -28,6 +28,10 @@ data class MediaViewerState( ) sealed interface MediaViewerPageData { + data class Failure( + val throwable: Throwable, + ) : MediaViewerPageData + data class Loading( val direction: Timeline.PaginationDirection, ) : MediaViewerPageData diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt index 77f3698302..88931c90fc 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt @@ -154,6 +154,11 @@ open class MediaViewerStateProvider : PreviewParameterProvider MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS) ), ), + aMediaViewerState( + listOf( + MediaViewerPageData.Failure(Exception("error")) + ), + ), ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index b059805b01..5e9d527585 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -55,6 +55,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.designsystem.components.async.AsyncFailure 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 @@ -123,6 +124,14 @@ fun MediaViewerView( beyondViewportPageCount = 1, ) { page -> when (val dataForPage = state.listData[page]) { + is MediaViewerPageData.Failure -> { + MediaViewerErrorPage( + throwable = dataForPage.throwable, + onDismiss = { + onBackClick() + }, + ) + } is MediaViewerPageData.Loading -> { LaunchedEffect(Unit) { state.eventSink(MediaViewerEvents.LoadMore(dataForPage.direction)) @@ -200,11 +209,13 @@ fun MediaViewerView( else -> { TopAppBar( title = { - Text( - text = stringResource(id = CommonStrings.common_loading_more), - style = ElementTheme.typography.fontBodyMdMedium, - color = ElementTheme.colors.textPrimary, - ) + if (currentData is MediaViewerPageData.Loading) { + Text( + text = stringResource(id = CommonStrings.common_loading_more), + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + ) + } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent.copy(0.6f), @@ -386,6 +397,41 @@ private fun MediaViewerLoadingPage( } } +@Composable +private fun MediaViewerErrorPage( + throwable: Throwable, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false) + + DismissFlickEffects( + flickState = flickState, + onDismissing = { animationDuration -> + delay(animationDuration / 3) + onDismiss() + }, + onDragging = {}, + ) + + FlickToDismiss( + state = flickState, + modifier = modifier.background(backgroundColorFor(flickState)) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + contentAlignment = Alignment.Center + ) { + AsyncFailure( + throwable = throwable, + onRetry = null + ) + } + } +} + @Composable private fun DismissFlickEffects( flickState: FlickToDismissState, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt index 092467a63d..e7a684a44d 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt @@ -69,8 +69,7 @@ class MediaViewerDataSourceTest { galleryDataSource.emitGroupedMediaItems(AsyncData.Loading()) assertThat(awaitItem().first()).isInstanceOf(MediaViewerPageData.Loading::class.java) galleryDataSource.emitGroupedMediaItems(AsyncData.Failure(AN_EXCEPTION)) - // TODO Add an error screen in the ui - assertThat(awaitItem().first()).isInstanceOf(MediaViewerPageData.Loading::class.java) + assertThat(awaitItem().first()).isEqualTo(MediaViewerPageData.Failure(AN_EXCEPTION)) } } From f55da9027b862f803e6e2bb71b2bb8795e056f65 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 22 Jan 2025 11:27:57 +0100 Subject: [PATCH 21/38] Introduce MediaViewerFlickToDismiss and extract to its own file --- .../impl/viewer/MediaViewerFlickToDismiss.kt | 85 +++++++++++++++++ .../impl/viewer/MediaViewerView.kt | 95 ++----------------- 2 files changed, 95 insertions(+), 85 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt new file mode 100644 index 0000000000..aa8261df0a --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2025 New Vector 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 androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.delay +import me.saket.telephoto.flick.FlickToDismiss +import me.saket.telephoto.flick.FlickToDismissState +import me.saket.telephoto.flick.rememberFlickToDismissState +import kotlin.time.Duration + +@Composable +fun MediaViewerFlickToDismiss( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + onDragging: () -> Unit = {}, + content: @Composable BoxScope.() -> Unit, +) { + val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false) + DismissFlickEffects( + flickState = flickState, + onDismissing = { animationDuration -> + delay(animationDuration / 3) + onDismiss() + }, + onDragging = onDragging, + ) + FlickToDismiss( + state = flickState, + modifier = modifier.background(backgroundColorFor(flickState)), + content = content, + ) +} + +@Composable +private fun DismissFlickEffects( + flickState: FlickToDismissState, + onDismissing: suspend (Duration) -> Unit, + onDragging: suspend () -> Unit, +) { + val currentOnDismissing by rememberUpdatedState(onDismissing) + val currentOnDragging by rememberUpdatedState(onDragging) + + when (val gestureState = flickState.gestureState) { + is FlickToDismissState.GestureState.Dismissing -> { + LaunchedEffect(Unit) { + currentOnDismissing(gestureState.animationDuration) + } + } + is FlickToDismissState.GestureState.Dragging -> { + LaunchedEffect(Unit) { + currentOnDragging() + } + } + else -> Unit + } +} + +@Composable +private fun backgroundColorFor(flickState: FlickToDismissState): Color { + val animatedAlpha by animateFloatAsState( + targetValue = when (flickState.gestureState) { + is FlickToDismissState.GestureState.Dismissed, + is FlickToDismissState.GestureState.Dismissing -> 0f + is FlickToDismissState.GestureState.Dragging, + is FlickToDismissState.GestureState.Idle, + is FlickToDismissState.GestureState.Resetting -> 1f - flickState.offsetFraction + }, + label = "Background alpha", + ) + return Color.Black.copy(alpha = animatedAlpha) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 5e9d527585..8bb86936af 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -11,7 +11,6 @@ package io.element.android.libraries.mediaviewer.impl.viewer import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background @@ -81,13 +80,9 @@ import io.element.android.libraries.mediaviewer.impl.local.PlayableState import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.delay -import me.saket.telephoto.flick.FlickToDismiss -import me.saket.telephoto.flick.FlickToDismissState -import me.saket.telephoto.flick.rememberFlickToDismissState import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.rememberZoomableState import timber.log.Timber -import kotlin.time.Duration @Composable fun MediaViewerView( @@ -289,22 +284,13 @@ private fun MediaViewerPage( ) { val currentShowOverlay by rememberUpdatedState(showOverlay) val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange) - val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false) - DismissFlickEffects( - flickState = flickState, - onDismissing = { animationDuration -> - delay(animationDuration / 3) - onDismiss() - }, + MediaViewerFlickToDismiss( + onDismiss = onDismiss, onDragging = { currentOnShowOverlayChange(false) - } - ) - - FlickToDismiss( - state = flickState, - modifier = modifier.background(backgroundColorFor(flickState)) + }, + modifier = modifier, ) { val downloadedMedia by data.downloadedMedia val showProgress = rememberShowProgress(downloadedMedia) @@ -371,20 +357,9 @@ private fun MediaViewerLoadingPage( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false) - - DismissFlickEffects( - flickState = flickState, - onDismissing = { animationDuration -> - delay(animationDuration / 3) - onDismiss() - }, - onDragging = {}, - ) - - FlickToDismiss( - state = flickState, - modifier = modifier.background(backgroundColorFor(flickState)) + MediaViewerFlickToDismiss( + onDismiss = onDismiss, + modifier = modifier, ) { Box( modifier = Modifier @@ -403,20 +378,9 @@ private fun MediaViewerErrorPage( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false) - - DismissFlickEffects( - flickState = flickState, - onDismissing = { animationDuration -> - delay(animationDuration / 3) - onDismiss() - }, - onDragging = {}, - ) - - FlickToDismiss( - state = flickState, - modifier = modifier.background(backgroundColorFor(flickState)) + MediaViewerFlickToDismiss( + onDismiss = onDismiss, + modifier = modifier, ) { Box( modifier = Modifier @@ -432,30 +396,6 @@ private fun MediaViewerErrorPage( } } -@Composable -private fun DismissFlickEffects( - flickState: FlickToDismissState, - onDismissing: suspend (Duration) -> Unit, - onDragging: suspend () -> Unit, -) { - val currentOnDismissing by rememberUpdatedState(onDismissing) - val currentOnDragging by rememberUpdatedState(onDragging) - - when (val gestureState = flickState.gestureState) { - is FlickToDismissState.GestureState.Dismissing -> { - LaunchedEffect(Unit) { - currentOnDismissing(gestureState.animationDuration) - } - } - is FlickToDismissState.GestureState.Dragging -> { - LaunchedEffect(Unit) { - currentOnDragging() - } - } - else -> Unit - } -} - @Composable private fun rememberShowProgress(downloadedMedia: AsyncData): Boolean { var showProgress by remember { @@ -623,21 +563,6 @@ private fun ErrorView( ) } -@Composable -private fun backgroundColorFor(flickState: FlickToDismissState): Color { - val animatedAlpha by animateFloatAsState( - targetValue = when (flickState.gestureState) { - is FlickToDismissState.GestureState.Dismissed, - is FlickToDismissState.GestureState.Dismissing -> 0f - is FlickToDismissState.GestureState.Dragging, - is FlickToDismissState.GestureState.Idle, - is FlickToDismissState.GestureState.Resetting -> 1f - flickState.offsetFraction - }, - label = "Background alpha", - ) - return Color.Black.copy(alpha = animatedAlpha) -} - // Only preview in dark, dark theme is forced on the Node. @Preview @Composable From 5e506be0626ec8cd4d4f3cbcbfcd56e5edb0dbf6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 22 Jan 2025 11:40:42 +0100 Subject: [PATCH 22/38] Restore overlay when user cancel the dragging --- .../mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt | 9 +++++++++ .../libraries/mediaviewer/impl/viewer/MediaViewerView.kt | 3 +++ 2 files changed, 12 insertions(+) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt index aa8261df0a..dea8ee1df3 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt @@ -27,6 +27,7 @@ fun MediaViewerFlickToDismiss( onDismiss: () -> Unit, modifier: Modifier = Modifier, onDragging: () -> Unit = {}, + onResetting: () -> Unit = {}, content: @Composable BoxScope.() -> Unit, ) { val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false) @@ -37,6 +38,7 @@ fun MediaViewerFlickToDismiss( onDismiss() }, onDragging = onDragging, + onResetting = onResetting, ) FlickToDismiss( state = flickState, @@ -50,9 +52,11 @@ private fun DismissFlickEffects( flickState: FlickToDismissState, onDismissing: suspend (Duration) -> Unit, onDragging: suspend () -> Unit, + onResetting: suspend () -> Unit, ) { val currentOnDismissing by rememberUpdatedState(onDismissing) val currentOnDragging by rememberUpdatedState(onDragging) + val currentOnResetting by rememberUpdatedState(onResetting) when (val gestureState = flickState.gestureState) { is FlickToDismissState.GestureState.Dismissing -> { @@ -65,6 +69,11 @@ private fun DismissFlickEffects( currentOnDragging() } } + is FlickToDismissState.GestureState.Resetting -> { + LaunchedEffect(Unit) { + currentOnResetting() + } + } else -> Unit } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 8bb86936af..c04bddb3d8 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -290,6 +290,9 @@ private fun MediaViewerPage( onDragging = { currentOnShowOverlayChange(false) }, + onResetting = { + currentOnShowOverlayChange(true) + }, modifier = modifier, ) { val downloadedMedia by data.downloadedMedia From 40a44cae75d465df63886170a41320e01a1c3125 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 22 Jan 2025 15:04:34 +0100 Subject: [PATCH 23/38] Add timestamp to trigger back pagination. --- .../impl/viewer/MediaViewerDataSource.kt | 14 +++++++++-- .../impl/viewer/MediaViewerNode.kt | 5 +++- .../impl/viewer/MediaViewerState.kt | 1 + .../impl/viewer/MediaViewerStateProvider.kt | 12 +++++++++- .../impl/viewer/MediaViewerView.kt | 2 +- .../impl/viewer/MediaViewerDataSourceTest.kt | 24 +++++++++++++------ .../impl/viewer/MediaViewerPresenterTest.kt | 2 ++ 7 files changed, 48 insertions(+), 12 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt index 97d5f626b0..9138334e90 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -27,6 +27,7 @@ import io.element.android.libraries.mediaviewer.impl.gallery.eventId import io.element.android.libraries.mediaviewer.impl.gallery.mediaInfo import io.element.android.libraries.mediaviewer.impl.gallery.mediaSource import io.element.android.libraries.mediaviewer.impl.gallery.thumbnailSource +import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList @@ -42,6 +43,7 @@ class MediaViewerDataSource( private val galleryDataSource: MediaGalleryDataSource, private val mediaLoader: MatrixMediaLoader, private val localMediaFactory: LocalMediaFactory, + private val systemClock: SystemClock, ) { // List of media files that are currently being loaded private val mediaFiles: MutableList = mutableListOf() @@ -108,12 +110,20 @@ class MediaViewerDataSource( ) } is MediaItem.LoadingIndicator -> add( - MediaViewerPageData.Loading(mediaItem.direction) + MediaViewerPageData.Loading( + direction = mediaItem.direction, + timestamp = systemClock.epochMillis(), + ) ) } } if (isEmpty()) { - add(MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS)) + add( + MediaViewerPageData.Loading( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = systemClock.epochMillis(), + ) + ) } }.toPersistentList() diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index 05a5fb759c..e06b691520 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -27,6 +27,7 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode import io.element.android.libraries.mediaviewer.impl.gallery.SingleMediaGalleryDataSource import io.element.android.libraries.mediaviewer.impl.gallery.TimelineMediaGalleryDataSource +import io.element.android.services.toolbox.api.systemclock.SystemClock @ContributesNode(RoomScope::class) class MediaViewerNode @AssistedInject constructor( @@ -37,6 +38,7 @@ class MediaViewerNode @AssistedInject constructor( mediaLoader: MatrixMediaLoader, localMediaFactory: LocalMediaFactory, coroutineDispatchers: CoroutineDispatchers, + systemClock: SystemClock, ) : Node(buildContext, plugins = plugins), MediaViewerNavigator { private val inputs = inputs() @@ -77,7 +79,8 @@ class MediaViewerNode @AssistedInject constructor( galleryMode = galleryMode, galleryDataSource = mediaGallerySource, mediaLoader = mediaLoader, - localMediaFactory = localMediaFactory + localMediaFactory = localMediaFactory, + systemClock = systemClock, ) ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt index 60c4889f60..cf363a70f1 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt @@ -34,6 +34,7 @@ sealed interface MediaViewerPageData { data class Loading( val direction: Timeline.PaginationDirection, + val timestamp: Long, ) : MediaViewerPageData data class MediaViewerData( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt index 88931c90fc..2c54751fed 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt @@ -151,7 +151,7 @@ open class MediaViewerStateProvider : PreviewParameterProvider }, aMediaViewerState( listOf( - MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS) + aMediaViewerPageDataLoading() ), ), aMediaViewerState( @@ -162,6 +162,16 @@ open class MediaViewerStateProvider : PreviewParameterProvider ) } +fun aMediaViewerPageDataLoading( + direction: Timeline.PaginationDirection = Timeline.PaginationDirection.BACKWARDS, + timestamp: Long = 0L, +): MediaViewerPageData { + return MediaViewerPageData.Loading( + direction = direction, + timestamp = timestamp, + ) +} + fun aMediaViewerPageData( downloadedMedia: AsyncData = AsyncData.Uninitialized, mediaInfo: MediaInfo = anImageMediaInfo(), diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index c04bddb3d8..3246faa255 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -128,7 +128,7 @@ fun MediaViewerView( ) } is MediaViewerPageData.Loading -> { - LaunchedEffect(Unit) { + LaunchedEffect(dataForPage.timestamp) { state.eventSink(MediaViewerEvents.LoadMore(dataForPage.direction)) } MediaViewerLoadingPage( diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt index e7a684a44d..4af8fe0066 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt @@ -27,6 +27,8 @@ import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory +import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.testCoroutineDispatchers @@ -90,7 +92,12 @@ class MediaViewerDataSourceTest { ) val result = awaitItem() assertThat(result).hasSize(1) - assertThat(result.first()).isEqualTo(MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS)) + assertThat(result.first()).isEqualTo( + MediaViewerPageData.Loading( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = A_FAKE_TIMESTAMP, + ) + ) } } @@ -118,8 +125,14 @@ class MediaViewerDataSourceTest { ) val result = awaitItem() assertThat(result).containsExactly( - MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS), - MediaViewerPageData.Loading(Timeline.PaginationDirection.FORWARDS), + MediaViewerPageData.Loading( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = A_FAKE_TIMESTAMP, + ), + MediaViewerPageData.Loading( + direction = Timeline.PaginationDirection.FORWARDS, + timestamp = A_FAKE_TIMESTAMP, + ), ) } } @@ -255,10 +268,6 @@ class MediaViewerDataSourceTest { } } - @Test - fun clearLoadingError() { - } - private fun TestScope.createMediaViewerDataSource( galleryMode: MediaGalleryMode = MediaGalleryMode.Images, galleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(), @@ -270,5 +279,6 @@ class MediaViewerDataSourceTest { galleryDataSource = galleryDataSource, mediaLoader = mediaLoader, localMediaFactory = localMediaFactory, + systemClock = FakeSystemClock(), ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index 3b408cbab5..ac0acfe719 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -37,6 +37,7 @@ import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -593,6 +594,7 @@ class MediaViewerPresenterTest { galleryDataSource = mediaGalleryDataSource, mediaLoader = matrixMediaLoader, localMediaFactory = localMediaFactory, + systemClock = FakeSystemClock(), ), room = room, localMediaActions = localMediaActions, From c817112bf23ec2e81d08b8ae1f532c8c1dc1677d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 22 Jan 2025 15:12:29 +0100 Subject: [PATCH 24/38] Fix tests. --- .../impl/viewer/MediaViewerPresenterTest.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index ac0acfe719..00a9260f7b 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -72,7 +72,7 @@ class MediaViewerPresenterTest { ) presenter.test { val initialState = awaitFirstItem() - assertThat(initialState.listData).isEmpty() + assertThat(initialState.listData.singleOrNull()).isInstanceOf(MediaViewerPageData.Loading::class.java) assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isTrue() @@ -90,7 +90,7 @@ class MediaViewerPresenterTest { ) presenter.test { val initialState = awaitFirstItem() - assertThat(initialState.listData).isEmpty() + assertThat(initialState.listData.singleOrNull()).isInstanceOf(MediaViewerPageData.Loading::class.java) assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isFalse() @@ -108,7 +108,7 @@ class MediaViewerPresenterTest { ) presenter.test { val initialState = awaitFirstItem() - assertThat(initialState.listData).isEmpty() + assertThat(initialState.listData.singleOrNull()).isInstanceOf(MediaViewerPageData.Loading::class.java) assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isTrue() @@ -127,7 +127,7 @@ class MediaViewerPresenterTest { ) presenter.test { val initialState = awaitFirstItem() - assertThat(initialState.listData).isEmpty() + assertThat(initialState.listData.singleOrNull()).isInstanceOf(MediaViewerPageData.Loading::class.java) assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isTrue() @@ -146,7 +146,7 @@ class MediaViewerPresenterTest { val anImage = aMediaItemImage() presenter.test { val initialState = awaitFirstItem() - assertThat(initialState.listData).isEmpty() + assertThat(initialState.listData.singleOrNull()).isInstanceOf(MediaViewerPageData.Loading::class.java) mediaGalleryDataSource.emitGroupedMediaItems( AsyncData.Success( GroupedMediaItems( From cb5988935fecce5d1fb10711d4dcc895fe99b231 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 22 Jan 2025 06:40:12 +0000 Subject: [PATCH 25/38] Update screenshots --- ...libraries.mediaviewer.impl.viewer_MediaViewerView_14_en.png | 3 +++ ...libraries.mediaviewer.impl.viewer_MediaViewerView_15_en.png | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_14_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_15_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_14_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_14_en.png new file mode 100644 index 0000000000..5c79c5f49d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_14_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef76327acada9ef186ad21772323416d1cd34eccd93a4cad1fdd6a1084d894fe +size 8134 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_15_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_15_en.png new file mode 100644 index 0000000000..d218e4f0dd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_15_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f3b2b9938105b75e64782ea70ae925a261395788b14c9fc51725d7808299532 +size 5091 From 4c6f46e46ccf045c800179723dbd0b0d7a22207c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 23 Jan 2025 09:26:10 +0100 Subject: [PATCH 26/38] Ensure gallery is paginating to get new items. --- .../impl/gallery/MediaGalleryView.kt | 7 ++++ .../impl/viewer/MediaViewerDataSource.kt | 36 ++++++++++--------- .../impl/viewer/MediaViewerDataSourceTest.kt | 8 +---- .../impl/viewer/MediaViewerPresenterTest.kt | 10 +++--- 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt index bcd9b2a238..b71648b027 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt @@ -198,6 +198,13 @@ private fun MediaGalleryPage( ) { val groupedMediaItems = state.groupedMediaItems if (groupedMediaItems.isLoadingItems(mode)) { + // Need to trigger a pagination now if there is only one LoadingIndicator. + (groupedMediaItems.dataOrNull() + ?.getItems(mode)?.singleOrNull() as? MediaItem.LoadingIndicator)?.let { item -> + LaunchedEffect(item.timestamp) { + state.eventSink(MediaGalleryEvents.LoadMore(item.direction)) + } + } LoadingContent(mode) } else { when (groupedMediaItems) { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt index 9138334e90..5d610589b6 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -71,14 +71,26 @@ class MediaViewerDataSource( fun dataFlow(): Flow> { return galleryDataSource.groupedMediaItemsFlow() .map { groupedItems -> - if (groupedItems is AsyncData.Failure) { - persistentListOf( - MediaViewerPageData.Failure(groupedItems.error), - ) - } else { - val mediaItems = groupedItems.dataOrNull()?.getItems(galleryMode).orEmpty() - withContext(dispatcher) { - buildMediaViewerPageList(mediaItems) + when (groupedItems) { + AsyncData.Uninitialized, + is AsyncData.Loading -> { + persistentListOf( + MediaViewerPageData.Loading( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = systemClock.epochMillis(), + ) + ) + } + is AsyncData.Failure -> { + persistentListOf( + MediaViewerPageData.Failure(groupedItems.error), + ) + } + is AsyncData.Success -> { + withContext(dispatcher) { + val mediaItems = groupedItems.data.getItems(galleryMode) + buildMediaViewerPageList(mediaItems) + } } } } @@ -117,14 +129,6 @@ class MediaViewerDataSource( ) } } - if (isEmpty()) { - add( - MediaViewerPageData.Loading( - direction = Timeline.PaginationDirection.BACKWARDS, - timestamp = systemClock.epochMillis(), - ) - ) - } }.toPersistentList() fun clearLoadingError(data: MediaViewerPageData.MediaViewerData) { diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt index 4af8fe0066..5348eb2aa3 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt @@ -91,13 +91,7 @@ class MediaViewerDataSourceTest { ) ) val result = awaitItem() - assertThat(result).hasSize(1) - assertThat(result.first()).isEqualTo( - MediaViewerPageData.Loading( - direction = Timeline.PaginationDirection.BACKWARDS, - timestamp = A_FAKE_TIMESTAMP, - ) - ) + assertThat(result).isEmpty() } } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index 00a9260f7b..ac0acfe719 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -72,7 +72,7 @@ class MediaViewerPresenterTest { ) presenter.test { val initialState = awaitFirstItem() - assertThat(initialState.listData.singleOrNull()).isInstanceOf(MediaViewerPageData.Loading::class.java) + assertThat(initialState.listData).isEmpty() assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isTrue() @@ -90,7 +90,7 @@ class MediaViewerPresenterTest { ) presenter.test { val initialState = awaitFirstItem() - assertThat(initialState.listData.singleOrNull()).isInstanceOf(MediaViewerPageData.Loading::class.java) + assertThat(initialState.listData).isEmpty() assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isFalse() @@ -108,7 +108,7 @@ class MediaViewerPresenterTest { ) presenter.test { val initialState = awaitFirstItem() - assertThat(initialState.listData.singleOrNull()).isInstanceOf(MediaViewerPageData.Loading::class.java) + assertThat(initialState.listData).isEmpty() assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isTrue() @@ -127,7 +127,7 @@ class MediaViewerPresenterTest { ) presenter.test { val initialState = awaitFirstItem() - assertThat(initialState.listData.singleOrNull()).isInstanceOf(MediaViewerPageData.Loading::class.java) + assertThat(initialState.listData).isEmpty() assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isTrue() @@ -146,7 +146,7 @@ class MediaViewerPresenterTest { val anImage = aMediaItemImage() presenter.test { val initialState = awaitFirstItem() - assertThat(initialState.listData.singleOrNull()).isInstanceOf(MediaViewerPageData.Loading::class.java) + assertThat(initialState.listData).isEmpty() mediaGalleryDataSource.emitGroupedMediaItems( AsyncData.Success( GroupedMediaItems( From 3dcf921c1ac4d0d9cb9f1710100b9faacf8fcf25 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 23 Jan 2025 09:56:51 +0100 Subject: [PATCH 27/38] Fix formatting. --- .../libraries/mediaviewer/impl/gallery/MediaGalleryView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt index b71648b027..717aba99e2 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt @@ -199,8 +199,8 @@ private fun MediaGalleryPage( val groupedMediaItems = state.groupedMediaItems if (groupedMediaItems.isLoadingItems(mode)) { // Need to trigger a pagination now if there is only one LoadingIndicator. - (groupedMediaItems.dataOrNull() - ?.getItems(mode)?.singleOrNull() as? MediaItem.LoadingIndicator)?.let { item -> + val loadingItem = groupedMediaItems.dataOrNull()?.getItems(mode)?.singleOrNull() as? MediaItem.LoadingIndicator + loadingItem?.let { item -> LaunchedEffect(item.timestamp) { state.eventSink(MediaGalleryEvents.LoadMore(item.direction)) } From b967c877a6ad7ca664f82b8b412e1624f002f821 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 23 Jan 2025 14:49:18 +0100 Subject: [PATCH 28/38] Remove useless parameter --- .../impl/gallery/MediaGalleryView.kt | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt index 717aba99e2..ba8bcba4ad 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt @@ -108,15 +108,15 @@ fun MediaGalleryView( ) { paddingValues -> Column( modifier = Modifier - .padding(paddingValues) - .consumeWindowInsets(paddingValues) - .fillMaxSize(), + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .fillMaxSize(), verticalArrangement = Arrangement.spacedBy(2.dp), ) { SingleChoiceSegmentedButtonRow( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + .fillMaxWidth() + .padding(horizontal = 16.dp), ) { MediaGalleryMode.entries.forEach { mode -> SegmentedButton( @@ -137,7 +137,6 @@ fun MediaGalleryView( HorizontalPager( state = pagerState, userScrollEnabled = false, - modifier = Modifier, ) { page -> val mode = MediaGalleryMode.entries[page] MediaGalleryPage( @@ -355,8 +354,8 @@ private fun MediaGalleryImageGrid( ) { LazyVerticalGrid( modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), + .fillMaxSize() + .padding(horizontal = 16.dp), columns = GridCells.Adaptive(80.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), @@ -427,9 +426,9 @@ private fun LoadingMoreIndicator( Timeline.PaginationDirection.FORWARDS -> { LinearProgressIndicator( modifier = Modifier - .fillMaxWidth() - .padding(top = 2.dp) - .height(1.dp) + .fillMaxWidth() + .padding(top = 2.dp) + .height(1.dp) ) } Timeline.PaginationDirection.BACKWARDS -> { @@ -467,9 +466,9 @@ private fun EmptyContent( OnboardingBackground() PageTitle( modifier = Modifier - .fillMaxWidth() - .padding(top = 44.dp) - .padding(24.dp), + .fillMaxWidth() + .padding(top = 44.dp) + .padding(24.dp), title = stringResource(titleRes), iconStyle = BigIcon.Style.Default(icon), subtitle = stringResource(subtitleRes), @@ -487,9 +486,9 @@ private fun LoadingContent( OnboardingBackground() Column( modifier = Modifier - .fillMaxSize() - .padding(top = 48.dp) - .padding(24.dp), + .fillMaxSize() + .padding(top = 48.dp) + .padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { From fae1c0800d39559ced0cbbfcaf25f036119290bf Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 23 Jan 2025 14:57:05 +0100 Subject: [PATCH 29/38] Simplify with code `coerceAtLeast(0)`` --- .../libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index a8a22b21d8..a801437e82 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -206,8 +206,6 @@ class MediaViewerPresenter @AssistedInject constructor( } return data.indexOfFirst { (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId - } - .takeIf { it != -1 } - ?: 0 + }.coerceAtLeast(0) } } From d1d323424dbb09ad0694761b5aaa4f63501d5e66 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 23 Jan 2025 14:57:59 +0100 Subject: [PATCH 30/38] Simplify --- .../libraries/mediaviewer/impl/viewer/MediaViewerView.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 3246faa255..80408933bf 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -122,9 +122,7 @@ fun MediaViewerView( is MediaViewerPageData.Failure -> { MediaViewerErrorPage( throwable = dataForPage.throwable, - onDismiss = { - onBackClick() - }, + onDismiss = onBackClick, ) } is MediaViewerPageData.Loading -> { @@ -132,9 +130,7 @@ fun MediaViewerView( state.eventSink(MediaViewerEvents.LoadMore(dataForPage.direction)) } MediaViewerLoadingPage( - onDismiss = { - onBackClick() - }, + onDismiss = onBackClick, ) } is MediaViewerPageData.MediaViewerData -> { From 0b5ad293913e834dffd2a621e3722d0d1677a72f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 23 Jan 2025 15:03:40 +0100 Subject: [PATCH 31/38] Add documentation on buildMediaViewerPageList. --- .../mediaviewer/impl/viewer/MediaViewerDataSource.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt index 5d610589b6..b185834c3e 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -102,6 +102,11 @@ class MediaViewerDataSource( return buildMediaViewerPageList(initialMediaItems) } + /** + * Build a list of [MediaViewerPageData] from a list of [MediaItem]. + * In particular, create a mutable state of AsyncData for each media item, which + * will be used to render the downloaded media (see [loadMedia] which will update this value). + */ private fun buildMediaViewerPageList(groupedItems: List) = buildList { groupedItems.forEach { mediaItem -> when (mediaItem) { From c3ec11dec54aea100b9e077a3cfe7e6d36316cab Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 23 Jan 2025 15:06:10 +0100 Subject: [PATCH 32/38] Add name to argument for clarity. --- .../libraries/mediaviewer/impl/viewer/MediaViewerView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 80408933bf..868498942f 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -209,7 +209,7 @@ fun MediaViewerView( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent.copy(0.6f), + containerColor = Color.Transparent.copy(alpha = 0.6f), ), navigationIcon = { BackButton(onClick = onBackClick) }, ) @@ -455,7 +455,7 @@ private fun MediaViewerTopBar( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent.copy(0.6f), + containerColor = Color.Transparent.copy(alpha = 0.6f), ), navigationIcon = { BackButton(onClick = onBackClick) }, actions = { From ed80ca9899ac8558133aa0b9e3f930232ed04700 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 23 Jan 2025 15:08:40 +0100 Subject: [PATCH 33/38] Use Black for code clarity. --- .../libraries/mediaviewer/impl/viewer/MediaViewerView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 868498942f..ddb9c0923c 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -209,7 +209,7 @@ fun MediaViewerView( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent.copy(alpha = 0.6f), + containerColor = Color.Black.copy(alpha = 0.6f), ), navigationIcon = { BackButton(onClick = onBackClick) }, ) @@ -455,7 +455,7 @@ private fun MediaViewerTopBar( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent.copy(alpha = 0.6f), + containerColor = Color.Black.copy(alpha = 0.6f), ), navigationIcon = { BackButton(onClick = onBackClick) }, actions = { From 8d049e134fb391d3531d9ddaccce3da18946216e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 23 Jan 2025 15:37:09 +0100 Subject: [PATCH 34/38] Fix color for media viewer according to Figma. --- .../local/player/MediaPlayerControllerView.kt | 4 ++-- .../libraries/mediaviewer/impl/util/Colors.kt | 16 ++++++++++++++++ .../impl/viewer/MediaViewerFlickToDismiss.kt | 3 ++- .../mediaviewer/impl/viewer/MediaViewerView.kt | 7 ++++--- 4 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/util/Colors.kt diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt index 3d1cef88e8..67868be7dc 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt @@ -26,7 +26,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter @@ -40,6 +39,7 @@ 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.Slider import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency import io.element.android.libraries.ui.strings.CommonStrings @Composable @@ -58,7 +58,7 @@ fun MediaPlayerControllerView( ) { Box( modifier = Modifier - .background(color = Color(0x99101317)) + .background(color = bgCanvasWithTransparency) .padding(horizontal = 8.dp, vertical = 4.dp), contentAlignment = Alignment.Center, ) { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/util/Colors.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/util/Colors.kt new file mode 100644 index 0000000000..5105f1ba9b --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/util/Colors.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 New Vector 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.util + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import io.element.android.compound.theme.ElementTheme + +val bgCanvasWithTransparency: Color + @Composable + get() = ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.6f) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt index dea8ee1df3..1b99cdfa03 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import io.element.android.compound.theme.ElementTheme import kotlinx.coroutines.delay import me.saket.telephoto.flick.FlickToDismiss import me.saket.telephoto.flick.FlickToDismissState @@ -90,5 +91,5 @@ private fun backgroundColorFor(flickState: FlickToDismissState): Color { }, label = "Background alpha", ) - return Color.Black.copy(alpha = animatedAlpha) + return ElementTheme.colors.bgCanvasDefault.copy(alpha = animatedAlpha) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index ddb9c0923c..1ce74b03a1 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -78,6 +78,7 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaDetailsBottomS import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView import io.element.android.libraries.mediaviewer.impl.local.PlayableState import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState +import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.delay import me.saket.telephoto.zoomable.ZoomSpec @@ -209,7 +210,7 @@ fun MediaViewerView( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Black.copy(alpha = 0.6f), + containerColor = bgCanvasWithTransparency, ), navigationIcon = { BackButton(onClick = onBackClick) }, ) @@ -455,7 +456,7 @@ private fun MediaViewerTopBar( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Black.copy(alpha = 0.6f), + containerColor = bgCanvasWithTransparency, ), navigationIcon = { BackButton(onClick = onBackClick) }, actions = { @@ -501,7 +502,7 @@ private fun MediaViewerBottomBar( Column( modifier = modifier .fillMaxWidth() - .background(Color(0x99101317)) + .background(bgCanvasWithTransparency) .onSizeChanged { onHeightChange(it.height) }, From dbf511ee2a2e4b75d18e43b2f7f39827a0534b15 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 23 Jan 2025 15:42:00 +0100 Subject: [PATCH 35/38] Cleanup --- .../libraries/mediaviewer/impl/viewer/MediaViewerView.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 1ce74b03a1..695725009f 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -147,9 +147,7 @@ fun MediaViewerView( showOverlay = showOverlay, bottomPaddingInPixels = bottomPaddingInPixels, data = dataForPage, - onDismiss = { - onBackClick() - }, + onDismiss = onBackClick, onRetry = { state.eventSink(MediaViewerEvents.LoadMedia(dataForPage)) }, From c20a9d011c4a34a7485da1f475852de1a476c772 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 23 Jan 2025 15:43:33 +0100 Subject: [PATCH 36/38] Improve code clarity --- .../libraries/mediaviewer/impl/gallery/MediaGalleryView.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt index ba8bcba4ad..3f5e1fc107 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt @@ -199,9 +199,9 @@ private fun MediaGalleryPage( if (groupedMediaItems.isLoadingItems(mode)) { // Need to trigger a pagination now if there is only one LoadingIndicator. val loadingItem = groupedMediaItems.dataOrNull()?.getItems(mode)?.singleOrNull() as? MediaItem.LoadingIndicator - loadingItem?.let { item -> - LaunchedEffect(item.timestamp) { - state.eventSink(MediaGalleryEvents.LoadMore(item.direction)) + if (loadingItem != null) { + LaunchedEffect(loadingItem.timestamp) { + state.eventSink(MediaGalleryEvents.LoadMore(loadingItem.direction)) } } LoadingContent(mode) From 4ae773cfd68c6b78481e33a9c4151c13ab2aa685 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 23 Jan 2025 14:56:31 +0000 Subject: [PATCH 37/38] Update screenshots --- ...s.mediaviewer.impl.local.audio_MediaAudioView_Day_0_en.png | 4 ++-- ...s.mediaviewer.impl.local.audio_MediaAudioView_Day_1_en.png | 4 ++-- ...r.impl.local.player_MediaPlayerControllerView_Day_0_en.png | 4 ++-- ...r.impl.local.player_MediaPlayerControllerView_Day_1_en.png | 4 ++-- ...r.impl.local.player_MediaPlayerControllerView_Day_2_en.png | 4 ++-- ...s.mediaviewer.impl.local.video_MediaVideoView_Day_0_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png | 4 ++-- ...ibraries.mediaviewer.impl.viewer_MediaViewerView_10_en.png | 4 ++-- ...ibraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png | 4 ++-- ...ibraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png | 4 ++-- ...ibraries.mediaviewer.impl.viewer_MediaViewerView_13_en.png | 4 ++-- ...ibraries.mediaviewer.impl.viewer_MediaViewerView_14_en.png | 4 ++-- ...ibraries.mediaviewer.impl.viewer_MediaViewerView_15_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png | 4 ++-- 22 files changed, 44 insertions(+), 44 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_0_en.png index 70d447adcd..10f75384f6 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c7d4201ed9aa37995f4ab8ac982404f59e77374f316a057685886f14e698c35 -size 24680 +oid sha256:8d8842663702441ce586c7e2141c0cdf47032a26b6015e592abb5682e3cd2c60 +size 25152 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_1_en.png index 41b0cc2f9a..3eb086fab4 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b94fd31b7ed71eacfe8f136bfd59405b85d31a0fe557800311794f4ba7006271 -size 22749 +oid sha256:e57fc21cd01917630a08324f49a3d76d82823ee4a2f90f23624c120126426689 +size 23190 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_0_en.png index 93d2782dbb..c497a8cb26 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:333a21a41a7d5f8d47946648a7381dadeccf213a1176696b3512c08ae929d4d6 -size 7819 +oid sha256:ddb495eaf8113f0be1ba572697083bd5b8ccd4c308780478b6c4691dd0f8d922 +size 8019 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_1_en.png index a40e83dc48..257beda294 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cba8f49caf65856569266a561206437840a43da2d41ed5f08321ecda99204329 -size 8236 +oid sha256:22e4744300d62e550a9c545420621fe6aa8db1674a876731be31a12fbc426cbc +size 7320 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_2_en.png index b5b75d1b63..a9f023e618 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a6d04e0ee068682ebb0a3842ba73407855f2b83b7389d26fa0f3e2ec20d42dc8 -size 7389 +oid sha256:5076cbf15e1d0ec2c70432c88a5f48eb074490bdf8431cfb51109f91ad4e9576 +size 7495 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.video_MediaVideoView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.video_MediaVideoView_Day_0_en.png index 6ab81fd4de..4bd792bedf 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.video_MediaVideoView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.video_MediaVideoView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:267d4be8b727a0ecb5af1e5f1e69adfd68f50f32f1de78f4f9fde60f635244b5 -size 13045 +oid sha256:b3599a3a6fb43f98d928ce71c3ef7a8b8102d558c31ea1f735a948e337738f95 +size 13533 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png index fd5c2af6e6..0ab3d99837 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66437179fb0b851d4d4d647d00cab94cc7422d625f559839c675b378dbf1af38 -size 389408 +oid sha256:1e4ef6ed6fe4c858ac4c67bf2bc5d428f4d4734cf8b01244a3bc963a815a540a +size 389328 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_10_en.png index 6a81f3f129..81afb4ea1c 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_10_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2b286342ff4d46637beac1f980294f77b3e2eb6824d56448cdbdce7b41c911ab -size 388612 +oid sha256:67ca752577251e9e57e7680ae1bbdef3324c84ec6a1037aa9f7c228bb8206f4c +size 388634 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png index 4a43ce31da..b66dd0d506 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8165bcb4b0d52a227aad4e1f3951fc3628ef647947ef2584ed50d8ede8a6a344 -size 38248 +oid sha256:96941aab9596583187e4a089bd448252be551e3ef6b2fb7550c3bad5c7ba60fb +size 37905 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png index f3d0c19a9f..2b16d56f04 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c9efb4ed1ee82bba30351bf213f0873637e8194140a3bbea669321bf76bc6483 -size 31449 +oid sha256:add442c1cabc79cde42e65775b22413c92918bba93d96bb72a9ff214b9ae7fac +size 31126 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_13_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_13_en.png index 6d8afe1140..a227321f6b 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_13_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5d2882d79b9f66726c6d16c8c6fc84cb9f65a5674222fe85794229dc5ba12a6b -size 24679 +oid sha256:0c288f75fcccb93e074afd8178219887ce8a541d8e444df18ca041647114d340 +size 24491 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_14_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_14_en.png index 5c79c5f49d..c96f6f4bfb 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_14_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef76327acada9ef186ad21772323416d1cd34eccd93a4cad1fdd6a1084d894fe -size 8134 +oid sha256:9a60bd02d969c7c6ccc0eb2e8f4f2c5551b8d910e5bb2f96710589040541691d +size 7973 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_15_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_15_en.png index d218e4f0dd..205d657d46 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_15_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_15_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f3b2b9938105b75e64782ea70ae925a261395788b14c9fc51725d7808299532 -size 5091 +oid sha256:049993637421009db857dbd8a647d268241cd0f88a12a376804444647f94e885 +size 5069 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png index 12d5df3fa1..73e4bde51e 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da172fdf40dc8702bc6dcb89bcc75e93bd279f6bbb9454f5283febe4da25d399 -size 389440 +oid sha256:248ad0bbfc8c8a56975c2fc6ddfa5275ff4f3ad39b9a76126c8d4bdd0c566e88 +size 389354 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png index bf9ae5ebac..ef7f299a26 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ee478f10b781385a5bd472a9fb1047e869ca2e46e1bab28758315722ff911bbc -size 94992 +oid sha256:ecdd1219635a61be5473773464d3796ea6a8f17ac2c384e131265e9557dadf42 +size 95129 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png index 40ff36cd94..adf626696d 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7ad6e45382dec9bb27593b6e2ed92ed633204479040ccebaf4d362bb2f41fec7 -size 396403 +oid sha256:c89d437ccf40ac25227d19d1b775ae0992cad7970784e29586438a60f6bf950b +size 396206 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png index d94989c757..cf56e5a13b 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d10cb9be5b5139f0fdfdfb11cc3d3eca1955297180e5db8142bfea6250f20d73 -size 25811 +oid sha256:9ac33be436135d5022024108ec403ea3e53995d8e42dcc52bbd3d8041e5e2975 +size 25053 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png index 3603786361..75b5e2302f 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:69b5d7572ae6e4ff084867fac1bae41a55c75c9a5236cb6ccb4c31b89ef77898 -size 5442 +oid sha256:4d0288375c9e746d4cbb9b270c1c7f5e97633d76027186bd9568555edeaf8700 +size 5411 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png index 6b6e6a655d..2bfbd92c3b 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f8d3e3f8733424870b254be90599ed1ff6ba784089600bcb200fbef62c81537c -size 14562 +oid sha256:c756b50710b1ed10eea01d8f97d8cca79a3b3440c559e1f593c790b55e5f6556 +size 14194 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png index 32e7fcef89..be7ed165cf 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d1785d90957316791969f047e66bd779da62d675004914099f2af2b69bebe405 -size 14700 +oid sha256:2c5a6e30127fe88d1d55da6daebff64981e88bbf7a37c712c99f831849e56172 +size 14374 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png index dc33c0aef4..654e1d891e 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1ddcd8e9e20de4171a3d9f8175806a268723e47a14dca431849c2c29edaf5d0b -size 26267 +oid sha256:1ddd7d087dac24b5f2861b59c946eb89046b9bcb1a709dca423b8893f55a81f9 +size 26217 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png index d115aaa7ed..f230ae125e 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:035ef0079af6e9825a52b86e2eab50667404a66d70dd2756596b60cc1cea376a -size 26404 +oid sha256:8a0e84ee0e17fa30222ffb5fe58f353ca847b251a8da7088a58faa467f1f6742 +size 26258 From 31a7d3f3bb248bfdcf0c9943cb09fc3a890573b7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 23 Jan 2025 17:44:31 +0100 Subject: [PATCH 38/38] Fix pagination restart issue and cover by unit test. --- .../matrix/impl/timeline/RustTimeline.kt | 6 +- .../impl/fixtures/fakes/FakeRustTimeline.kt | 14 ++ .../matrix/impl/timeline/RustTimelineTest.kt | 124 ++++++++++++++++++ .../test/systemclock/FakeSystemClock.kt | 8 +- 4 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index 3e10ce1a5b..4908d73d2e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -55,8 +55,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.launchIn @@ -213,8 +211,8 @@ class RustTimeline( override val timelineItems: Flow> = combine( _timelineItems, - backPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(), - forwardPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(), + backPaginationStatus, + forwardPaginationStatus, matrixRoom.roomInfoFlow.map { it.creator }, isTimelineInitialized, ) { timelineItems, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustTimeline.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustTimeline.kt index e269a30b1b..042d4c98a1 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustTimeline.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustTimeline.kt @@ -8,10 +8,12 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes import org.matrix.rustcomponents.sdk.NoPointer +import org.matrix.rustcomponents.sdk.PaginationStatusListener import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.Timeline import org.matrix.rustcomponents.sdk.TimelineDiff import org.matrix.rustcomponents.sdk.TimelineListener +import uniffi.matrix_sdk_ui.LiveBackPaginationStatus class FakeRustTimeline : Timeline(NoPointer) { private var listener: TimelineListener? = null @@ -23,4 +25,16 @@ class FakeRustTimeline : Timeline(NoPointer) { fun emitDiff(diff: List) { listener!!.onUpdate(diff) } + + private var paginationStatusListener: PaginationStatusListener? = null + override suspend fun subscribeToBackPaginationStatus(listener: PaginationStatusListener): TaskHandle { + this.paginationStatusListener = listener + return FakeRustTaskHandle() + } + + fun emitPaginationStatus(status: LiveBackPaginationStatus) { + paginationStatusListener!!.onUpdate(status) + } + + override suspend fun fetchMembers() = Unit } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt new file mode 100644 index 0000000000..8e9120a839 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.matrix.impl.timeline + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustRoomListService +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustTimeline +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustTimelineDiff +import io.element.android.libraries.matrix.impl.room.RoomContentForwarder +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.services.toolbox.api.systemclock.SystemClock +import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.rustcomponents.sdk.TimelineChange +import uniffi.matrix_sdk_ui.LiveBackPaginationStatus +import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline + +class RustTimelineTest { + @Test + fun `ensure that the timeline emits new loading item when pagination does not bring new events`() = runTest { + val inner = FakeRustTimeline() + val systemClock = FakeSystemClock() + val sut = createRustTimeline( + inner = inner, + systemClock = systemClock, + ) + sut.timelineItems.test { + // Give time for the listener to be set + runCurrent() + inner.emitDiff( + listOf( + FakeRustTimelineDiff( + item = null, + change = TimelineChange.RESET, + ) + ) + ) + with(awaitItem()) { + assertThat(size).isEqualTo(1) + // Typing notification + assertThat((get(0) as MatrixTimelineItem.Virtual).virtual).isEqualTo(VirtualTimelineItem.TypingNotification) + } + with(awaitItem()) { + assertThat(size).isEqualTo(2) + // The loading + assertThat((get(0) as MatrixTimelineItem.Virtual).virtual).isEqualTo( + VirtualTimelineItem.LoadingIndicator( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = A_FAKE_TIMESTAMP, + ) + ) + // Typing notification + assertThat((get(1) as MatrixTimelineItem.Virtual).virtual).isEqualTo(VirtualTimelineItem.TypingNotification) + } + systemClock.epochMillisResult = A_FAKE_TIMESTAMP + 1 + // Start pagination + sut.paginate(Timeline.PaginationDirection.BACKWARDS) + // Simulate SDK starting pagination + inner.emitPaginationStatus(LiveBackPaginationStatus.Paginating) + // No new events received + // Simulate SDK stopping pagination, more event to load + inner.emitPaginationStatus(LiveBackPaginationStatus.Idle(hitStartOfTimeline = false)) + // expect an item to be emitted, with an updated timestamp + with(awaitItem()) { + assertThat(size).isEqualTo(2) + // The loading + assertThat((get(0) as MatrixTimelineItem.Virtual).virtual).isEqualTo( + VirtualTimelineItem.LoadingIndicator( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = A_FAKE_TIMESTAMP + 1, + ) + ) + // Typing notification + assertThat((get(1) as MatrixTimelineItem.Virtual).virtual).isEqualTo(VirtualTimelineItem.TypingNotification) + } + } + } +} + +private fun TestScope.createRustTimeline( + inner: InnerTimeline, + mode: Timeline.Mode = Timeline.Mode.LIVE, + systemClock: SystemClock = FakeSystemClock(), + matrixRoom: MatrixRoom = FakeMatrixRoom().apply { givenRoomInfo(aRoomInfo()) }, + coroutineScope: CoroutineScope = backgroundScope, + dispatcher: CoroutineDispatcher = testCoroutineDispatchers().io, + roomContentForwarder: RoomContentForwarder = RoomContentForwarder(FakeRustRoomListService()), + featureFlagsService: FeatureFlagService = FakeFeatureFlagService(), + onNewSyncedEvent: () -> Unit = {}, +): RustTimeline { + return RustTimeline( + inner = inner, + mode = mode, + systemClock = systemClock, + matrixRoom = matrixRoom, + coroutineScope = coroutineScope, + dispatcher = dispatcher, + roomContentForwarder = roomContentForwarder, + featureFlagsService = featureFlagsService, + onNewSyncedEvent = onNewSyncedEvent, + ) +} diff --git a/services/toolbox/test/src/main/kotlin/io/element/android/services/toolbox/test/systemclock/FakeSystemClock.kt b/services/toolbox/test/src/main/kotlin/io/element/android/services/toolbox/test/systemclock/FakeSystemClock.kt index 3835b163ac..444502aea8 100644 --- a/services/toolbox/test/src/main/kotlin/io/element/android/services/toolbox/test/systemclock/FakeSystemClock.kt +++ b/services/toolbox/test/src/main/kotlin/io/element/android/services/toolbox/test/systemclock/FakeSystemClock.kt @@ -11,8 +11,8 @@ import io.element.android.services.toolbox.api.systemclock.SystemClock const val A_FAKE_TIMESTAMP = 123L -class FakeSystemClock : SystemClock { - override fun epochMillis(): Long { - return A_FAKE_TIMESTAMP - } +class FakeSystemClock( + var epochMillisResult: Long = A_FAKE_TIMESTAMP +) : SystemClock { + override fun epochMillis() = epochMillisResult }