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..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 @@ -246,6 +248,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, @@ -447,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/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/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/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..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 @@ -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, @@ -55,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..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 @@ -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 @@ -102,6 +101,7 @@ class EventItemFactory @Inject constructor( dateSent = dateSent, dateSentFull = dateSentFull, waveform = null, + duration = null, ), mediaSource = type.source, ) @@ -120,8 +120,10 @@ class EventItemFactory @Inject constructor( dateSent = dateSent, dateSentFull = dateSentFull, waveform = null, + 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, @@ -138,9 +140,10 @@ class EventItemFactory @Inject constructor( dateSent = dateSent, dateSentFull = dateSentFull, waveform = null, + duration = null, ), mediaSource = type.source, - thumbnailSource = null, + thumbnailSource = type.info?.thumbnailSource, ) is StickerMessageType -> MediaItem.Image( id = currentTimelineItem.uniqueId, @@ -157,9 +160,10 @@ class EventItemFactory @Inject constructor( dateSent = dateSent, dateSentFull = dateSentFull, waveform = null, + duration = null, ), mediaSource = type.source, - thumbnailSource = null, + thumbnailSource = type.info?.thumbnailSource, ) is VideoMessageType -> MediaItem.Video( id = currentTimelineItem.uniqueId, @@ -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/MediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt new file mode 100644 index 0000000000..0e12653108 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt @@ -0,0 +1,100 @@ +/* + * 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 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 +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 + +interface MediaGalleryDataSource { + fun start() + fun groupedMediaItemsFlow(): Flow> + fun getLastData(): AsyncData + suspend fun loadMore(direction: Timeline.PaginationDirection) + suspend fun deleteItem(eventId: EventId) +} + +@SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) +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) + + override fun groupedMediaItemsFlow(): Flow> = groupedMediaItemsFlow + + override fun getLastData(): AsyncData = groupedMediaItemsFlow.replayCache.firstOrNull() ?: AsyncData.Uninitialized + + private val isStarted = AtomicBoolean(false) + + @OptIn(ExperimentalCoroutinesApi::class) + override 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) + } + + override suspend fun loadMore(direction: Timeline.PaginationDirection) { + timeline?.paginate(direction) + } + + 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 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,39 +62,17 @@ 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() } - val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() - localMediaActions.Configure() + .collectAsState(AsyncData.Uninitialized) - 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() - } + mediaGalleryDataSource.start() } - MediaListEffect( - timeline = timeline, - onItemsChange = { newItems -> - mediaItems = newItems - } - ) + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + localMediaActions.Configure() fun handleEvents(event: MediaGalleryEvents) { when (event) { @@ -114,16 +80,18 @@ class MediaGalleryPresenter @AssistedInject constructor( 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/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/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..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 @@ -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( @@ -198,6 +197,13 @@ private fun MediaGalleryPage( ) { val groupedMediaItems = state.groupedMediaItems 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 + if (loadingItem != null) { + LaunchedEffect(loadingItem.timestamp) { + state.eventSink(MediaGalleryEvents.LoadMore(loadingItem.direction)) + } + } LoadingContent(mode) } else { when (groupedMediaItems) { @@ -348,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), @@ -420,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 -> { @@ -460,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), @@ -480,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, ) { 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/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()) { 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..8e6b708a7f --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.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.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.coroutines.flow.flowOf + +class SingleMediaGalleryDataSource( + 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() -> { + MediaItem.Image( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + thumbnailSource = params.thumbnailSource, + ) + } + params.mediaInfo.mimeType.isMimeTypeVideo() -> { + 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) { + MediaItem.Audio( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + ) + } else { + MediaItem.Voice( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + ) + } + } + else -> { + 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/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/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/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/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/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, ) ) } 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/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/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/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/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt new file mode 100644 index 0000000000..b185834c3e --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -0,0 +1,175 @@ +/* + * 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.annotation.VisibleForTesting +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.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 io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +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, + private val systemClock: SystemClock, +) { + // 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() + } + + @Composable + fun collectAsState(): State> { + return remember { dataFlow() }.collectAsState(initialData()) + } + + @VisibleForTesting + fun dataFlow(): Flow> { + return galleryDataSource.groupedMediaItemsFlow() + .map { groupedItems -> + 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) + } + } + } + } + } + + private fun initialData(): PersistentList { + val initialMediaItems = + galleryDataSource.getLastData().dataOrNull()?.getItems(galleryMode).orEmpty() + 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) { + 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( + direction = mediaItem.direction, + timestamp = systemClock.epochMillis(), + ) + ) + } + } + }.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) { + 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) + } + } +} 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..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 @@ -8,16 +8,23 @@ 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 ClearLoadingError(val data: MediaViewerPageData.MediaViewerData) : 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/MediaViewerFlickToDismiss.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt new file mode 100644 index 0000000000..1b99cdfa03 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt @@ -0,0 +1,95 @@ +/* + * 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 io.element.android.compound.theme.ElementTheme +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 = {}, + onResetting: () -> Unit = {}, + content: @Composable BoxScope.() -> Unit, +) { + val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false) + DismissFlickEffects( + flickState = flickState, + onDismissing = { animationDuration -> + delay(animationDuration / 3) + onDismiss() + }, + onDragging = onDragging, + onResetting = onResetting, + ) + FlickToDismiss( + state = flickState, + modifier = modifier.background(backgroundColorFor(flickState)), + content = content, + ) +} + +@Composable +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 -> { + LaunchedEffect(Unit) { + currentOnDismissing(gestureState.animationDuration) + } + } + is FlickToDismissState.GestureState.Dragging -> { + LaunchedEffect(Unit) { + currentOnDragging() + } + } + is FlickToDismissState.GestureState.Resetting -> { + LaunchedEffect(Unit) { + currentOnResetting() + } + } + 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 ElementTheme.colors.bgCanvasDefault.copy(alpha = animatedAlpha) +} 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..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 @@ -18,15 +18,27 @@ 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 +import io.element.android.services.toolbox.api.systemclock.SystemClock @ContributesNode(RoomScope::class) class MediaViewerNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, presenterFactory: MediaViewerPresenter.Factory, + timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource, + mediaLoader: MatrixMediaLoader, + localMediaFactory: LocalMediaFactory, + coroutineDispatchers: CoroutineDispatchers, + systemClock: SystemClock, ) : Node(buildContext, plugins = plugins), MediaViewerNavigator { private val inputs = inputs() @@ -47,9 +59,29 @@ 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, + dataSource = MediaViewerDataSource( + dispatcher = coroutineDispatchers.computation, + galleryMode = galleryMode, + galleryDataSource = mediaGallerySource, + mediaLoader = mediaLoader, + localMediaFactory = localMediaFactory, + systemClock = systemClock, + ) ) @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..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 @@ -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.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -26,15 +25,12 @@ 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.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.local.LocalMediaActions import io.element.android.libraries.ui.strings.CommonStrings @@ -45,9 +41,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 dataSource: MediaViewerDataSource, private val room: MatrixRoom, - private val localMediaFactory: LocalMediaFactory, - private val mediaLoader: MatrixMediaLoader, private val localMediaActions: LocalMediaActions, private val snackbarDispatcher: SnackbarDispatcher, ) : Presenter { @@ -56,83 +51,89 @@ class MediaViewerPresenter @AssistedInject constructor( fun create( inputs: MediaViewerEntryPoint.Params, navigator: MediaViewerNavigator, + dataSource: MediaViewerDataSource, ): MediaViewerPresenter } @Composable override fun present(): MediaViewerState { val coroutineScope = rememberCoroutineScope() - var loadMediaTrigger by remember { mutableIntStateOf(0) } - val mediaFile: MutableState = remember { - mutableStateOf(null) - } - val localMedia: MutableState> = remember { - mutableStateOf(AsyncData.Uninitialized) - } + val data by dataSource.collectAsState() + var currentIndex by remember { mutableIntStateOf(searchIndex(data, inputs.eventId)) } val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() - localMediaActions.Configure() - DisposableEffect(loadMediaTrigger) { - coroutineScope.downloadMedia(mediaFile, localMedia) + + var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } + + DisposableEffect(Unit) { + dataSource.setup() onDispose { - mediaFile.value?.close() + dataSource.dispose() } } - var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } + localMediaActions.Configure() - fun handleEvents(mediaViewerEvents: MediaViewerEvents) { - when (mediaViewerEvents) { - MediaViewerEvents.RetryLoading -> loadMediaTrigger++ - MediaViewerEvents.ClearLoadingError -> localMedia.value = AsyncData.Uninitialized - MediaViewerEvents.SaveOnDisk -> { + fun handleEvents(event: MediaViewerEvents) { + when (event) { + is MediaViewerEvents.LoadMedia -> { + coroutineScope.downloadMedia(data = event.data) + } + is MediaViewerEvents.ClearLoadingError -> { + dataSource.clearLoadingError(event.data) + } + is MediaViewerEvents.SaveOnDisk -> { mediaBottomSheetState = MediaBottomSheetState.Hidden - coroutineScope.saveOnDisk(localMedia.value) + coroutineScope.saveOnDisk(event.data.downloadedMedia.value) } - MediaViewerEvents.Share -> { + is MediaViewerEvents.Share -> { mediaBottomSheetState = MediaBottomSheetState.Hidden - coroutineScope.share(localMedia.value) + coroutineScope.share(event.data.downloadedMedia.value) } - MediaViewerEvents.OpenWith -> { + is MediaViewerEvents.OpenWith -> { mediaBottomSheetState = MediaBottomSheetState.Hidden - coroutineScope.open(localMedia.value) + coroutineScope.open(event.data.downloadedMedia.value) } 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 { + dataSource.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,28 +141,10 @@ class MediaViewerPresenter @AssistedInject constructor( ) } - private fun CoroutineScope.downloadMedia(mediaFile: MutableState, localMedia: MutableState>) = launch { - localMedia.value = AsyncData.Loading() - mediaLoader.downloadMediaFile( - source = inputs.mediaSource, - mimeType = inputs.mediaInfo.mimeType, - filename = inputs.mediaInfo.filename - ) - .onSuccess { - mediaFile.value = it - } - .mapCatching { mediaFile -> - localMediaFactory.createFromMediaFile( - mediaFile = mediaFile, - mediaInfo = inputs.mediaInfo - ) - } - .onSuccess { - localMedia.value = AsyncData.Success(it) - } - .onFailure { - localMedia.value = AsyncData.Failure(it) - } + private fun CoroutineScope.downloadMedia( + data: MediaViewerPageData.MediaViewerData, + ) = launch { + dataSource.loadMedia(data) } private fun CoroutineScope.saveOnDisk(localMedia: AsyncData) = launch { @@ -216,4 +199,13 @@ 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 + }.coerceAtLeast(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..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 @@ -7,21 +7,41 @@ 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 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 +import kotlinx.collections.immutable.ImmutableList data class MediaViewerState( - val eventId: EventId?, - val mediaInfo: MediaInfo, - val thumbnailSource: MediaSource?, - val downloadedMedia: AsyncData, + val listData: ImmutableList, + val currentIndex: Int, val snackbarMessage: SnackbarMessage?, val canShowInfo: Boolean, val mediaBottomSheetState: MediaBottomSheetState, val eventSink: (MediaViewerEvents) -> Unit, ) + +sealed interface MediaViewerPageData { + data class Failure( + val throwable: Throwable, + ) : MediaViewerPageData + + data class Loading( + val direction: Timeline.PaginationDirection, + val timestamp: Long, + ) : MediaViewerPageData + + data class MediaViewerData( + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + 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 70c6e62e7e..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 @@ -8,9 +8,12 @@ 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 +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 @@ -21,23 +24,28 @@ 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 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 +54,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 +139,60 @@ 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, + ) + ) ) }, + aMediaViewerState( + listOf( + aMediaViewerPageDataLoading() + ), + ), + aMediaViewerState( + listOf( + MediaViewerPageData.Failure(Exception("error")) + ), + ), ) } -fun aMediaViewerState( +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(), + mediaSource: MediaSource = MediaSource(""), +): MediaViewerPageData.MediaViewerData = MediaViewerPageData.MediaViewerData( + eventId = null, + mediaInfo = mediaInfo, + mediaSource = mediaSource, + thumbnailSource = null, + downloadedMedia = mutableStateOf(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.toPersistentList(), + 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..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 @@ -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 @@ -22,6 +21,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 +36,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 +54,8 @@ 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 import io.element.android.libraries.designsystem.preview.ElementPreviewDark @@ -74,14 +78,12 @@ 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.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 kotlin.time.Duration +import timber.log.Timber @Composable fun MediaViewerView( @@ -93,51 +95,129 @@ 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( 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)) } - ) + } + LaunchedEffect(state.listData) { + Timber.d("MediaViewerView: state.listData: ${state.listData}") + } + HorizontalPager( + state = pagerState, + modifier = Modifier, + // Pre-load previous and next pages + beyondViewportPageCount = 1, + ) { page -> + when (val dataForPage = state.listData[page]) { + is MediaViewerPageData.Failure -> { + MediaViewerErrorPage( + throwable = dataForPage.throwable, + onDismiss = onBackClick, + ) + } + is MediaViewerPageData.Loading -> { + LaunchedEffect(dataForPage.timestamp) { + state.eventSink(MediaViewerEvents.LoadMore(dataForPage.direction)) + } + MediaViewerLoadingPage( + onDismiss = onBackClick, + ) + } + is MediaViewerPageData.MediaViewerData -> { + var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) } + LaunchedEffect(Unit) { + state.eventSink(MediaViewerEvents.LoadMedia(dataForPage)) + } + 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 }, + ) + } + } + } + } + } + } + // 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.MediaViewerData -> { + MediaViewerTopBar( + data = currentData, + canShowInfo = state.canShowInfo, + onBackClick = onBackClick, + onInfoClick = { + state.eventSink(MediaViewerEvents.OpenInfo(currentData)) + }, + eventSink = state.eventSink + ) + } + else -> { + TopAppBar( + title = { + 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 = bgCanvasWithTransparency, + ), + navigationIcon = { BackButton(onClick = onBackClick) }, + ) + } + } } } } + when (val bottomSheetState = state.mediaBottomSheetState) { MediaBottomSheetState.Hidden -> Unit is MediaBottomSheetState.MediaDetailsBottomSheetState -> { @@ -147,13 +227,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) @@ -176,41 +267,31 @@ fun MediaViewerView( @Composable 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.RetryLoading) - } - - fun onDismissError() { - state.eventSink(MediaViewerEvents.ClearLoadingError) - } - 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)) + }, + onResetting = { + currentOnShowOverlayChange(true) + }, + modifier = modifier, ) { - val showProgress = rememberShowProgress(state.downloadedMedia) + val downloadedMedia by data.downloadedMedia + val showProgress = rememberShowProgress(downloadedMedia) Box( modifier = Modifier @@ -224,7 +305,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 = downloadedMedia.isFailure() LaunchedEffect(playableState) { if (playableState is PlayableState.Playable) { @@ -234,10 +315,11 @@ private fun MediaViewerPage( LocalMediaView( modifier = Modifier.fillMaxSize(), + isDisplayed = isDisplayed, bottomPaddingInPixels = bottomPaddingInPixels, localMediaViewState = localMediaViewState, - localMedia = state.downloadedMedia.dataOrNull(), - mediaInfo = state.mediaInfo, + localMedia = downloadedMedia.dataOrNull(), + mediaInfo = data.mediaInfo, onClick = { if (playableState is PlayableState.NotPlayable) { currentOnShowOverlayChange(!currentShowOverlay) @@ -245,15 +327,15 @@ private fun MediaViewerPage( }, ) ThumbnailView( - mediaInfo = state.mediaInfo, - thumbnailSource = state.thumbnailSource, + mediaInfo = data.mediaInfo, + thumbnailSource = data.thumbnailSource, isVisible = showThumbnail, ) if (showError) { ErrorView( errorMessage = stringResource(id = CommonStrings.error_unknown), - onRetry = ::onRetry, - onDismiss = ::onDismissError + onRetry = onRetry, + onDismiss = onDismissError ) } } @@ -269,26 +351,46 @@ private fun MediaViewerPage( } @Composable -private fun DismissFlickEffects( - flickState: FlickToDismissState, - onDismissing: suspend (Duration) -> Unit, - onDragging: suspend () -> Unit, +private fun MediaViewerLoadingPage( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, ) { - val currentOnDismissing by rememberUpdatedState(onDismissing) - val currentOnDragging by rememberUpdatedState(onDragging) - - when (val gestureState = flickState.gestureState) { - is FlickToDismissState.GestureState.Dismissing -> { - LaunchedEffect(Unit) { - currentOnDismissing(gestureState.animationDuration) - } + MediaViewerFlickToDismiss( + onDismiss = onDismiss, + modifier = modifier, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + contentAlignment = Alignment.Center + ) { + AsyncLoading() } - is FlickToDismissState.GestureState.Dragging -> { - LaunchedEffect(Unit) { - currentOnDragging() - } + } +} + +@Composable +private fun MediaViewerErrorPage( + throwable: Throwable, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + MediaViewerFlickToDismiss( + onDismiss = onDismiss, + modifier = modifier, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + contentAlignment = Alignment.Center + ) { + AsyncFailure( + throwable = throwable, + onRetry = null + ) } - else -> Unit } } @@ -316,15 +418,17 @@ 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 downloadedMedia by data.downloadedMedia + val actionsEnabled = downloadedMedia.isSuccess() + val mimeType = data.mediaInfo.mimeType + val senderName = data.mediaInfo.senderName + val dateSent = data.mediaInfo.dateSent TopAppBar( title = { if (senderName != null && dateSent != null) { @@ -350,14 +454,14 @@ private fun MediaViewerTopBar( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent.copy(0.6f), + containerColor = bgCanvasWithTransparency, ), navigationIcon = { BackButton(onClick = onBackClick) }, actions = { IconButton( enabled = actionsEnabled, onClick = { - eventSink(MediaViewerEvents.OpenWith) + eventSink(MediaViewerEvents.OpenWith(data)) }, ) { when (mimeType) { @@ -378,7 +482,7 @@ private fun MediaViewerTopBar( ) { Icon( imageVector = CompoundIcons.Info(), - contentDescription = null, + contentDescription = stringResource(id = CommonStrings.a11y_view_details), ) } } @@ -396,7 +500,7 @@ private fun MediaViewerBottomBar( Column( modifier = modifier .fillMaxWidth() - .background(Color(0x99101317)) + .background(bgCanvasWithTransparency) .onSizeChanged { onHeightChange(it.height) }, @@ -457,21 +561,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 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" 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/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, + ) +} 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..2f8cd634ec --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt @@ -0,0 +1,277 @@ +/* + * 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`() = runTest { + val fakeTimeline = FakeTimeline() + 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/MediaViewerDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt new file mode 100644 index 0000000000..5348eb2aa3 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt @@ -0,0 +1,278 @@ +/* + * 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.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 +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)) + assertThat(awaitItem().first()).isEqualTo(MediaViewerPageData.Failure(AN_EXCEPTION)) + } + } + + @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).isEmpty() + } + } + + @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( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = A_FAKE_TIMESTAMP, + ), + MediaViewerPageData.Loading( + direction = Timeline.PaginationDirection.FORWARDS, + timestamp = A_FAKE_TIMESTAMP, + ), + ) + } + } + + @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() + } + } + + 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, + 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 3f62fe463c..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 @@ -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,23 @@ 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.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 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 +61,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 +71,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 +89,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 +107,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 +126,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 +136,280 @@ class MediaViewerPresenterTest { } @Test - fun `present - download media success scenario`() = runTest { + fun `present - data source update`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) val presenter = createMediaViewerPresenter( - room = FakeMatrixRoom( - canRedactOwnResult = { Result.success(true) }, + 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 - load media`() = 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( + 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 - check all actions`() = runTest { - val mediaActions = FakeLocalMediaActions() - val snackbarDispatcher = SnackbarDispatcher() + fun `present - open info`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) val presenter = createMediaViewerPresenter( - localMediaActions = mediaActions, - snackbarDispatcher = snackbarDispatcher, + mediaGalleryDataSource = mediaGalleryDataSource, room = FakeMatrixRoom( canRedactOwnResult = { Result.success(true) }, ) ) - 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() + 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) + } + } - // 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() + @Test + 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 - download media failure then retry with success scenario`() = runTest { - val matrixMediaLoader = FakeMatrixMediaLoader() + fun `present - share`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) val presenter = createMediaViewerPresenter( - matrixMediaLoader = matrixMediaLoader, - room = FakeMatrixRoom( - canRedactOwnResult = { Result.success(true) }, + 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 = { }, ) - 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 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 media success scenario`() = runTest { + 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 +417,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 +473,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 +550,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 +580,25 @@ 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, - localMediaActions = localMediaActions, - snackbarDispatcher = snackbarDispatcher, navigator = mediaViewerNavigator, + dataSource = MediaViewerDataSource( + galleryMode = MediaGalleryMode.Images, + dispatcher = testCoroutineDispatchers().computation, + galleryDataSource = mediaGalleryDataSource, + mediaLoader = matrixMediaLoader, + localMediaFactory = localMediaFactory, + systemClock = FakeSystemClock(), + ), room = room, + localMediaActions = localMediaActions, + snackbarDispatcher = snackbarDispatcher, ) } } 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), + ) + } + + @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(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) { + 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..26a87bde89 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" @@ -181,6 +182,7 @@ Reason: %1$s." "Light" "Link copied to clipboard" "Loading…" + "Loading more…" "%1$d member" "%1$d members" 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 } 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", 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 new file mode 100644 index 0000000000..c96f6f4bfb --- /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: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 new file mode 100644 index 0000000000..205d657d46 --- /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: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