diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt index 0486c65f07..293329c2dd 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt @@ -123,6 +123,7 @@ class EventItemFactory @Inject constructor( duration = null, ), mediaSource = type.source, + // TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource ) is ImageMessageType -> MediaItem.Image( id = currentTimelineItem.uniqueId, @@ -142,7 +143,7 @@ class EventItemFactory @Inject constructor( duration = null, ), mediaSource = type.source, - thumbnailSource = null, + thumbnailSource = type.info?.thumbnailSource, ) is StickerMessageType -> MediaItem.Image( id = currentTimelineItem.uniqueId, @@ -162,7 +163,7 @@ class EventItemFactory @Inject constructor( duration = null, ), mediaSource = type.source, - thumbnailSource = null, + thumbnailSource = type.info?.thumbnailSource, ) is VideoMessageType -> MediaItem.Video( id = currentTimelineItem.uniqueId, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt index a2f827636b..0e12653108 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery +import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.SingleIn @@ -35,6 +36,7 @@ interface MediaGalleryDataSource { } @SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) class TimelineMediaGalleryDataSource @Inject constructor( private val room: MatrixRoom, private val timelineMediaItemsFactory: TimelineMediaItemsFactory, @@ -62,7 +64,9 @@ class TimelineMediaGalleryDataSource @Inject constructor( timeline = it emit(it) }, - { groupedMediaItemsFlow.emit(AsyncData.Failure(it)) }, + { + groupedMediaItemsFlow.emit(AsyncData.Failure(it)) + }, ) }.flatMapLatest { timeline -> timeline.timelineItems.onEach { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt index 247014db01..476e86c5c5 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -40,7 +40,7 @@ import kotlinx.coroutines.launch class MediaGalleryPresenter @AssistedInject constructor( @Assisted private val navigator: MediaGalleryNavigator, private val room: MatrixRoom, - private val mediaGalleryDataSource: TimelineMediaGalleryDataSource, + private val mediaGalleryDataSource: MediaGalleryDataSource, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, private val localMediaActions: LocalMediaActions, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt index 2ed781c7ae..ceb934fbe2 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt @@ -18,6 +18,7 @@ fun aMediaItemImage( id: UniqueId = UniqueId("imageId"), eventId: EventId? = null, senderId: UserId? = null, + mediaSourceUrl: String = "", ): MediaItem.Image { return MediaItem.Image( id = id, @@ -25,7 +26,7 @@ fun aMediaItemImage( mediaInfo = anImageMediaInfo( senderId = senderId, ), - mediaSource = MediaSource(""), + mediaSource = MediaSource(mediaSourceUrl), thumbnailSource = null, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt index 592a84c1cb..89c3fe943d 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -41,7 +41,6 @@ class MediaViewerDataSource( private val mediaLoader: MatrixMediaLoader, private val localMediaFactory: LocalMediaFactory, ) { - // List of media files that are currently being loaded private val mediaFiles: MutableList = mutableListOf() @@ -145,6 +144,4 @@ class MediaViewerDataSource( localMediaState.value = AsyncData.Failure(it) } } - - } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 3c2b986c98..c75bf1eacc 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -266,7 +266,6 @@ private fun MediaViewerPage( onShowOverlayChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { - val currentShowOverlay by rememberUpdatedState(showOverlay) val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange) val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false) @@ -489,7 +488,7 @@ private fun MediaViewerTopBar( ) { Icon( imageVector = CompoundIcons.Info(), - contentDescription = null, + contentDescription = stringResource(id = CommonStrings.a11y_view_details), ) } } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt index 9b9a6092b5..a0bc4c1f0f 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt @@ -165,6 +165,7 @@ class DefaultEventItemFactoryTest { dateSent = "0 Day false", dateSentFull = "0 Full false", waveform = null, + duration = null, ), mediaSource = MediaSource(""), ) @@ -214,6 +215,7 @@ class DefaultEventItemFactoryTest { dateSent = "0 Day false", dateSentFull = "0 Full false", waveform = null, + duration = null, ), mediaSource = MediaSource(""), thumbnailSource = null, @@ -260,6 +262,7 @@ class DefaultEventItemFactoryTest { dateSent = "0 Day false", dateSentFull = "0 Full false", waveform = null, + duration = null, ), mediaSource = MediaSource(""), ) @@ -310,10 +313,10 @@ class DefaultEventItemFactoryTest { dateSent = "0 Day false", dateSentFull = "0 Full false", waveform = null, + duration = "2:03", ), mediaSource = MediaSource(""), thumbnailSource = null, - duration = "2:03", ) ) } @@ -361,10 +364,9 @@ class DefaultEventItemFactoryTest { dateSent = "0 Day false", dateSentFull = "0 Full false", waveform = listOf(1f, 2f).toImmutableList(), + duration = "7:36", ), mediaSource = MediaSource(""), - duration = "7:36", - waveform = listOf(1f, 2f).toImmutableList(), ) ) } @@ -412,6 +414,7 @@ class DefaultEventItemFactoryTest { dateSent = "0 Day false", dateSentFull = "0 Full false", waveform = null, + duration = null, ), mediaSource = MediaSource(""), thumbnailSource = null, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryDataSource.kt new file mode 100644 index 0000000000..419c2c568a --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryDataSource.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +class FakeMediaGalleryDataSource( + private val startLambda: () -> Unit = { lambdaError() }, + private val loadMoreLambda: (Timeline.PaginationDirection) -> Unit = { lambdaError() }, + private val deleteItemLambda: (EventId) -> Unit = { lambdaError() }, + ) : MediaGalleryDataSource { + override fun start() = startLambda() + + private val groupedMediaItemsFlow = MutableSharedFlow>( + replay = 1 + ) + + override fun groupedMediaItemsFlow(): Flow> { + return groupedMediaItemsFlow + } + + suspend fun emitGroupedMediaItems(groupedMediaItems: AsyncData) { + groupedMediaItemsFlow.emit(groupedMediaItems) + } + + override fun getLastData(): AsyncData { + return groupedMediaItemsFlow.replayCache.firstOrNull() ?: AsyncData.Uninitialized + } + + override suspend fun loadMore(direction: Timeline.PaginationDirection) { + loadMoreLambda(direction) + } + + override suspend fun deleteItem(eventId: EventId) { + deleteItemLambda(eventId) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt index 3a206dc244..0f304f209e 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt @@ -8,12 +8,12 @@ package io.element.android.libraries.mediaviewer.impl.gallery import android.net.Uri +import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter -import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_USER_ID @@ -25,15 +25,11 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetSta import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory -import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test -import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk -import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -47,49 +43,37 @@ class MediaGalleryPresenterTest { @Test fun `present - initial state`() = runTest { - val onViewInTimelineClickLambda = lambdaRecorder { } - val navigator = FakeMediaGalleryNavigator( - onViewInTimelineClickLambda = onViewInTimelineClickLambda, - ) + val startLambda = lambdaRecorder { } val presenter = createMediaGalleryPresenter( - navigator = navigator, + mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = startLambda, + ), room = FakeMatrixRoom( displayName = A_ROOM_NAME, mediaTimelineResult = { Result.success(FakeTimeline()) }, ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() + val initialState = awaitFirstItem() assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images) assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) assertThat(initialState.roomName).isEqualTo(A_ROOM_NAME) - assertThat(initialState.groupedMediaItems.dataOrNull()).isEqualTo( - GroupedMediaItems( - imageAndVideoItems = persistentListOf(), - fileItems = persistentListOf(), - ) - ) + assertThat(initialState.groupedMediaItems.isUninitialized()).isTrue() assertThat(initialState.snackbarMessage).isNull() } + startLambda.assertions().isCalledOnce() } @Test fun `present - change mode`() = runTest { - val onViewInTimelineClickLambda = lambdaRecorder { } - val navigator = FakeMediaGalleryNavigator( - onViewInTimelineClickLambda = onViewInTimelineClickLambda, - ) val presenter = createMediaGalleryPresenter( - navigator = navigator, room = FakeMatrixRoom( displayName = A_ROOM_NAME, mediaTimelineResult = { Result.success(FakeTimeline()) }, ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() + val initialState = awaitFirstItem() assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images) initialState.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Files)) val state = awaitItem() @@ -110,7 +94,7 @@ class MediaGalleryPresenterTest { `present - bottom sheet state - own message`(canDeleteOwn = false) } - private suspend fun TestScope.`present - bottom sheet state - own message`(canDeleteOwn: Boolean) { + private suspend fun `present - bottom sheet state - own message`(canDeleteOwn: Boolean) { val presenter = createMediaGalleryPresenter( room = FakeMatrixRoom( sessionId = A_USER_ID, @@ -120,8 +104,7 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() + val initialState = awaitFirstItem() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) val item = aMediaItemImage( eventId = AN_EVENT_ID, @@ -154,7 +137,7 @@ class MediaGalleryPresenterTest { `present - bottom sheet state - other message`(canDeleteOther = false) } - private suspend fun TestScope.`present - bottom sheet state - other message`(canDeleteOther: Boolean) { + private suspend fun `present - bottom sheet state - other message`(canDeleteOther: Boolean) { val presenter = createMediaGalleryPresenter( room = FakeMatrixRoom( sessionId = A_USER_ID, @@ -164,8 +147,7 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() + val initialState = awaitFirstItem() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) val item = aMediaItemImage( eventId = AN_EVENT_ID, @@ -197,8 +179,7 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() + val initialState = awaitFirstItem() // Delete bottom sheet val item = aMediaItemImage() initialState.eventSink(MediaGalleryEvents.ConfirmDelete(AN_EVENT_ID, item.mediaInfo, item.thumbnailSource)) @@ -217,6 +198,42 @@ class MediaGalleryPresenterTest { } } + @Test + fun `present - delete item`() = runTest { + val deleteItemLambda = lambdaRecorder { } + val presenter = createMediaGalleryPresenter( + mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + deleteItemLambda = deleteItemLambda, + ), + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.Delete(AN_EVENT_ID)) + deleteItemLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) + } + } + + @Test + fun `present - share item`() = runTest { + val presenter = createMediaGalleryPresenter() + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.Share(AN_EVENT_ID)) + } + // TODO Add more test on this part + } + + @Test + fun `present - save on disk`() = runTest { + val presenter = createMediaGalleryPresenter() + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.SaveOnDisk(AN_EVENT_ID)) + } + // TODO Add more test on this part + } + @Test fun `present - view in timeline invokes the navigator`() = runTest { val onViewInTimelineClickLambda = lambdaRecorder { } @@ -230,15 +247,37 @@ class MediaGalleryPresenterTest { navigator = navigator, ) presenter.test { - skipItems(2) - val initialState = awaitItem() + val initialState = awaitFirstItem() initialState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID)) onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) } } - private fun TestScope.createMediaGalleryPresenter( + @Test + fun `present - load more`() = runTest { + val loadMoreLambda = lambdaRecorder { } + val presenter = createMediaGalleryPresenter( + mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + loadMoreLambda = loadMoreLambda, + ), + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) + loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS)) + } + } + + private suspend fun ReceiveTurbine.awaitFirstItem(): T { + return awaitItem() + } + + private fun createMediaGalleryPresenter( matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(), + mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ), localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), navigator: MediaGalleryNavigator = FakeMediaGalleryNavigator(), @@ -249,22 +288,11 @@ class MediaGalleryPresenterTest { return MediaGalleryPresenter( navigator = navigator, room = room, - timelineMediaItemsFactory = TimelineMediaItemsFactory( - dispatchers = testCoroutineDispatchers(), - virtualItemFactory = VirtualItemFactory( - dateFormatter = FakeDateFormatter(), - ), - eventItemFactory = EventItemFactory( - fileSizeFormatter = FakeFileSizeFormatter(), - fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), - dateFormatter = FakeDateFormatter(), - ), - ), + mediaGalleryDataSource = mediaGalleryDataSource, localMediaFactory = localMediaFactory, mediaLoader = matrixMediaLoader, localMediaActions = localMediaActions, snackbarDispatcher = snackbarDispatcher, - mediaItemsPostProcessor = MediaItemsPostProcessor(), ) } } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt index 9a8fc615ff..4c823350ce 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt @@ -8,9 +8,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.UniqueId -import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemAudio import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile @@ -42,27 +40,6 @@ class MediaItemsPostProcessorTest { private val date3 = aMediaItemDateSeparator(id = UniqueId("3")) private val loading1 = aMediaItemLoadingIndicator(id = UniqueId("1")) - @Test - fun `process Uninitialized`() { - val sut = MediaItemsPostProcessor() - val result = sut.process(AsyncData.Uninitialized) - assertThat(result).isEqualTo(AsyncData.Uninitialized) - } - - @Test - fun `process Loading`() { - val sut = MediaItemsPostProcessor() - val result = sut.process(AsyncData.Loading()) - assertThat(result).isEqualTo(AsyncData.Loading()) - } - - @Test - fun `process Failure`() { - val sut = MediaItemsPostProcessor() - val result = sut.process(AsyncData.Failure(AN_EXCEPTION)) - assertThat(result).isEqualTo(AsyncData.Failure(AN_EXCEPTION)) - } - @Test fun `process Empty`() { test( @@ -215,19 +192,16 @@ class MediaItemsPostProcessorTest { expectedFileItems: List, ) { val sut = MediaItemsPostProcessor() - val result = sut.process(AsyncData.Success(mediaItems.toImmutableList())) - val data = result.dataOrNull()!! + val result = sut.process(mediaItems.toImmutableList()) // Compare the lists to have better failure info - assertThat(data.imageAndVideoItems.toList()).isEqualTo(expectedImageAndVideoItems) - assertThat(data.fileItems.toList()).isEqualTo(expectedFileItems) + assertThat(result.imageAndVideoItems.toList()).isEqualTo(expectedImageAndVideoItems) + assertThat(result.fileItems.toList()).isEqualTo(expectedFileItems) assertThat(result).isEqualTo( - AsyncData.Success( - GroupedMediaItems( - imageAndVideoItems = expectedImageAndVideoItems.toImmutableList(), - fileItems = expectedFileItems.toImmutableList(), - ) + GroupedMediaItems( + imageAndVideoItems = expectedImageAndVideoItems.toImmutableList(), + fileItems = expectedFileItems.toImmutableList(), ) ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt new file mode 100644 index 0000000000..5c21299f4d --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt @@ -0,0 +1,279 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.media.ThumbnailInfo +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_UNIQUE_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.matrix.test.timeline.aMessageContent +import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class TimelineMediaGalleryDataSourceTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `test - not started TimelineMediaGalleryDataSource emits no events`() { + val fakeTimeline = FakeTimeline() + runTest { + val sut = createTimelineMediaGalleryDataSource( + room = FakeMatrixRoom( + mediaTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.groupedMediaItemsFlow().test { + // Also, loadMore and deleteItem should be no-op + sut.loadMore(Timeline.PaginationDirection.BACKWARDS) + sut.deleteItem(AN_EVENT_ID) + expectNoEvents() + } + } + } + + @Test + fun `test - getLastData should return the previous emitted data`() { + val fakeTimeline = FakeTimeline() + runTest { + val sut = createTimelineMediaGalleryDataSource( + room = FakeMatrixRoom( + mediaTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.start() + assertThat(sut.getLastData()).isEqualTo(AsyncData.Uninitialized) + sut.groupedMediaItemsFlow().test { + assertThat(awaitItem().isLoading()).isTrue() + assertThat(sut.getLastData().isLoading()).isTrue() + assertThat(awaitItem()).isEqualTo( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(), + ) + ) + ) + assertThat(sut.getLastData().isSuccess()).isTrue() + // Also test that starting again should have no effect + sut.start() + } + } + // Ensure that the timeline has been closed on flow completion + assertThat(fakeTimeline.closeCounter).isEqualTo(1) + } + + @Test + fun `test - load more should call the timeline paginate method`() = runTest { + val paginateLambdaRecorder = + lambdaRecorder> { _ -> + Result.success(true) + } + val fakeTimeline = FakeTimeline().apply { + paginateLambda = paginateLambdaRecorder + } + val sut = createTimelineMediaGalleryDataSource( + room = FakeMatrixRoom( + mediaTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.start() + sut.groupedMediaItemsFlow().test { + skipItems(2) + sut.loadMore(Timeline.PaginationDirection.BACKWARDS) + paginateLambdaRecorder.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS)) + } + } + + @Test + fun `test - delete item should call the timeline redact method`() = runTest { + val redactEventLambdaRecorder = + lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val fakeTimeline = FakeTimeline().apply { + redactEventLambda = redactEventLambdaRecorder + } + val sut = createTimelineMediaGalleryDataSource( + room = FakeMatrixRoom( + mediaTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.start() + sut.groupedMediaItemsFlow().test { + skipItems(2) + sut.deleteItem(AN_EVENT_ID) + redactEventLambdaRecorder.assertions().isCalledOnce().with( + value(AN_EVENT_ID.toEventOrTransactionId()), + value(null), + ) + } + } + + @Test + fun `test - failing to load timeline should emit an error`() = runTest { + val sut = createTimelineMediaGalleryDataSource( + room = FakeMatrixRoom( + mediaTimelineResult = { Result.failure(AN_EXCEPTION) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.start() + sut.groupedMediaItemsFlow().test { + assertThat(awaitItem().isLoading()).isTrue() + assertThat(sut.getLastData().isLoading()).isTrue() + assertThat(awaitItem()).isEqualTo( + AsyncData.Failure(AN_EXCEPTION) + ) + } + } + + @Test + fun `test - when timeline emits new data, the flow emits the data`() = runTest { + val timelineItems = MutableStateFlow>(emptyList()) + val fakeTimeline = FakeTimeline( + timelineItems = timelineItems, + ) + val sut = createTimelineMediaGalleryDataSource( + room = FakeMatrixRoom( + mediaTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.start() + sut.groupedMediaItemsFlow().test { + assertThat(awaitItem().isLoading()).isTrue() + assertThat(sut.getLastData().isLoading()).isTrue() + assertThat(awaitItem()).isEqualTo( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(), + ) + ) + ) + timelineItems.emit( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = ImageMessageType( + filename = "body.jpg", + caption = "body.jpg caption", + formattedCaption = FormattedBody(MessageFormat.HTML, "formatted"), + source = MediaSource("url"), + info = ImageInfo( + height = 10L, + width = 5L, + mimetype = MimeTypes.Jpeg, + size = 888L, + thumbnailInfo = ThumbnailInfo( + height = 10L, + width = 5L, + mimetype = MimeTypes.Jpeg, + size = 111L, + ), + thumbnailSource = MediaSource("url_thumbnail"), + blurhash = A_BLUR_HASH, + ) + ) + ) + ), + ) + ) + ) + assertThat(awaitItem()).isEqualTo( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf( + MediaItem.Image( + id = A_UNIQUE_ID, + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + filename = "body.jpg", + caption = "body.jpg caption", + mimeType = MimeTypes.Jpeg, + formattedFileSize = "888 Bytes", + fileExtension = "jpg", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "0 Day false", + dateSentFull = "0 Full false", + waveform = null, + duration = null + ), + mediaSource = MediaSource("url"), + thumbnailSource = MediaSource("url_thumbnail"), + ) + ), + fileItems = persistentListOf() + ) + ) + ) + } + } + + private fun TestScope.createTimelineMediaGalleryDataSource( + room: MatrixRoom = FakeMatrixRoom( + liveTimeline = FakeTimeline(), + ), + ): TimelineMediaGalleryDataSource { + return TimelineMediaGalleryDataSource( + room = room, + timelineMediaItemsFactory = TimelineMediaItemsFactory( + dispatchers = testCoroutineDispatchers(), + virtualItemFactory = VirtualItemFactory( + dateFormatter = FakeDateFormatter(), + ), + eventItemFactory = EventItemFactory( + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + dateFormatter = FakeDateFormatter(), + ), + ), + mediaItemsPostProcessor = MediaItemsPostProcessor(), + ) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt index 829efd7a15..48c636297b 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt @@ -50,6 +50,7 @@ class AndroidLocalMediaFactoryTest { dateSent = "12:34", dateSentFull = "full", waveform = null, + duration = null, ) ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index 3f62fe463c..3b408cbab5 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -10,14 +10,14 @@ package io.element.android.libraries.mediaviewer.impl.viewer import android.net.Uri -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test +import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -30,14 +30,22 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.anApkMediaInfo import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.gallery.FakeMediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.gallery.GroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -52,6 +60,7 @@ class MediaViewerPresenterTest { private val mockMediaUri: Uri = mockk("localMediaUri") private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) + private val aUrl = "aUrl" @Test fun `present - initial state null Event`() = runTest { @@ -61,9 +70,9 @@ class MediaViewerPresenterTest { ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + val initialState = awaitFirstItem() + assertThat(initialState.listData).isEmpty() + assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isTrue() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) @@ -79,9 +88,9 @@ class MediaViewerPresenterTest { ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + val initialState = awaitFirstItem() + assertThat(initialState.listData).isEmpty() + assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isFalse() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) @@ -97,9 +106,9 @@ class MediaViewerPresenterTest { ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + val initialState = awaitFirstItem() + assertThat(initialState.listData).isEmpty() + assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isTrue() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) @@ -116,9 +125,9 @@ class MediaViewerPresenterTest { ) ) presenter.test { - skipItems(2) - val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + val initialState = awaitFirstItem() + assertThat(initialState.listData).isEmpty() + assertThat(initialState.currentIndex).isEqualTo(0) assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.canShowInfo).isTrue() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) @@ -126,114 +135,280 @@ class MediaViewerPresenterTest { } @Test - fun `present - download media success scenario`() = runTest { - val presenter = createMediaViewerPresenter( - room = FakeMatrixRoom( - canRedactOwnResult = { Result.success(true) }, - ) + fun `present - data source update`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - var state = awaitItem() - assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized) - assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) - state = awaitItem() - assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) - state = awaitItem() - val successData = state.downloadedMedia.dataOrNull() - assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) - assertThat(successData).isNotNull() + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage() + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.listData).isEmpty() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitFirstItem() + assertThat(updatedState.listData).hasSize(1) + val item = updatedState.listData.first() as MediaViewerPageData.MediaViewerData + assertThat(item.eventId).isNull() + assertThat(item.mediaInfo).isEqualTo(anImage.mediaInfo) + assertThat(item.mediaSource).isEqualTo(anImage.mediaSource) + assertThat(item.thumbnailSource).isEqualTo(anImage.thumbnailSource) + assertThat(item.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized) } } @Test - fun `present - check all actions`() = runTest { - val mediaActions = FakeLocalMediaActions() - val snackbarDispatcher = SnackbarDispatcher() - val presenter = createMediaViewerPresenter( - localMediaActions = mediaActions, - snackbarDispatcher = snackbarDispatcher, - room = FakeMatrixRoom( - canRedactOwnResult = { Result.success(true) }, - ) + fun `present - load media`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - var state = awaitItem() - assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized) - state = awaitItem() - assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) - // no state changes while media is loading - state.eventSink(MediaViewerEvents.OpenWith) - state.eventSink(MediaViewerEvents.Share) - state.eventSink(MediaViewerEvents.SaveOnDisk) - state = awaitItem() - assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) - // Should succeed without change of state - state.eventSink(MediaViewerEvents.OpenWith) - // Should succeed without change of state - state.eventSink(MediaViewerEvents.Share) - state.eventSink(MediaViewerEvents.SaveOnDisk) - state = awaitItem() - assertThat(state.snackbarMessage).isNotNull() - snackbarDispatcher.clear() - assertThat(awaitItem().snackbarMessage).isNull() - - // Check failures - mediaActions.shouldFail = true - state.eventSink(MediaViewerEvents.OpenWith) - state = awaitItem() - assertThat(state.snackbarMessage).isNotNull() - snackbarDispatcher.clear() - assertThat(awaitItem().snackbarMessage).isNull() - state.eventSink(MediaViewerEvents.Share) - state = awaitItem() - assertThat(state.snackbarMessage).isNotNull() - snackbarDispatcher.clear() - assertThat(awaitItem().snackbarMessage).isNull() - state.eventSink(MediaViewerEvents.SaveOnDisk) - state = awaitItem() - assertThat(state.snackbarMessage).isNotNull() + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.LoadMedia( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) } } @Test - fun `present - download media failure then retry with success scenario`() = runTest { - val matrixMediaLoader = FakeMatrixMediaLoader() + fun `present - open info`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) val presenter = createMediaViewerPresenter( - matrixMediaLoader = matrixMediaLoader, + mediaGalleryDataSource = mediaGalleryDataSource, room = FakeMatrixRoom( canRedactOwnResult = { Result.success(true) }, ) ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - matrixMediaLoader.shouldFail = true - val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized) - assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) - val loadingState = awaitItem() - assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) - val failureState = awaitItem() - assertThat(failureState.downloadedMedia).isInstanceOf(AsyncData.Failure::class.java) - matrixMediaLoader.shouldFail = false - failureState.eventSink(MediaViewerEvents.RetryLoading) - // There is one recomposition because of the retry mechanism - skipItems(1) - val retryLoadingState = awaitItem() - assertThat(retryLoadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - val successData = successState.downloadedMedia.dataOrNull() - assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) - assertThat(successData).isNotNull() + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.OpenInfo( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + val withInfoState = awaitItem() + assertThat(withInfoState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java) + withInfoState.eventSink( + MediaViewerEvents.CloseBottomSheet + ) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) } } @Test - fun `present - delete media success scenario`() = runTest { + fun `present - clear loading error`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.ClearLoadingError( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + } + } + + @Test + fun `present - share`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.Share( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + } + } + + @Test + fun `present - save on disk`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.SaveOnDisk( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + } + } + + @Test + fun `present - open with`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.OpenWith( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + } + } + + @Test + fun `present - delete and cancel`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.ConfirmDelete( + eventId = AN_EVENT_ID, + data = aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + val withBottomSheetState = awaitItem() + assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java) + withBottomSheetState.eventSink( + MediaViewerEvents.CloseBottomSheet + ) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + } + } + + @Test + fun `present - delete`() = runTest { val redactEventLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } @@ -241,26 +416,51 @@ class MediaViewerPresenterTest { this.redactEventLambda = redactEventLambda } val onItemDeletedLambda = lambdaRecorder { } - val navigator = FakeMediaViewerNavigator( - onItemDeletedLambda = onItemDeletedLambda, + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, ) - val presenter = createMediaViewerPresenter( room = FakeMatrixRoom( liveTimeline = timeline, canRedactOwnResult = { Result.success(true) }, ), - mediaViewerNavigator = navigator, + mediaGalleryDataSource = mediaGalleryDataSource, + mediaViewerNavigator = FakeMediaViewerNavigator( + onItemDeletedLambda = onItemDeletedLambda + ) + ) + val anImage = aMediaItemImage( + eventId = AN_EVENT_ID, + mediaSourceUrl = aUrl, ) presenter.test { - val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized) - assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) - val loadingState = awaitItem() - assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) - successState.eventSink(MediaViewerEvents.Delete(AN_EVENT_ID)) + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.ConfirmDelete( + eventId = AN_EVENT_ID, + data = aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + val withBottomSheetState = awaitItem() + assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java) + updatedState.eventSink( + MediaViewerEvents.Delete( + eventId = AN_EVENT_ID, + ) + ) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) redactEventLambda.assertions() .isCalledOnce() .with( @@ -272,7 +472,71 @@ class MediaViewerPresenterTest { } @Test - fun `present - view in timeline invokes the navigator`() = runTest { + fun `present - on navigate to`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + val anImage2 = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage, anImage2), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.OnNavigateTo(1) + ) + val finalState = awaitItem() + assertThat(finalState.currentIndex).isEqualTo(1) + } + } + + @Test + fun `present - load more`() = runTest { + val loadMoreLambda = lambdaRecorder { } + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + loadMoreLambda = loadMoreLambda, + ) + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS) + ) + loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS)) + } + } + + @Test + fun `present - view in timeline hide the bottom sheet and invokes the navigator`() = runTest { val onViewInTimelineClickLambda = lambdaRecorder { } val navigator = FakeMediaViewerNavigator( onViewInTimelineClickLambda = onViewInTimelineClickLambda, @@ -285,22 +549,28 @@ class MediaViewerPresenterTest { ) presenter.test { val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized) - assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) - val loadingState = awaitItem() - assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) - successState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID)) + initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData())) + val withBottomSheetState = awaitItem() + assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java) + initialState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID)) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) } } - private fun createMediaViewerPresenter( + private suspend fun ReceiveTurbine.awaitFirstItem(): T { + return awaitItem() + } + + private fun TestScope.createMediaViewerPresenter( eventId: EventId? = null, matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(), localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), + mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ), canShowInfo: Boolean = true, mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(), room: MatrixRoom = FakeMatrixRoom( @@ -309,18 +579,24 @@ class MediaViewerPresenterTest { ): MediaViewerPresenter { return MediaViewerPresenter( inputs = MediaViewerEntryPoint.Params( + mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, eventId = eventId, mediaInfo = TESTED_MEDIA_INFO, mediaSource = aMediaSource(), thumbnailSource = null, canShowInfo = canShowInfo, ), - localMediaFactory = localMediaFactory, - mediaLoader = matrixMediaLoader, + navigator = mediaViewerNavigator, + dataSource = MediaViewerDataSource( + galleryMode = MediaGalleryMode.Images, + dispatcher = testCoroutineDispatchers().computation, + galleryDataSource = mediaGalleryDataSource, + mediaLoader = matrixMediaLoader, + localMediaFactory = localMediaFactory, + ), + room = room, localMediaActions = localMediaActions, snackbarDispatcher = snackbarDispatcher, - navigator = mediaViewerNavigator, - room = room, ) } } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt index 89dfc0dd91..bfc294098b 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt @@ -18,15 +18,15 @@ import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeDown import androidx.test.ext.junit.runners.AndroidJUnit4 import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.mediaviewer.api.anImageMediaInfo -import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState +import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.pressBack +import io.mockk.mockk import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -36,78 +36,127 @@ import org.junit.runner.RunWith class MediaViewerViewTest { @get:Rule val rule = createAndroidComposeRule() + private val mockMediaUrl: Uri = mockk("localMediaUri") + @Test fun `clicking on back invokes expected callback`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder() + val state = aMediaViewerState( + eventSink = eventsRecorder + ) ensureCalledOnce { callback -> rule.setMediaViewerView( - aMediaViewerState( - eventSink = eventsRecorder - ), + state = state, onBackClick = callback, ) rule.pressBack() } + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData), + ) + ) } @Test fun `clicking on open emit expected Event`() { - testMenuAction(CommonStrings.action_open_with, MediaViewerEvents.OpenWith) + val data = aMediaViewerPageData( + downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)), + ) + testMenuAction( + data, + CommonStrings.action_open_with, + MediaViewerEvents.OpenWith(data), + ) } - private fun testMenuAction(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) { + @Test + fun `clicking on info emit expected Event`() { + val data = aMediaViewerPageData( + downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)), + ) + testMenuAction( + data, + CommonStrings.a11y_view_details, + MediaViewerEvents.OpenInfo(data), + ) + } + + private fun testMenuAction( + data: MediaViewerPageData.MediaViewerData, + contentDescriptionRes: Int, + expectedEvent: MediaViewerEvents, + ) { val eventsRecorder = EventsRecorder() rule.setMediaViewerView( aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, anImageMediaInfo()) - ), - mediaInfo = anImageMediaInfo(), + listData = listOf(data), eventSink = eventsRecorder ), ) val contentDescription = rule.activity.getString(contentDescriptionRes) rule.onNodeWithContentDescription(contentDescription).performClick() - eventsRecorder.assertSingle(expectedEvent) + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(data), + expectedEvent, + ) + ) } @Test fun `clicking on save emit expected Event`() { - testBottomSheetAction(CommonStrings.action_save, MediaViewerEvents.SaveOnDisk) + val data = aMediaViewerPageData() + testBottomSheetAction( + data, + CommonStrings.action_save, + MediaViewerEvents.SaveOnDisk(data), + ) } @Test fun `clicking on share emit expected Event`() { - testBottomSheetAction(CommonStrings.action_share, MediaViewerEvents.Share) + val data = aMediaViewerPageData() + testBottomSheetAction( + data, + CommonStrings.action_share, + MediaViewerEvents.Share(data), + ) } - private fun testBottomSheetAction(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) { + private fun testBottomSheetAction( + data: MediaViewerPageData.MediaViewerData, + contentDescriptionRes: Int, + expectedEvent: MediaViewerEvents, + ) { val eventsRecorder = EventsRecorder() rule.setMediaViewerView( aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, anImageMediaInfo()) - ), - mediaInfo = anImageMediaInfo(), + listData = listOf(data), mediaBottomSheetState = aMediaDetailsBottomSheetState(), eventSink = eventsRecorder ), ) rule.clickOn(contentDescriptionRes) - eventsRecorder.assertSingle(expectedEvent) + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(data), + expectedEvent, + ) + ) } @Test fun `clicking on image hides the overlay`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder() + val state = aMediaViewerState( + eventSink = eventsRecorder + ) rule.setMediaViewerView( - aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, anImageMediaInfo()) - ), - mediaInfo = anImageMediaInfo(), - eventSink = eventsRecorder - ), + state = state, ) // Ensure that the action are visible val contentDescription = rule.activity.getString(CommonStrings.action_open_with) @@ -120,54 +169,79 @@ class MediaViewerViewTest { rule.mainClock.advanceTimeBy(1_000) rule.onNodeWithContentDescription(contentDescription) .assertDoesNotExist() + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData), + ) + ) } @Test fun `clicking swipe on the image invokes the expected callback`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder() + val state = aMediaViewerState( + eventSink = eventsRecorder + ) ensureCalledOnce { callback -> rule.setMediaViewerView( - aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, anImageMediaInfo()) - ), - mediaInfo = anImageMediaInfo(), - eventSink = eventsRecorder - ), + state = state, onBackClick = callback, ) val imageContentDescription = rule.activity.getString(CommonStrings.common_image) rule.onNodeWithContentDescription(imageContentDescription).performTouchInput { swipeDown(startY = centerY) } rule.mainClock.advanceTimeBy(1_000) } + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData), + ) + ) } @Test fun `error case, click on retry emits the expected Event`() { val eventsRecorder = EventsRecorder() + val data = aMediaViewerPageData( + downloadedMedia = AsyncData.Failure(IllegalStateException("error")), + ) rule.setMediaViewerView( aMediaViewerState( - downloadedMedia = AsyncData.Failure(IllegalStateException("error")), - mediaInfo = anImageMediaInfo(), + listData = listOf(data), eventSink = eventsRecorder ), ) rule.clickOn(CommonStrings.action_retry) - eventsRecorder.assertSingle(MediaViewerEvents.RetryLoading) + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(data), + MediaViewerEvents.LoadMedia(data), + ) + ) } @Test fun `error case, click on cancel emits the expected Event`() { val eventsRecorder = EventsRecorder() + val data = aMediaViewerPageData( + downloadedMedia = AsyncData.Failure(IllegalStateException("error")), + ) rule.setMediaViewerView( aMediaViewerState( - downloadedMedia = AsyncData.Failure(IllegalStateException("error")), - mediaInfo = anImageMediaInfo(), + listData = listOf(data), eventSink = eventsRecorder ), ) rule.clickOn(CommonStrings.action_cancel) - eventsRecorder.assertSingle(MediaViewerEvents.ClearLoadingError) + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(data), + MediaViewerEvents.ClearLoadingError(data) + ) + ) } } diff --git a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt index 1b93856bde..f1ebbd04e9 100644 --- a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt +++ b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt @@ -43,6 +43,7 @@ class FakeLocalMediaFactory( dateSent = null, dateSentFull = null, waveform = null, + duration = null, ) return aLocalMedia(uri, mediaInfo) } diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 4a31e05852..f2d4f51872 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -29,6 +29,7 @@ "Show password" "Start a call" "User menu" + "View details" "Record voice message." "Stop recording" "Accept"