Merge pull request #4161 from element-hq/feature/bma/mediaNavigation

Media navigation with swipe gesture
This commit is contained in:
Benoit Marty 2025-01-23 18:33:52 +01:00 committed by GitHub
commit 26e2172edd
76 changed files with 2641 additions and 679 deletions

View file

@ -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.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent 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.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.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.BackstackWithOverlayBox 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.architecture.overlay.operation.show
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode 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.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -246,6 +248,8 @@ class MessagesFlowNode @AssistedInject constructor(
} }
is NavTarget.MediaViewer -> { is NavTarget.MediaViewer -> {
val params = MediaViewerEntryPoint.Params( 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, eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo, mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource, mediaSource = navTarget.mediaSource,
@ -447,6 +451,7 @@ class MessagesFlowNode @AssistedInject constructor(
mode = DateFormatterMode.Full, mode = DateFormatterMode.Full,
), ),
waveform = (content as? TimelineItemVoiceContent)?.waveform, waveform = (content as? TimelineItemVoiceContent)?.waveform,
duration = content.duration()?.toHumanReadableDuration(),
), ),
mediaSource = mediaSource, mediaSource = mediaSource,
thumbnailSource = thumbnailSource, thumbnailSource = thumbnailSource,

View file

@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import kotlin.time.Duration
@Immutable @Immutable
sealed interface TimelineItemEventContent { sealed interface TimelineItemEventContent {
@ -90,3 +91,12 @@ fun TimelineItemEventContent.isEdited(): Boolean = when (this) {
is TimelineItemEventMutableContent -> isEdited is TimelineItemEventMutableContent -> isEdited
else -> false else -> false
} }
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
return when (this) {
is TimelineItemAudioContent -> duration
is TimelineItemVideoContent -> duration
is TimelineItemVoiceContent -> duration
else -> null
}
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.dateformatter.api package io.element.android.libraries.dateformatter.api
import java.util.Locale import java.util.Locale
import kotlin.time.Duration
/** /**
* Convert milliseconds to human readable duration. * Convert milliseconds to human readable duration.
@ -38,3 +39,5 @@ fun Long.toHumanReadableDuration(): String {
String.format(Locale.US, "%d:%02d", minutes, seconds) String.format(Locale.US, "%d:%02d", minutes, seconds)
} }
} }
fun Duration.toHumanReadableDuration() = inWholeMilliseconds.toHumanReadableDuration()

View file

@ -55,8 +55,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -213,8 +211,8 @@ class RustTimeline(
override val timelineItems: Flow<List<MatrixTimelineItem>> = combine( override val timelineItems: Flow<List<MatrixTimelineItem>> = combine(
_timelineItems, _timelineItems,
backPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(), backPaginationStatus,
forwardPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(), forwardPaginationStatus,
matrixRoom.roomInfoFlow.map { it.creator }, matrixRoom.roomInfoFlow.map { it.creator },
isTimelineInitialized, isTimelineInitialized,
) { timelineItems, ) { timelineItems,

View file

@ -8,10 +8,12 @@
package io.element.android.libraries.matrix.impl.fixtures.fakes package io.element.android.libraries.matrix.impl.fixtures.fakes
import org.matrix.rustcomponents.sdk.NoPointer import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.PaginationStatusListener
import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.Timeline import org.matrix.rustcomponents.sdk.Timeline
import org.matrix.rustcomponents.sdk.TimelineDiff import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineListener import org.matrix.rustcomponents.sdk.TimelineListener
import uniffi.matrix_sdk_ui.LiveBackPaginationStatus
class FakeRustTimeline : Timeline(NoPointer) { class FakeRustTimeline : Timeline(NoPointer) {
private var listener: TimelineListener? = null private var listener: TimelineListener? = null
@ -23,4 +25,16 @@ class FakeRustTimeline : Timeline(NoPointer) {
fun emitDiff(diff: List<TimelineDiff>) { fun emitDiff(diff: List<TimelineDiff>) {
listener!!.onUpdate(diff) 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
} }

View file

@ -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,
)
}

View file

@ -25,6 +25,7 @@ data class MediaInfo(
val dateSent: String?, val dateSent: String?,
val dateSentFull: String?, val dateSentFull: String?,
val waveform: List<Float>?, val waveform: List<Float>?,
val duration: String?,
) : Parcelable ) : Parcelable
fun anImageMediaInfo( fun anImageMediaInfo(
@ -45,6 +46,7 @@ fun anImageMediaInfo(
dateSent = dateSent, dateSent = dateSent,
dateSentFull = dateSentFull, dateSentFull = dateSentFull,
waveform = null, waveform = null,
duration = null,
) )
fun aVideoMediaInfo( fun aVideoMediaInfo(
@ -52,6 +54,7 @@ fun aVideoMediaInfo(
senderName: String? = null, senderName: String? = null,
dateSent: String? = null, dateSent: String? = null,
dateSentFull: String? = null, dateSentFull: String? = null,
duration: String? = null,
): MediaInfo = MediaInfo( ): MediaInfo = MediaInfo(
filename = "a video file.mp4", filename = "a video file.mp4",
caption = caption, caption = caption,
@ -64,6 +67,7 @@ fun aVideoMediaInfo(
dateSent = dateSent, dateSent = dateSent,
dateSentFull = dateSentFull, dateSentFull = dateSentFull,
waveform = null, waveform = null,
duration = duration,
) )
fun aPdfMediaInfo( fun aPdfMediaInfo(
@ -84,6 +88,7 @@ fun aPdfMediaInfo(
dateSent = dateSent, dateSent = dateSent,
dateSentFull = dateSentFull, dateSentFull = dateSentFull,
waveform = null, waveform = null,
duration = null,
) )
fun anApkMediaInfo( fun anApkMediaInfo(
@ -103,6 +108,7 @@ fun anApkMediaInfo(
dateSent = dateSent, dateSent = dateSent,
dateSentFull = dateSentFull, dateSentFull = dateSentFull,
waveform = null, waveform = null,
duration = null,
) )
fun anAudioMediaInfo( fun anAudioMediaInfo(
@ -112,6 +118,7 @@ fun anAudioMediaInfo(
dateSent: String? = null, dateSent: String? = null,
dateSentFull: String? = null, dateSentFull: String? = null,
waveForm: List<Float>? = null, waveForm: List<Float>? = null,
duration: String? = null,
): MediaInfo = MediaInfo( ): MediaInfo = MediaInfo(
filename = filename, filename = filename,
caption = caption, caption = caption,
@ -124,6 +131,7 @@ fun anAudioMediaInfo(
dateSent = dateSent, dateSent = dateSent,
dateSentFull = dateSentFull, dateSentFull = dateSentFull,
waveform = waveForm, waveform = waveForm,
duration = duration,
) )
fun aVoiceMediaInfo( fun aVoiceMediaInfo(
@ -133,6 +141,7 @@ fun aVoiceMediaInfo(
dateSent: String? = null, dateSent: String? = null,
dateSentFull: String? = null, dateSentFull: String? = null,
waveForm: List<Float>? = null, waveForm: List<Float>? = null,
duration: String? = null,
): MediaInfo = MediaInfo( ): MediaInfo = MediaInfo(
filename = filename, filename = filename,
caption = caption, caption = caption,
@ -145,4 +154,5 @@ fun aVoiceMediaInfo(
dateSent = dateSent, dateSent = dateSent,
dateSentFull = dateSentFull, dateSentFull = dateSentFull,
waveform = waveForm, waveform = waveForm,
duration = duration,
) )

View file

@ -31,10 +31,17 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
} }
data class Params( data class Params(
val mode: MediaViewerMode,
val eventId: EventId?, val eventId: EventId?,
val mediaInfo: MediaInfo, val mediaInfo: MediaInfo,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val canShowInfo: Boolean, val canShowInfo: Boolean,
) : NodeInputs ) : NodeInputs
enum class MediaViewerMode {
SingleMedia,
TimelineImagesAndVideos,
TimelineFilesAndAudios,
}
} }

View file

@ -42,6 +42,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
val mimeType = MimeTypes.Images val mimeType = MimeTypes.Images
return params( return params(
MediaViewerEntryPoint.Params( MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = null, eventId = null,
mediaInfo = MediaInfo( mediaInfo = MediaInfo(
filename = filename, filename = filename,
@ -55,6 +56,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
dateSent = null, dateSent = null,
dateSentFull = null, dateSentFull = null,
waveform = null, waveform = null,
duration = null,
), ),
mediaSource = MediaSource(url = avatarUrl), mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null, thumbnailSource = null,

View file

@ -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.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import kotlinx.collections.immutable.persistentListOf
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -102,6 +101,7 @@ class EventItemFactory @Inject constructor(
dateSent = dateSent, dateSent = dateSent,
dateSentFull = dateSentFull, dateSentFull = dateSentFull,
waveform = null, waveform = null,
duration = null,
), ),
mediaSource = type.source, mediaSource = type.source,
) )
@ -120,8 +120,10 @@ class EventItemFactory @Inject constructor(
dateSent = dateSent, dateSent = dateSent,
dateSentFull = dateSentFull, dateSentFull = dateSentFull,
waveform = null, waveform = null,
duration = null,
), ),
mediaSource = type.source, mediaSource = type.source,
// TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource
) )
is ImageMessageType -> MediaItem.Image( is ImageMessageType -> MediaItem.Image(
id = currentTimelineItem.uniqueId, id = currentTimelineItem.uniqueId,
@ -138,9 +140,10 @@ class EventItemFactory @Inject constructor(
dateSent = dateSent, dateSent = dateSent,
dateSentFull = dateSentFull, dateSentFull = dateSentFull,
waveform = null, waveform = null,
duration = null,
), ),
mediaSource = type.source, mediaSource = type.source,
thumbnailSource = null, thumbnailSource = type.info?.thumbnailSource,
) )
is StickerMessageType -> MediaItem.Image( is StickerMessageType -> MediaItem.Image(
id = currentTimelineItem.uniqueId, id = currentTimelineItem.uniqueId,
@ -157,9 +160,10 @@ class EventItemFactory @Inject constructor(
dateSent = dateSent, dateSent = dateSent,
dateSentFull = dateSentFull, dateSentFull = dateSentFull,
waveform = null, waveform = null,
duration = null,
), ),
mediaSource = type.source, mediaSource = type.source,
thumbnailSource = null, thumbnailSource = type.info?.thumbnailSource,
) )
is VideoMessageType -> MediaItem.Video( is VideoMessageType -> MediaItem.Video(
id = currentTimelineItem.uniqueId, id = currentTimelineItem.uniqueId,
@ -176,10 +180,10 @@ class EventItemFactory @Inject constructor(
dateSent = dateSent, dateSent = dateSent,
dateSentFull = dateSentFull, dateSentFull = dateSentFull,
waveform = null, waveform = null,
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
), ),
mediaSource = type.source, mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource, thumbnailSource = type.info?.thumbnailSource,
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
) )
is VoiceMessageType -> MediaItem.Voice( is VoiceMessageType -> MediaItem.Voice(
id = currentTimelineItem.uniqueId, id = currentTimelineItem.uniqueId,
@ -196,10 +200,9 @@ class EventItemFactory @Inject constructor(
dateSent = dateSent, dateSent = dateSent,
dateSentFull = dateSentFull, dateSentFull = dateSentFull,
waveform = type.details?.waveform.orEmpty(), waveform = type.details?.waveform.orEmpty(),
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
), ),
mediaSource = type.source, mediaSource = type.source,
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
waveform = type.details?.waveform ?: persistentListOf(),
) )
} }
} }

View file

@ -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<AsyncData<GroupedMediaItems>>
fun getLastData(): AsyncData<GroupedMediaItems>
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<AsyncData<GroupedMediaItems>>(replay = 1)
override fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>> = groupedMediaItemsFlow
override fun getLastData(): AsyncData<GroupedMediaItems> = 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,
)
}
}

View file

@ -9,15 +9,12 @@ package io.element.android.libraries.mediaviewer.impl.gallery
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther 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.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.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory 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.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
import io.element.android.libraries.ui.strings.CommonStrings 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 import kotlinx.coroutines.launch
class MediaGalleryPresenter @AssistedInject constructor( class MediaGalleryPresenter @AssistedInject constructor(
@Assisted private val navigator: MediaGalleryNavigator, @Assisted private val navigator: MediaGalleryNavigator,
private val room: MatrixRoom, private val room: MatrixRoom,
private val timelineMediaItemsFactory: TimelineMediaItemsFactory, private val mediaGalleryDataSource: MediaGalleryDataSource,
private val localMediaFactory: LocalMediaFactory, private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader, private val mediaLoader: MatrixMediaLoader,
private val localMediaActions: LocalMediaActions, private val localMediaActions: LocalMediaActions,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val mediaItemsPostProcessor: MediaItemsPostProcessor,
) : Presenter<MediaGalleryState> { ) : Presenter<MediaGalleryState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -74,56 +62,36 @@ class MediaGalleryPresenter @AssistedInject constructor(
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) } var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
var mediaItems by remember {
mutableStateOf<AsyncData<ImmutableList<MediaItem>>>(AsyncData.Uninitialized)
}
val groupedMediaItems by remember { val groupedMediaItems by remember {
derivedStateOf { mediaGalleryDataSource.groupedMediaItemsFlow()
mediaItemsPostProcessor.process(
mediaItems = mediaItems,
)
}
} }
.collectAsState(AsyncData.Uninitialized)
LaunchedEffect(Unit) {
mediaGalleryDataSource.start()
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure() localMediaActions.Configure()
var timeline by remember { mutableStateOf<AsyncData<Timeline>>(AsyncData.Uninitialized) }
LaunchedEffect(Unit) {
room.mediaTimeline()
.fold(
{ timeline = AsyncData.Success(it) },
{ timeline = AsyncData.Failure(it) },
)
}
DisposableEffect(Unit) {
onDispose {
timeline.dataOrNull()?.close()
}
}
MediaListEffect(
timeline = timeline,
onItemsChange = { newItems ->
mediaItems = newItems
}
)
fun handleEvents(event: MediaGalleryEvents) { fun handleEvents(event: MediaGalleryEvents) {
when (event) { when (event) {
is MediaGalleryEvents.ChangeMode -> { is MediaGalleryEvents.ChangeMode -> {
mode = event.mode mode = event.mode
} }
is MediaGalleryEvents.LoadMore -> coroutineScope.launch { 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 { is MediaGalleryEvents.SaveOnDisk -> coroutineScope.launch {
mediaItems.dataOrNull().find(event.eventId)?.let { groupedMediaItems.dataOrNull().find(event.eventId)?.let {
saveOnDisk(it) saveOnDisk(it)
} }
} }
is MediaGalleryEvents.Share -> coroutineScope.launch { is MediaGalleryEvents.Share -> coroutineScope.launch {
mediaItems.dataOrNull().find(event.eventId)?.let { groupedMediaItems.dataOrNull().find(event.eventId)?.let {
share(it) share(it)
} }
} }
@ -172,49 +140,6 @@ class MediaGalleryPresenter @AssistedInject constructor(
) )
} }
@Composable
private fun MediaListEffect(
timeline: AsyncData<Timeline>,
onItemsChange: (AsyncData<ImmutableList<MediaItem>>) -> 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<Timeline>,
eventId: EventId,
) = launch {
timeline.dataOrNull()?.redactEvent(
eventOrTransactionId = eventId.toEventOrTransactionId(),
reason = null,
)
}
private suspend fun downloadMedia(mediaItem: MediaItem.Event): Result<LocalMedia> { private suspend fun downloadMedia(mediaItem: MediaItem.Event): Result<LocalMedia> {
return mediaLoader.downloadMediaFile( return mediaLoader.downloadMediaFile(
source = mediaItem.mediaSource(), source = mediaItem.mediaSource(),
@ -264,10 +189,10 @@ class MediaGalleryPresenter @AssistedInject constructor(
} }
} }
private fun List<MediaItem>?.find(eventId: EventId?): MediaItem.Event? { private fun GroupedMediaItems?.find(eventId: EventId?): MediaItem.Event? {
if (this == null || eventId == null) { if (this == null || eventId == null) {
return null return null
} }
return filterIsInstance<MediaItem.Event>() return (imageAndVideoItems + fileItems).filterIsInstance<MediaItem.Event>()
.firstOrNull { it.eventId() == eventId } .firstOrNull { it.eventId() == eventId }
} }

View file

@ -122,7 +122,7 @@ private fun aMediaGalleryState(
eventSink = {} eventSink = {}
) )
private fun aGroupedMediaItems( fun aGroupedMediaItems(
imageAndVideoItems: List<MediaItem> = emptyList(), imageAndVideoItems: List<MediaItem> = emptyList(),
fileItems: List<MediaItem> = emptyList(), fileItems: List<MediaItem> = emptyList(),
) = GroupedMediaItems( ) = GroupedMediaItems(

View file

@ -108,15 +108,15 @@ fun MediaGalleryView(
) { paddingValues -> ) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier
.padding(paddingValues) .padding(paddingValues)
.consumeWindowInsets(paddingValues) .consumeWindowInsets(paddingValues)
.fillMaxSize(), .fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
SingleChoiceSegmentedButtonRow( SingleChoiceSegmentedButtonRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) { ) {
MediaGalleryMode.entries.forEach { mode -> MediaGalleryMode.entries.forEach { mode ->
SegmentedButton( SegmentedButton(
@ -137,7 +137,6 @@ fun MediaGalleryView(
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
userScrollEnabled = false, userScrollEnabled = false,
modifier = Modifier,
) { page -> ) { page ->
val mode = MediaGalleryMode.entries[page] val mode = MediaGalleryMode.entries[page]
MediaGalleryPage( MediaGalleryPage(
@ -198,6 +197,13 @@ private fun MediaGalleryPage(
) { ) {
val groupedMediaItems = state.groupedMediaItems val groupedMediaItems = state.groupedMediaItems
if (groupedMediaItems.isLoadingItems(mode)) { 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) LoadingContent(mode)
} else { } else {
when (groupedMediaItems) { when (groupedMediaItems) {
@ -348,8 +354,8 @@ private fun MediaGalleryImageGrid(
) { ) {
LazyVerticalGrid( LazyVerticalGrid(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
columns = GridCells.Adaptive(80.dp), columns = GridCells.Adaptive(80.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
@ -420,9 +426,9 @@ private fun LoadingMoreIndicator(
Timeline.PaginationDirection.FORWARDS -> { Timeline.PaginationDirection.FORWARDS -> {
LinearProgressIndicator( LinearProgressIndicator(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 2.dp) .padding(top = 2.dp)
.height(1.dp) .height(1.dp)
) )
} }
Timeline.PaginationDirection.BACKWARDS -> { Timeline.PaginationDirection.BACKWARDS -> {
@ -460,9 +466,9 @@ private fun EmptyContent(
OnboardingBackground() OnboardingBackground()
PageTitle( PageTitle(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 44.dp) .padding(top = 44.dp)
.padding(24.dp), .padding(24.dp),
title = stringResource(titleRes), title = stringResource(titleRes),
iconStyle = BigIcon.Style.Default(icon), iconStyle = BigIcon.Style.Default(icon),
subtitle = stringResource(subtitleRes), subtitle = stringResource(subtitleRes),
@ -480,9 +486,9 @@ private fun LoadingContent(
OnboardingBackground() OnboardingBackground()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = 48.dp) .padding(top = 48.dp)
.padding(24.dp), .padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {

View file

@ -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.api.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.MediaInfo
import kotlinx.collections.immutable.ImmutableList
sealed interface MediaItem { sealed interface MediaItem {
data class DateSeparator( data class DateSeparator(
@ -46,7 +45,6 @@ sealed interface MediaItem {
val mediaInfo: MediaInfo, val mediaInfo: MediaInfo,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val duration: String?,
) : Event { ) : Event {
val thumbnailMediaRequestData: MediaRequestData val thumbnailMediaRequestData: MediaRequestData
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100)) get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
@ -64,8 +62,6 @@ sealed interface MediaItem {
val eventId: EventId?, val eventId: EventId?,
val mediaInfo: MediaInfo, val mediaInfo: MediaInfo,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val duration: String?,
val waveform: ImmutableList<Float>,
) : Event ) : Event
data class File( data class File(

View file

@ -7,32 +7,19 @@
package io.element.android.libraries.mediaviewer.impl.gallery 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 kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject import javax.inject.Inject
class MediaItemsPostProcessor @Inject constructor() { class MediaItemsPostProcessor @Inject constructor() {
fun process( fun process(
mediaItems: AsyncData<ImmutableList<MediaItem>>, mediaItems: List<MediaItem>,
): AsyncData<GroupedMediaItems> { ): GroupedMediaItems {
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<MediaItem>.process(): GroupedMediaItems {
val imageAndVideoItems = mutableListOf<MediaItem>() val imageAndVideoItems = mutableListOf<MediaItem>()
val fileItems = mutableListOf<MediaItem>() val fileItems = mutableListOf<MediaItem>()
val imageAndVideoItemsSubList = mutableListOf<MediaItem.Event>() val imageAndVideoItemsSubList = mutableListOf<MediaItem.Event>()
val fileItemsSublist = mutableListOf<MediaItem.Event>() val fileItemsSublist = mutableListOf<MediaItem.Event>()
forEach { item -> mediaItems.forEach { item ->
when (item) { when (item) {
is MediaItem.DateSeparator -> { is MediaItem.DateSeparator -> {
if (imageAndVideoItemsSubList.isNotEmpty()) { if (imageAndVideoItemsSubList.isNotEmpty()) {

View file

@ -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<GroupedMediaItems> = 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(),
)
}
)
}
}

View file

@ -60,6 +60,7 @@ class MediaGalleryRootNode @AssistedInject constructor(
@Parcelize @Parcelize
data class MediaViewer( data class MediaViewer(
val mode: MediaViewerEntryPoint.MediaViewerMode,
val eventId: EventId?, val eventId: EventId?,
val mediaInfo: MediaInfo, val mediaInfo: MediaInfo,
val mediaSource: MediaSource, val mediaSource: MediaSource,
@ -92,8 +93,16 @@ class MediaGalleryRootNode @AssistedInject constructor(
} }
override fun onItemClick(item: MediaItem.Event) { 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( overlay.show(
NavTarget.MediaViewer( NavTarget.MediaViewer(
mode = mode,
eventId = item.eventId(), eventId = item.eventId(),
mediaInfo = item.mediaInfo(), mediaInfo = item.mediaInfo(),
mediaSource = item.mediaSource(), mediaSource = item.mediaSource(),
@ -117,6 +126,7 @@ class MediaGalleryRootNode @AssistedInject constructor(
mediaViewerEntryPoint.nodeBuilder(this, buildContext) mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.params( .params(
MediaViewerEntryPoint.Params( MediaViewerEntryPoint.Params(
mode = navTarget.mode,
eventId = navTarget.eventId, eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo, mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource, mediaSource = navTarget.mediaSource,

View file

@ -9,6 +9,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.preview.loremIpsum 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.core.UniqueId
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
@ -30,12 +31,13 @@ class MediaItemFileProvider : PreviewParameterProvider<MediaItem.File> {
fun aMediaItemFile( fun aMediaItemFile(
id: UniqueId = UniqueId("fileId"), id: UniqueId = UniqueId("fileId"),
eventId: EventId? = null,
filename: String = "filename", filename: String = "filename",
caption: String? = null, caption: String? = null,
): MediaItem.File { ): MediaItem.File {
return MediaItem.File( return MediaItem.File(
id = id, id = id,
eventId = null, eventId = eventId,
mediaInfo = aPdfMediaInfo( mediaInfo = aPdfMediaInfo(
filename = filename, filename = filename,
caption = caption, caption = caption,

View file

@ -18,6 +18,7 @@ fun aMediaItemImage(
id: UniqueId = UniqueId("imageId"), id: UniqueId = UniqueId("imageId"),
eventId: EventId? = null, eventId: EventId? = null,
senderId: UserId? = null, senderId: UserId? = null,
mediaSourceUrl: String = "",
): MediaItem.Image { ): MediaItem.Image {
return MediaItem.Image( return MediaItem.Image(
id = id, id = id,
@ -25,7 +26,7 @@ fun aMediaItemImage(
mediaInfo = anImageMediaInfo( mediaInfo = anImageMediaInfo(
senderId = senderId, senderId = senderId,
), ),
mediaSource = MediaSource(""), mediaSource = MediaSource(mediaSourceUrl),
thumbnailSource = null, thumbnailSource = null,
) )
} }

View file

@ -13,10 +13,11 @@ import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
fun aMediaItemLoadingIndicator( fun aMediaItemLoadingIndicator(
id: UniqueId = UniqueId("loadingId"), id: UniqueId = UniqueId("loadingId"),
direction: Timeline.PaginationDirection = Timeline.PaginationDirection.BACKWARDS,
): MediaItem.LoadingIndicator { ): MediaItem.LoadingIndicator {
return MediaItem.LoadingIndicator( return MediaItem.LoadingIndicator(
id = id, id = id,
direction = Timeline.PaginationDirection.BACKWARDS, direction = direction,
timestamp = 123, timestamp = 123,
) )
} }

View file

@ -31,9 +31,10 @@ fun aMediaItemVideo(
return MediaItem.Video( return MediaItem.Video(
id = id, id = id,
eventId = null, eventId = null,
mediaInfo = aVideoMediaInfo(), mediaInfo = aVideoMediaInfo(
duration = duration
),
mediaSource = mediaSource, mediaSource = mediaSource,
thumbnailSource = null, thumbnailSource = null,
duration = duration,
) )
} }

View file

@ -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.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import kotlinx.collections.immutable.toImmutableList
class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> { class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> {
override val values: Sequence<MediaItem.Voice> override val values: Sequence<MediaItem.Voice>
@ -46,9 +45,9 @@ fun aMediaItemVoice(
mediaInfo = aVoiceMediaInfo( mediaInfo = aVoiceMediaInfo(
filename = filename, filename = filename,
caption = caption, caption = caption,
duration = duration,
waveForm = waveform,
), ),
mediaSource = MediaSource(""), mediaSource = MediaSource(""),
duration = duration,
waveform = waveform.toImmutableList(),
) )
} }

View file

@ -101,10 +101,10 @@ private fun VideoInfoRow(
imageVector = CompoundIcons.VideoCallSolid(), imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = null contentDescription = null
) )
if (video.duration != null) { video.mediaInfo.duration?.let { duration ->
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
Text( Text(
text = video.duration, text = duration,
style = ElementTheme.typography.fontBodySmMedium, style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
) )

View file

@ -115,7 +115,7 @@ private fun VoiceInfoRow(
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( 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, color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdMedium, style = ElementTheme.typography.fontBodyMdMedium,
maxLines = 1, maxLines = 1,
@ -128,7 +128,7 @@ private fun VoiceInfoRow(
.height(34.dp), .height(34.dp),
showCursor = state.showCursor, showCursor = state.showCursor,
playbackProgress = state.progress, playbackProgress = state.progress,
waveform = voice.waveform.toPersistentList(), waveform = voice.mediaInfo.waveform.orEmpty().toPersistentList(),
onSeek = { onSeek = {
state.eventSink(VoiceMessageEvents.Seek(it)) state.eventSink(VoiceMessageEvents.Seek(it))
}, },

View file

@ -48,6 +48,7 @@ class AndroidLocalMediaFactory @Inject constructor(
dateSent = mediaInfo.dateSent, dateSent = mediaInfo.dateSent,
dateSentFull = mediaInfo.dateSentFull, dateSentFull = mediaInfo.dateSentFull,
waveform = mediaInfo.waveform, waveform = mediaInfo.waveform,
duration = mediaInfo.duration,
) )
override fun createFromUri( override fun createFromUri(
@ -67,6 +68,7 @@ class AndroidLocalMediaFactory @Inject constructor(
dateSent = null, dateSent = null,
dateSentFull = null, dateSentFull = null,
waveform = null, waveform = null,
duration = null,
) )
private fun createFromUri( private fun createFromUri(
@ -81,6 +83,7 @@ class AndroidLocalMediaFactory @Inject constructor(
dateSent: String?, dateSent: String?,
dateSentFull: String?, dateSentFull: String?,
waveform: List<Float>?, waveform: List<Float>?,
duration: String?,
): LocalMedia { ): LocalMedia {
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
val fileName = name ?: context.getFileName(uri) ?: "" val fileName = name ?: context.getFileName(uri) ?: ""
@ -100,6 +103,7 @@ class AndroidLocalMediaFactory @Inject constructor(
dateSent = dateSent, dateSent = dateSent,
dateSentFull = dateSentFull, dateSentFull = dateSentFull,
waveform = waveform, waveform = waveform,
duration = duration,
) )
) )
} }

View file

@ -27,6 +27,7 @@ fun LocalMediaView(
bottomPaddingInPixels: Int, bottomPaddingInPixels: Int,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isDisplayed: Boolean = true,
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(), localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
mediaInfo: MediaInfo? = localMedia?.info, mediaInfo: MediaInfo? = localMedia?.info,
) { ) {
@ -39,6 +40,7 @@ fun LocalMediaView(
onClick = onClick, onClick = onClick,
) )
mimeType.isMimeTypeVideo() -> MediaVideoView( mimeType.isMimeTypeVideo() -> MediaVideoView(
isDisplayed = isDisplayed,
localMediaViewState = localMediaViewState, localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels, bottomPaddingInPixels = bottomPaddingInPixels,
localMedia = localMedia, localMedia = localMedia,
@ -51,6 +53,7 @@ fun LocalMediaView(
onClick = onClick, onClick = onClick,
) )
mimeType.isMimeTypeAudio() -> MediaAudioView( mimeType.isMimeTypeAudio() -> MediaAudioView(
isDisplayed = isDisplayed,
localMediaViewState = localMediaViewState, localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels, bottomPaddingInPixels = bottomPaddingInPixels,
localMedia = localMedia, localMedia = localMedia,

View file

@ -83,9 +83,11 @@ fun MediaAudioView(
localMedia: LocalMedia?, localMedia: LocalMedia?,
info: MediaInfo?, info: MediaInfo?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isDisplayed: Boolean = true,
) { ) {
val exoPlayer = rememberExoPlayer() val exoPlayer = rememberExoPlayer()
ExoPlayerMediaAudioView( ExoPlayerMediaAudioView(
isDisplayed = isDisplayed,
localMediaViewState = localMediaViewState, localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels, bottomPaddingInPixels = bottomPaddingInPixels,
exoPlayer = exoPlayer, exoPlayer = exoPlayer,
@ -98,6 +100,7 @@ fun MediaAudioView(
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
@Composable @Composable
private fun ExoPlayerMediaAudioView( private fun ExoPlayerMediaAudioView(
isDisplayed: Boolean,
localMediaViewState: LocalMediaViewState, localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int, bottomPaddingInPixels: Int,
exoPlayer: ExoPlayer, 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) { if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) { LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri) val mediaItem = MediaItem.fromUri(localMedia.uri)

View file

@ -26,7 +26,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter 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.IconButton
import io.element.android.libraries.designsystem.theme.components.Slider import io.element.android.libraries.designsystem.theme.components.Slider
import io.element.android.libraries.designsystem.theme.components.Text 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 import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
@ -58,7 +58,7 @@ fun MediaPlayerControllerView(
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.background(color = Color(0x99101317)) .background(color = bgCanvasWithTransparency)
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {

View file

@ -57,6 +57,7 @@ import kotlin.time.Duration.Companion.seconds
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
@Composable @Composable
fun MediaVideoView( fun MediaVideoView(
isDisplayed: Boolean,
localMediaViewState: LocalMediaViewState, localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int, bottomPaddingInPixels: Int,
localMedia: LocalMedia?, localMedia: LocalMedia?,
@ -64,6 +65,7 @@ fun MediaVideoView(
) { ) {
val exoPlayer = rememberExoPlayer() val exoPlayer = rememberExoPlayer()
ExoPlayerMediaVideoView( ExoPlayerMediaVideoView(
isDisplayed = isDisplayed,
localMediaViewState = localMediaViewState, localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels, bottomPaddingInPixels = bottomPaddingInPixels,
exoPlayer = exoPlayer, exoPlayer = exoPlayer,
@ -75,6 +77,7 @@ fun MediaVideoView(
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
@Composable @Composable
private fun ExoPlayerMediaVideoView( private fun ExoPlayerMediaVideoView(
isDisplayed: Boolean,
localMediaViewState: LocalMediaViewState, localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int, bottomPaddingInPixels: Int,
exoPlayer: ExoPlayer, 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) { if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) { LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri) val mediaItem = MediaItem.fromUri(localMedia.uri)
@ -245,6 +254,7 @@ private fun ExoPlayerMediaVideoView(
@Composable @Composable
internal fun MediaVideoViewPreview() = ElementPreview { internal fun MediaVideoViewPreview() = ElementPreview {
MediaVideoView( MediaVideoView(
isDisplayed = true,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
bottomPaddingInPixels = 0, bottomPaddingInPixels = 0,
localMediaViewState = rememberLocalMediaViewState(), localMediaViewState = rememberLocalMediaViewState(),

View file

@ -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)

View file

@ -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<MediaFile> = mutableListOf()
// Map of sourceUrl to local media state
private val localMediaStates: MutableMap<String, MutableState<AsyncData<LocalMedia>>> =
mutableMapOf()
fun setup() {
galleryDataSource.start()
}
fun dispose() {
mediaFiles.forEach { it.close() }
mediaFiles.clear()
localMediaStates.clear()
}
@Composable
fun collectAsState(): State<PersistentList<MediaViewerPageData>> {
return remember { dataFlow() }.collectAsState(initialData())
}
@VisibleForTesting
fun dataFlow(): Flow<PersistentList<MediaViewerPageData>> {
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<MediaViewerPageData> {
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<LocalMedia> 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<MediaItem>) = 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)
}
}
}

View file

@ -8,16 +8,23 @@
package io.element.android.libraries.mediaviewer.impl.viewer package io.element.android.libraries.mediaviewer.impl.viewer
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
sealed interface MediaViewerEvents { sealed interface MediaViewerEvents {
data object SaveOnDisk : MediaViewerEvents data class LoadMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data object Share : MediaViewerEvents data class SaveOnDisk(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data object OpenWith : MediaViewerEvents data class Share(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data object RetryLoading : MediaViewerEvents data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data object ClearLoadingError : MediaViewerEvents data class ClearLoadingError(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents
data object OpenInfo : MediaViewerEvents data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ConfirmDelete(val eventId: EventId) : MediaViewerEvents data class ConfirmDelete(
val eventId: EventId,
val data: MediaViewerPageData.MediaViewerData,
) : MediaViewerEvents
data object CloseBottomSheet : MediaViewerEvents data object CloseBottomSheet : MediaViewerEvents
data class Delete(val eventId: EventId) : MediaViewerEvents data class Delete(val eventId: EventId) : MediaViewerEvents
data class OnNavigateTo(val index: Int) : MediaViewerEvents
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvents
} }

View file

@ -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)
}

View file

@ -18,15 +18,27 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ForcedDarkElementTheme import io.element.android.compound.theme.ForcedDarkElementTheme
import io.element.android.libraries.architecture.inputs 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.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId 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.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) @ContributesNode(RoomScope::class)
class MediaViewerNode @AssistedInject constructor( class MediaViewerNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: MediaViewerPresenter.Factory, presenterFactory: MediaViewerPresenter.Factory,
timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource,
mediaLoader: MatrixMediaLoader,
localMediaFactory: LocalMediaFactory,
coroutineDispatchers: CoroutineDispatchers,
systemClock: SystemClock,
) : Node(buildContext, plugins = plugins), ) : Node(buildContext, plugins = plugins),
MediaViewerNavigator { MediaViewerNavigator {
private val inputs = inputs<MediaViewerEntryPoint.Params>() private val inputs = inputs<MediaViewerEntryPoint.Params>()
@ -47,9 +59,29 @@ class MediaViewerNode @AssistedInject constructor(
onDone() 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( private val presenter = presenterFactory.create(
inputs = inputs, inputs = inputs,
navigator = this, navigator = this,
dataSource = MediaViewerDataSource(
dispatcher = coroutineDispatchers.computation,
galleryMode = galleryMode,
galleryDataSource = mediaGallerySource,
mediaLoader = mediaLoader,
localMediaFactory = localMediaFactory,
systemClock = systemClock,
)
) )
@Composable @Composable

View file

@ -10,7 +10,6 @@ package io.element.android.libraries.mediaviewer.impl.viewer
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf 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.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState 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.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.MatrixRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther 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.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId 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.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMedia 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.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
import io.element.android.libraries.ui.strings.CommonStrings 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( class MediaViewerPresenter @AssistedInject constructor(
@Assisted private val inputs: MediaViewerEntryPoint.Params, @Assisted private val inputs: MediaViewerEntryPoint.Params,
@Assisted private val navigator: MediaViewerNavigator, @Assisted private val navigator: MediaViewerNavigator,
@Assisted private val dataSource: MediaViewerDataSource,
private val room: MatrixRoom, private val room: MatrixRoom,
private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader,
private val localMediaActions: LocalMediaActions, private val localMediaActions: LocalMediaActions,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MediaViewerState> { ) : Presenter<MediaViewerState> {
@ -56,83 +51,89 @@ class MediaViewerPresenter @AssistedInject constructor(
fun create( fun create(
inputs: MediaViewerEntryPoint.Params, inputs: MediaViewerEntryPoint.Params,
navigator: MediaViewerNavigator, navigator: MediaViewerNavigator,
dataSource: MediaViewerDataSource,
): MediaViewerPresenter ): MediaViewerPresenter
} }
@Composable @Composable
override fun present(): MediaViewerState { override fun present(): MediaViewerState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var loadMediaTrigger by remember { mutableIntStateOf(0) } val data by dataSource.collectAsState()
val mediaFile: MutableState<MediaFile?> = remember { var currentIndex by remember { mutableIntStateOf(searchIndex(data, inputs.eventId)) }
mutableStateOf(null)
}
val localMedia: MutableState<AsyncData<LocalMedia>> = remember {
mutableStateOf(AsyncData.Uninitialized)
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure()
DisposableEffect(loadMediaTrigger) {
coroutineScope.downloadMedia(mediaFile, localMedia)
onDispose {
mediaFile.value?.close()
}
}
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) } var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
fun handleEvents(mediaViewerEvents: MediaViewerEvents) { DisposableEffect(Unit) {
when (mediaViewerEvents) { dataSource.setup()
MediaViewerEvents.RetryLoading -> loadMediaTrigger++ onDispose {
MediaViewerEvents.ClearLoadingError -> localMedia.value = AsyncData.Uninitialized dataSource.dispose()
MediaViewerEvents.SaveOnDisk -> { }
mediaBottomSheetState = MediaBottomSheetState.Hidden }
coroutineScope.saveOnDisk(localMedia.value) localMediaActions.Configure()
fun handleEvents(event: MediaViewerEvents) {
when (event) {
is MediaViewerEvents.LoadMedia -> {
coroutineScope.downloadMedia(data = event.data)
} }
MediaViewerEvents.Share -> { is MediaViewerEvents.ClearLoadingError -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden dataSource.clearLoadingError(event.data)
coroutineScope.share(localMedia.value)
} }
MediaViewerEvents.OpenWith -> { is MediaViewerEvents.SaveOnDisk -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.open(localMedia.value) coroutineScope.saveOnDisk(event.data.downloadedMedia.value)
}
is MediaViewerEvents.Share -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.share(event.data.downloadedMedia.value)
}
is MediaViewerEvents.OpenWith -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.open(event.data.downloadedMedia.value)
} }
is MediaViewerEvents.Delete -> { is MediaViewerEvents.Delete -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.delete(mediaViewerEvents.eventId) coroutineScope.delete(event.eventId)
} }
is MediaViewerEvents.ViewInTimeline -> { is MediaViewerEvents.ViewInTimeline -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onViewInTimelineClick(mediaViewerEvents.eventId) navigator.onViewInTimelineClick(event.eventId)
} }
MediaViewerEvents.OpenInfo -> coroutineScope.launch { is MediaViewerEvents.OpenInfo -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState( mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState(
eventId = inputs.eventId, eventId = event.data.eventId,
canDelete = when (inputs.mediaInfo.senderId) { canDelete = when (event.data.mediaInfo.senderId) {
null -> false null -> false
room.sessionId -> room.canRedactOwn().getOrElse { false } && inputs.eventId != null room.sessionId -> room.canRedactOwn().getOrElse { false } && event.data.eventId != null
else -> room.canRedactOther().getOrElse { false } && inputs.eventId != null else -> room.canRedactOther().getOrElse { false } && event.data.eventId != null
}, },
mediaInfo = inputs.mediaInfo, mediaInfo = event.data.mediaInfo,
thumbnailSource = inputs.thumbnailSource, thumbnailSource = event.data.thumbnailSource,
) )
} }
is MediaViewerEvents.ConfirmDelete -> { is MediaViewerEvents.ConfirmDelete -> {
mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState( mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState(
eventId = mediaViewerEvents.eventId, eventId = event.eventId,
mediaInfo = inputs.mediaInfo, mediaInfo = event.data.mediaInfo,
thumbnailSource = inputs.thumbnailSource ?: inputs.mediaSource, thumbnailSource = event.data.thumbnailSource ?: event.data.mediaSource,
) )
} }
MediaViewerEvents.CloseBottomSheet -> { MediaViewerEvents.CloseBottomSheet -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden mediaBottomSheetState = MediaBottomSheetState.Hidden
} }
is MediaViewerEvents.OnNavigateTo -> {
currentIndex = event.index
}
is MediaViewerEvents.LoadMore -> coroutineScope.launch {
dataSource.loadMore(event.direction)
}
} }
} }
return MediaViewerState( return MediaViewerState(
eventId = inputs.eventId, listData = data,
mediaInfo = inputs.mediaInfo, currentIndex = currentIndex,
thumbnailSource = inputs.thumbnailSource,
downloadedMedia = localMedia.value,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
canShowInfo = inputs.canShowInfo, canShowInfo = inputs.canShowInfo,
mediaBottomSheetState = mediaBottomSheetState, mediaBottomSheetState = mediaBottomSheetState,
@ -140,28 +141,10 @@ class MediaViewerPresenter @AssistedInject constructor(
) )
} }
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<AsyncData<LocalMedia>>) = launch { private fun CoroutineScope.downloadMedia(
localMedia.value = AsyncData.Loading() data: MediaViewerPageData.MediaViewerData,
mediaLoader.downloadMediaFile( ) = launch {
source = inputs.mediaSource, dataSource.loadMedia(data)
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.saveOnDisk(localMedia: AsyncData<LocalMedia>) = launch { private fun CoroutineScope.saveOnDisk(localMedia: AsyncData<LocalMedia>) = launch {
@ -216,4 +199,13 @@ class MediaViewerPresenter @AssistedInject constructor(
CommonStrings.error_unknown CommonStrings.error_unknown
} }
} }
private fun searchIndex(data: List<MediaViewerPageData>, eventId: EventId?): Int {
if (eventId == null) {
return 0
}
return data.indexOfFirst {
(it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId
}.coerceAtLeast(0)
}
} }

View file

@ -7,21 +7,41 @@
package io.element.android.libraries.mediaviewer.impl.viewer 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.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage 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.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource 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.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia 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.MediaBottomSheetState
import kotlinx.collections.immutable.ImmutableList
data class MediaViewerState( data class MediaViewerState(
val eventId: EventId?, val listData: ImmutableList<MediaViewerPageData>,
val mediaInfo: MediaInfo, val currentIndex: Int,
val thumbnailSource: MediaSource?,
val downloadedMedia: AsyncData<LocalMedia>,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val canShowInfo: Boolean, val canShowInfo: Boolean,
val mediaBottomSheetState: MediaBottomSheetState, val mediaBottomSheetState: MediaBottomSheetState,
val eventSink: (MediaViewerEvents) -> Unit, 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<AsyncData<LocalMedia>>,
) : MediaViewerPageData
}

View file

@ -8,9 +8,12 @@
package io.element.android.libraries.mediaviewer.impl.viewer package io.element.android.libraries.mediaviewer.impl.viewer
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.media.aWaveForm 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.MediaInfo
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo 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.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirmationState import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirmationState
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
import kotlinx.collections.immutable.toPersistentList
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> { open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
override val values: Sequence<MediaViewerState> override val values: Sequence<MediaViewerState>
get() = sequenceOf( get() = sequenceOf(
aMediaViewerState(), aMediaViewerState(),
aMediaViewerState(AsyncData.Loading()), aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Loading()))),
aMediaViewerState(AsyncData.Failure(IllegalStateException("error"))), aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Failure(IllegalStateException("error"))))),
anImageMediaInfo( anImageMediaInfo(
senderName = "Sally Sanderson", senderName = "Sally Sanderson",
dateSent = "21 NOV, 2024", dateSent = "21 NOV, 2024",
caption = "A caption", caption = "A caption",
).let { ).let {
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listOf(
LocalMedia(Uri.EMPTY, it) aMediaViewerPageData(
), downloadedMedia = AsyncData.Success(
mediaInfo = it, LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
) )
}, },
aVideoMediaInfo( aVideoMediaInfo(
@ -46,50 +54,78 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
caption = "A caption", caption = "A caption",
).let { ).let {
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listOf(
LocalMedia(Uri.EMPTY, it) aMediaViewerPageData(
), downloadedMedia = AsyncData.Success(
mediaInfo = it, LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
) )
}, },
aPdfMediaInfo().let { aPdfMediaInfo().let {
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listOf(
LocalMedia(Uri.EMPTY, it) aMediaViewerPageData(
), downloadedMedia = AsyncData.Success(
mediaInfo = it, LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
) )
}, },
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Loading(), listOf(
mediaInfo = anApkMediaInfo(), aMediaViewerPageData(
downloadedMedia = AsyncData.Loading(),
mediaInfo = anApkMediaInfo(),
)
)
), ),
anApkMediaInfo().let { anApkMediaInfo().let {
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listOf(
LocalMedia(Uri.EMPTY, it) aMediaViewerPageData(
), downloadedMedia = AsyncData.Success(
mediaInfo = it, LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
) )
}, },
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Loading(), listOf(
mediaInfo = anAudioMediaInfo(), aMediaViewerPageData(
downloadedMedia = AsyncData.Loading(),
mediaInfo = anAudioMediaInfo(),
)
)
), ),
anAudioMediaInfo().let { anAudioMediaInfo().let {
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listOf(
LocalMedia(Uri.EMPTY, it) aMediaViewerPageData(
), downloadedMedia = AsyncData.Success(
mediaInfo = it, LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
) )
}, },
anImageMediaInfo().let { anImageMediaInfo().let {
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listOf(
LocalMedia(Uri.EMPTY, it) aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
), ),
mediaInfo = it,
canShowInfo = false, canShowInfo = false,
) )
}, },
@ -103,26 +139,60 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
waveForm = aWaveForm(), waveForm = aWaveForm(),
).let { ).let {
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listOf(
LocalMedia(Uri.EMPTY, it) aMediaViewerPageData(
), downloadedMedia = AsyncData.Success(
mediaInfo = it, 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<LocalMedia> = AsyncData.Uninitialized, downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
mediaInfo: MediaInfo = anImageMediaInfo(), 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<MediaViewerPageData> = listOf(aMediaViewerPageData()),
currentIndex: Int = 0,
canShowInfo: Boolean = true, canShowInfo: Boolean = true,
mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden, mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
eventSink: (MediaViewerEvents) -> Unit = {}, eventSink: (MediaViewerEvents) -> Unit = {},
) = MediaViewerState( ) = MediaViewerState(
eventId = null, listData = listData.toPersistentList(),
mediaInfo = mediaInfo, currentIndex = currentIndex,
thumbnailSource = null,
downloadedMedia = downloadedMedia,
snackbarMessage = null, snackbarMessage = null,
canShowInfo = canShowInfo, canShowInfo = canShowInfo,
mediaBottomSheetState = mediaBottomSheetState, mediaBottomSheetState = mediaBottomSheetState,

View file

@ -11,7 +11,6 @@ package io.element.android.libraries.mediaviewer.impl.viewer
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background 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.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -35,6 +36,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo 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.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark 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.LocalMediaView
import io.element.android.libraries.mediaviewer.impl.local.PlayableState 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.local.rememberLocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.delay 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.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState import me.saket.telephoto.zoomable.rememberZoomableState
import kotlin.time.Duration import timber.log.Timber
@Composable @Composable
fun MediaViewerView( fun MediaViewerView(
@ -93,51 +95,129 @@ fun MediaViewerView(
var showOverlay by remember { mutableStateOf(true) } var showOverlay by remember { mutableStateOf(true) }
val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0 val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0
var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) } val currentData = state.listData.getOrNull(state.currentIndex)
BackHandler { onBackClick() } BackHandler { onBackClick() }
Scaffold( Scaffold(
modifier, modifier,
containerColor = Color.Transparent, containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
) { ) {
MediaViewerPage( val pagerState = rememberPagerState(state.currentIndex, 0f) {
showOverlay = showOverlay, state.listData.size
bottomPaddingInPixels = bottomPaddingInPixels, }
state = state, LaunchedEffect(pagerState) {
onDismiss = { snapshotFlow { pagerState.currentPage }.collect { page ->
onBackClick() state.eventSink(MediaViewerEvents.OnNavigateTo(page))
},
onShowOverlayChange = {
showOverlay = it
} }
) }
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()) { AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.navigationBarsPadding() .navigationBarsPadding()
) { ) {
MediaViewerTopBar( when (currentData) {
actionsEnabled = state.downloadedMedia is AsyncData.Success, is MediaViewerPageData.MediaViewerData -> {
mimeType = state.mediaInfo.mimeType, MediaViewerTopBar(
senderName = state.mediaInfo.senderName, data = currentData,
dateSent = state.mediaInfo.dateSent, canShowInfo = state.canShowInfo,
canShowInfo = state.canShowInfo, onBackClick = onBackClick,
onBackClick = onBackClick, onInfoClick = {
onInfoClick = { state.eventSink(MediaViewerEvents.OpenInfo(currentData))
state.eventSink(MediaViewerEvents.OpenInfo) },
}, eventSink = state.eventSink
eventSink = state.eventSink )
) }
MediaViewerBottomBar( else -> {
modifier = Modifier.align(Alignment.BottomCenter), TopAppBar(
showDivider = state.mediaInfo.mimeType.isMimeTypeVideo(), title = {
caption = state.mediaInfo.caption, if (currentData is MediaViewerPageData.Loading) {
onHeightChange = { bottomPaddingInPixels = it }, 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) { when (val bottomSheetState = state.mediaBottomSheetState) {
MediaBottomSheetState.Hidden -> Unit MediaBottomSheetState.Hidden -> Unit
is MediaBottomSheetState.MediaDetailsBottomSheetState -> { is MediaBottomSheetState.MediaDetailsBottomSheetState -> {
@ -147,13 +227,24 @@ fun MediaViewerView(
state.eventSink(MediaViewerEvents.ViewInTimeline(it)) state.eventSink(MediaViewerEvents.ViewInTimeline(it))
}, },
onShare = { onShare = {
state.eventSink(MediaViewerEvents.Share) (currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(MediaViewerEvents.Share(currentData))
}
}, },
onDownload = { onDownload = {
state.eventSink(MediaViewerEvents.SaveOnDisk) (currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(MediaViewerEvents.SaveOnDisk(currentData))
}
}, },
onDelete = { eventId -> onDelete = { eventId ->
state.eventSink(MediaViewerEvents.ConfirmDelete(eventId)) (currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(
MediaViewerEvents.ConfirmDelete(
eventId,
currentData,
)
)
}
}, },
onDismiss = { onDismiss = {
state.eventSink(MediaViewerEvents.CloseBottomSheet) state.eventSink(MediaViewerEvents.CloseBottomSheet)
@ -176,41 +267,31 @@ fun MediaViewerView(
@Composable @Composable
private fun MediaViewerPage( private fun MediaViewerPage(
isDisplayed: Boolean,
showOverlay: Boolean, showOverlay: Boolean,
bottomPaddingInPixels: Int, bottomPaddingInPixels: Int,
state: MediaViewerState, data: MediaViewerPageData.MediaViewerData,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onRetry: () -> Unit,
onDismissError: () -> Unit,
onShowOverlayChange: (Boolean) -> Unit, onShowOverlayChange: (Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onRetry() {
state.eventSink(MediaViewerEvents.RetryLoading)
}
fun onDismissError() {
state.eventSink(MediaViewerEvents.ClearLoadingError)
}
val currentShowOverlay by rememberUpdatedState(showOverlay) val currentShowOverlay by rememberUpdatedState(showOverlay)
val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange) val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange)
val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false)
DismissFlickEffects( MediaViewerFlickToDismiss(
flickState = flickState, onDismiss = onDismiss,
onDismissing = { animationDuration ->
delay(animationDuration / 3)
onDismiss()
},
onDragging = { onDragging = {
currentOnShowOverlayChange(false) currentOnShowOverlayChange(false)
} },
) onResetting = {
currentOnShowOverlayChange(true)
FlickToDismiss( },
state = flickState, modifier = modifier,
modifier = modifier.background(backgroundColorFor(flickState))
) { ) {
val showProgress = rememberShowProgress(state.downloadedMedia) val downloadedMedia by data.downloadedMedia
val showProgress = rememberShowProgress(downloadedMedia)
Box( Box(
modifier = Modifier modifier = Modifier
@ -224,7 +305,7 @@ private fun MediaViewerPage(
val localMediaViewState = rememberLocalMediaViewState(zoomableState) val localMediaViewState = rememberLocalMediaViewState(zoomableState)
val showThumbnail = !localMediaViewState.isReady val showThumbnail = !localMediaViewState.isReady
val playableState = localMediaViewState.playableState val playableState = localMediaViewState.playableState
val showError = state.downloadedMedia is AsyncData.Failure val showError = downloadedMedia.isFailure()
LaunchedEffect(playableState) { LaunchedEffect(playableState) {
if (playableState is PlayableState.Playable) { if (playableState is PlayableState.Playable) {
@ -234,10 +315,11 @@ private fun MediaViewerPage(
LocalMediaView( LocalMediaView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
isDisplayed = isDisplayed,
bottomPaddingInPixels = bottomPaddingInPixels, bottomPaddingInPixels = bottomPaddingInPixels,
localMediaViewState = localMediaViewState, localMediaViewState = localMediaViewState,
localMedia = state.downloadedMedia.dataOrNull(), localMedia = downloadedMedia.dataOrNull(),
mediaInfo = state.mediaInfo, mediaInfo = data.mediaInfo,
onClick = { onClick = {
if (playableState is PlayableState.NotPlayable) { if (playableState is PlayableState.NotPlayable) {
currentOnShowOverlayChange(!currentShowOverlay) currentOnShowOverlayChange(!currentShowOverlay)
@ -245,15 +327,15 @@ private fun MediaViewerPage(
}, },
) )
ThumbnailView( ThumbnailView(
mediaInfo = state.mediaInfo, mediaInfo = data.mediaInfo,
thumbnailSource = state.thumbnailSource, thumbnailSource = data.thumbnailSource,
isVisible = showThumbnail, isVisible = showThumbnail,
) )
if (showError) { if (showError) {
ErrorView( ErrorView(
errorMessage = stringResource(id = CommonStrings.error_unknown), errorMessage = stringResource(id = CommonStrings.error_unknown),
onRetry = ::onRetry, onRetry = onRetry,
onDismiss = ::onDismissError onDismiss = onDismissError
) )
} }
} }
@ -269,26 +351,46 @@ private fun MediaViewerPage(
} }
@Composable @Composable
private fun DismissFlickEffects( private fun MediaViewerLoadingPage(
flickState: FlickToDismissState, onDismiss: () -> Unit,
onDismissing: suspend (Duration) -> Unit, modifier: Modifier = Modifier,
onDragging: suspend () -> Unit,
) { ) {
val currentOnDismissing by rememberUpdatedState(onDismissing) MediaViewerFlickToDismiss(
val currentOnDragging by rememberUpdatedState(onDragging) onDismiss = onDismiss,
modifier = modifier,
) {
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding(),
contentAlignment = Alignment.Center
) {
AsyncLoading()
}
}
}
when (val gestureState = flickState.gestureState) { @Composable
is FlickToDismissState.GestureState.Dismissing -> { private fun MediaViewerErrorPage(
LaunchedEffect(Unit) { throwable: Throwable,
currentOnDismissing(gestureState.animationDuration) onDismiss: () -> Unit,
} modifier: Modifier = Modifier,
) {
MediaViewerFlickToDismiss(
onDismiss = onDismiss,
modifier = modifier,
) {
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding(),
contentAlignment = Alignment.Center
) {
AsyncFailure(
throwable = throwable,
onRetry = null
)
} }
is FlickToDismissState.GestureState.Dragging -> {
LaunchedEffect(Unit) {
currentOnDragging()
}
}
else -> Unit
} }
} }
@ -316,15 +418,17 @@ private fun rememberShowProgress(downloadedMedia: AsyncData<LocalMedia>): Boolea
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun MediaViewerTopBar( private fun MediaViewerTopBar(
actionsEnabled: Boolean, data: MediaViewerPageData.MediaViewerData,
mimeType: String,
senderName: String?,
dateSent: String?,
canShowInfo: Boolean, canShowInfo: Boolean,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onInfoClick: () -> Unit, onInfoClick: () -> Unit,
eventSink: (MediaViewerEvents) -> 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( TopAppBar(
title = { title = {
if (senderName != null && dateSent != null) { if (senderName != null && dateSent != null) {
@ -350,14 +454,14 @@ private fun MediaViewerTopBar(
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent.copy(0.6f), containerColor = bgCanvasWithTransparency,
), ),
navigationIcon = { BackButton(onClick = onBackClick) }, navigationIcon = { BackButton(onClick = onBackClick) },
actions = { actions = {
IconButton( IconButton(
enabled = actionsEnabled, enabled = actionsEnabled,
onClick = { onClick = {
eventSink(MediaViewerEvents.OpenWith) eventSink(MediaViewerEvents.OpenWith(data))
}, },
) { ) {
when (mimeType) { when (mimeType) {
@ -378,7 +482,7 @@ private fun MediaViewerTopBar(
) { ) {
Icon( Icon(
imageVector = CompoundIcons.Info(), imageVector = CompoundIcons.Info(),
contentDescription = null, contentDescription = stringResource(id = CommonStrings.a11y_view_details),
) )
} }
} }
@ -396,7 +500,7 @@ private fun MediaViewerBottomBar(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color(0x99101317)) .background(bgCanvasWithTransparency)
.onSizeChanged { .onSizeChanged {
onHeightChange(it.height) 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. // Only preview in dark, dark theme is forced on the Node.
@Preview @Preview
@Composable @Composable

View file

@ -14,6 +14,8 @@
<string name="screen_media_browser_title">"Media and files"</string> <string name="screen_media_browser_title">"Media and files"</string>
<string name="screen_media_details_file_format">"File format"</string> <string name="screen_media_details_file_format">"File format"</string>
<string name="screen_media_details_filename">"File name"</string> <string name="screen_media_details_filename">"File name"</string>
<string name="screen_media_details_no_more_files_to_show">"No more files to show"</string>
<string name="screen_media_details_no_more_media_to_show">"No more media to show"</string>
<string name="screen_media_details_redact_confirmation_message">"This file will be removed from the room and members wont have access to it."</string> <string name="screen_media_details_redact_confirmation_message">"This file will be removed from the room and members wont have access to it."</string>
<string name="screen_media_details_redact_confirmation_title">"Delete file?"</string> <string name="screen_media_details_redact_confirmation_title">"Delete file?"</string>
<string name="screen_media_details_uploaded_by">"Uploaded by"</string> <string name="screen_media_details_uploaded_by">"Uploaded by"</string>

View file

@ -165,6 +165,7 @@ class DefaultEventItemFactoryTest {
dateSent = "0 Day false", dateSent = "0 Day false",
dateSentFull = "0 Full false", dateSentFull = "0 Full false",
waveform = null, waveform = null,
duration = null,
), ),
mediaSource = MediaSource(""), mediaSource = MediaSource(""),
) )
@ -214,6 +215,7 @@ class DefaultEventItemFactoryTest {
dateSent = "0 Day false", dateSent = "0 Day false",
dateSentFull = "0 Full false", dateSentFull = "0 Full false",
waveform = null, waveform = null,
duration = null,
), ),
mediaSource = MediaSource(""), mediaSource = MediaSource(""),
thumbnailSource = null, thumbnailSource = null,
@ -260,6 +262,7 @@ class DefaultEventItemFactoryTest {
dateSent = "0 Day false", dateSent = "0 Day false",
dateSentFull = "0 Full false", dateSentFull = "0 Full false",
waveform = null, waveform = null,
duration = null,
), ),
mediaSource = MediaSource(""), mediaSource = MediaSource(""),
) )
@ -310,10 +313,10 @@ class DefaultEventItemFactoryTest {
dateSent = "0 Day false", dateSent = "0 Day false",
dateSentFull = "0 Full false", dateSentFull = "0 Full false",
waveform = null, waveform = null,
duration = "2:03",
), ),
mediaSource = MediaSource(""), mediaSource = MediaSource(""),
thumbnailSource = null, thumbnailSource = null,
duration = "2:03",
) )
) )
} }
@ -361,10 +364,9 @@ class DefaultEventItemFactoryTest {
dateSent = "0 Day false", dateSent = "0 Day false",
dateSentFull = "0 Full false", dateSentFull = "0 Full false",
waveform = listOf(1f, 2f).toImmutableList(), waveform = listOf(1f, 2f).toImmutableList(),
duration = "7:36",
), ),
mediaSource = MediaSource(""), mediaSource = MediaSource(""),
duration = "7:36",
waveform = listOf(1f, 2f).toImmutableList(),
) )
) )
} }
@ -412,6 +414,7 @@ class DefaultEventItemFactoryTest {
dateSent = "0 Day false", dateSent = "0 Day false",
dateSentFull = "0 Full false", dateSentFull = "0 Full false",
waveform = null, waveform = null,
duration = null,
), ),
mediaSource = MediaSource(""), mediaSource = MediaSource(""),
thumbnailSource = null, thumbnailSource = null,

View file

@ -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<AsyncData<GroupedMediaItems>>(
replay = 1
)
override fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>> {
return groupedMediaItemsFlow
}
suspend fun emitGroupedMediaItems(groupedMediaItems: AsyncData<GroupedMediaItems>) {
groupedMediaItemsFlow.emit(groupedMediaItems)
}
override fun getLastData(): AsyncData<GroupedMediaItems> {
return groupedMediaItemsFlow.replayCache.firstOrNull() ?: AsyncData.Uninitialized
}
override suspend fun loadMore(direction: Timeline.PaginationDirection) {
loadMoreLambda(direction)
}
override suspend fun deleteItem(eventId: EventId) {
deleteItemLambda(eventId)
}
}

View file

@ -8,12 +8,12 @@
package io.element.android.libraries.mediaviewer.impl.gallery package io.element.android.libraries.mediaviewer.impl.gallery
import android.net.Uri import android.net.Uri
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat 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.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.core.EventId 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.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.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_USER_ID 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.impl.gallery.ui.aMediaItemImage
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory 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.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -47,49 +43,37 @@ class MediaGalleryPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { } val startLambda = lambdaRecorder<Unit> { }
val navigator = FakeMediaGalleryNavigator(
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
)
val presenter = createMediaGalleryPresenter( val presenter = createMediaGalleryPresenter(
navigator = navigator, mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = startLambda,
),
room = FakeMatrixRoom( room = FakeMatrixRoom(
displayName = A_ROOM_NAME, displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) }, mediaTimelineResult = { Result.success(FakeTimeline()) },
) )
) )
presenter.test { presenter.test {
skipItems(2) val initialState = awaitFirstItem()
val initialState = awaitItem()
assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images) assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images)
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
assertThat(initialState.roomName).isEqualTo(A_ROOM_NAME) assertThat(initialState.roomName).isEqualTo(A_ROOM_NAME)
assertThat(initialState.groupedMediaItems.dataOrNull()).isEqualTo( assertThat(initialState.groupedMediaItems.isUninitialized()).isTrue()
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(),
)
)
assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.snackbarMessage).isNull()
} }
startLambda.assertions().isCalledOnce()
} }
@Test @Test
fun `present - change mode`() = runTest { fun `present - change mode`() = runTest {
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
val navigator = FakeMediaGalleryNavigator(
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
)
val presenter = createMediaGalleryPresenter( val presenter = createMediaGalleryPresenter(
navigator = navigator,
room = FakeMatrixRoom( room = FakeMatrixRoom(
displayName = A_ROOM_NAME, displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) }, mediaTimelineResult = { Result.success(FakeTimeline()) },
) )
) )
presenter.test { presenter.test {
skipItems(2) val initialState = awaitFirstItem()
val initialState = awaitItem()
assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images) assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images)
initialState.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Files)) initialState.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Files))
val state = awaitItem() val state = awaitItem()
@ -110,7 +94,7 @@ class MediaGalleryPresenterTest {
`present - bottom sheet state - own message`(canDeleteOwn = false) `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( val presenter = createMediaGalleryPresenter(
room = FakeMatrixRoom( room = FakeMatrixRoom(
sessionId = A_USER_ID, sessionId = A_USER_ID,
@ -120,8 +104,7 @@ class MediaGalleryPresenterTest {
) )
) )
presenter.test { presenter.test {
skipItems(2) val initialState = awaitFirstItem()
val initialState = awaitItem()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
val item = aMediaItemImage( val item = aMediaItemImage(
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
@ -154,7 +137,7 @@ class MediaGalleryPresenterTest {
`present - bottom sheet state - other message`(canDeleteOther = false) `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( val presenter = createMediaGalleryPresenter(
room = FakeMatrixRoom( room = FakeMatrixRoom(
sessionId = A_USER_ID, sessionId = A_USER_ID,
@ -164,8 +147,7 @@ class MediaGalleryPresenterTest {
) )
) )
presenter.test { presenter.test {
skipItems(2) val initialState = awaitFirstItem()
val initialState = awaitItem()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
val item = aMediaItemImage( val item = aMediaItemImage(
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
@ -197,8 +179,7 @@ class MediaGalleryPresenterTest {
) )
) )
presenter.test { presenter.test {
skipItems(2) val initialState = awaitFirstItem()
val initialState = awaitItem()
// Delete bottom sheet // Delete bottom sheet
val item = aMediaItemImage() val item = aMediaItemImage()
initialState.eventSink(MediaGalleryEvents.ConfirmDelete(AN_EVENT_ID, item.mediaInfo, item.thumbnailSource)) 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<EventId, Unit> { }
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 @Test
fun `present - view in timeline invokes the navigator`() = runTest { fun `present - view in timeline invokes the navigator`() = runTest {
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { } val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
@ -230,15 +247,37 @@ class MediaGalleryPresenterTest {
navigator = navigator, navigator = navigator,
) )
presenter.test { presenter.test {
skipItems(2) val initialState = awaitFirstItem()
val initialState = awaitItem()
initialState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID)) initialState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID))
onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
} }
} }
private fun TestScope.createMediaGalleryPresenter( @Test
fun `present - load more`() = runTest {
val loadMoreLambda = lambdaRecorder<Timeline.PaginationDirection, Unit> { }
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 <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem()
}
private fun createMediaGalleryPresenter(
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(), matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
),
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(), localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
navigator: MediaGalleryNavigator = FakeMediaGalleryNavigator(), navigator: MediaGalleryNavigator = FakeMediaGalleryNavigator(),
@ -249,22 +288,11 @@ class MediaGalleryPresenterTest {
return MediaGalleryPresenter( return MediaGalleryPresenter(
navigator = navigator, navigator = navigator,
room = room, room = room,
timelineMediaItemsFactory = TimelineMediaItemsFactory( mediaGalleryDataSource = mediaGalleryDataSource,
dispatchers = testCoroutineDispatchers(),
virtualItemFactory = VirtualItemFactory(
dateFormatter = FakeDateFormatter(),
),
eventItemFactory = EventItemFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
dateFormatter = FakeDateFormatter(),
),
),
localMediaFactory = localMediaFactory, localMediaFactory = localMediaFactory,
mediaLoader = matrixMediaLoader, mediaLoader = matrixMediaLoader,
localMediaActions = localMediaActions, localMediaActions = localMediaActions,
snackbarDispatcher = snackbarDispatcher, snackbarDispatcher = snackbarDispatcher,
mediaItemsPostProcessor = MediaItemsPostProcessor(),
) )
} }
} }

View file

@ -8,9 +8,7 @@
package io.element.android.libraries.mediaviewer.impl.gallery package io.element.android.libraries.mediaviewer.impl.gallery
import com.google.common.truth.Truth.assertThat 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.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.aMediaItemAudio
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator 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.aMediaItemFile
@ -42,27 +40,6 @@ class MediaItemsPostProcessorTest {
private val date3 = aMediaItemDateSeparator(id = UniqueId("3")) private val date3 = aMediaItemDateSeparator(id = UniqueId("3"))
private val loading1 = aMediaItemLoadingIndicator(id = UniqueId("1")) 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<GroupedMediaItems>())
}
@Test
fun `process Failure`() {
val sut = MediaItemsPostProcessor()
val result = sut.process(AsyncData.Failure(AN_EXCEPTION))
assertThat(result).isEqualTo(AsyncData.Failure<GroupedMediaItems>(AN_EXCEPTION))
}
@Test @Test
fun `process Empty`() { fun `process Empty`() {
test( test(
@ -215,19 +192,16 @@ class MediaItemsPostProcessorTest {
expectedFileItems: List<MediaItem>, expectedFileItems: List<MediaItem>,
) { ) {
val sut = MediaItemsPostProcessor() val sut = MediaItemsPostProcessor()
val result = sut.process(AsyncData.Success(mediaItems.toImmutableList())) val result = sut.process(mediaItems.toImmutableList())
val data = result.dataOrNull()!!
// Compare the lists to have better failure info // Compare the lists to have better failure info
assertThat(data.imageAndVideoItems.toList()).isEqualTo(expectedImageAndVideoItems) assertThat(result.imageAndVideoItems.toList()).isEqualTo(expectedImageAndVideoItems)
assertThat(data.fileItems.toList()).isEqualTo(expectedFileItems) assertThat(result.fileItems.toList()).isEqualTo(expectedFileItems)
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
AsyncData.Success( GroupedMediaItems(
GroupedMediaItems( imageAndVideoItems = expectedImageAndVideoItems.toImmutableList(),
imageAndVideoItems = expectedImageAndVideoItems.toImmutableList(), fileItems = expectedFileItems.toImmutableList(),
fileItems = expectedFileItems.toImmutableList(),
)
) )
) )
} }

View file

@ -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,
)
}

View file

@ -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<Timeline.PaginationDirection, Result<Boolean>> { _ ->
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<EventOrTransactionId, String?, Result<Unit>> { _, _ ->
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<GroupedMediaItems>(AN_EXCEPTION)
)
}
}
@Test
fun `test - when timeline emits new data, the flow emits the data`() = runTest {
val timelineItems = MutableStateFlow<List<MatrixTimelineItem>>(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(),
)
}
}

View file

@ -50,6 +50,7 @@ class AndroidLocalMediaFactoryTest {
dateSent = "12:34", dateSent = "12:34",
dateSentFull = "full", dateSentFull = "full",
waveform = null, waveform = null,
duration = null,
) )
) )
} }

View file

@ -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<Unit> { }
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<Timeline.PaginationDirection, Unit> { }
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(),
)
}

View file

@ -10,14 +10,14 @@
package io.element.android.libraries.mediaviewer.impl.viewer package io.element.android.libraries.mediaviewer.impl.viewer
import android.net.Uri import android.net.Uri
import app.cash.molecule.RecompositionMode import app.cash.turbine.ReceiveTurbine
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher 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.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.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.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId 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_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.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState 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.FakeLocalMediaActions
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory 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.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -52,6 +61,7 @@ class MediaViewerPresenterTest {
private val mockMediaUri: Uri = mockk("localMediaUri") private val mockMediaUri: Uri = mockk("localMediaUri")
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
private val aUrl = "aUrl"
@Test @Test
fun `present - initial state null Event`() = runTest { fun `present - initial state null Event`() = runTest {
@ -61,9 +71,9 @@ class MediaViewerPresenterTest {
) )
) )
presenter.test { presenter.test {
skipItems(2) val initialState = awaitFirstItem()
val initialState = awaitItem() assertThat(initialState.listData).isEmpty()
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) assertThat(initialState.currentIndex).isEqualTo(0)
assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isTrue() assertThat(initialState.canShowInfo).isTrue()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@ -79,9 +89,9 @@ class MediaViewerPresenterTest {
) )
) )
presenter.test { presenter.test {
skipItems(2) val initialState = awaitFirstItem()
val initialState = awaitItem() assertThat(initialState.listData).isEmpty()
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) assertThat(initialState.currentIndex).isEqualTo(0)
assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isFalse() assertThat(initialState.canShowInfo).isFalse()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@ -97,9 +107,9 @@ class MediaViewerPresenterTest {
) )
) )
presenter.test { presenter.test {
skipItems(2) val initialState = awaitFirstItem()
val initialState = awaitItem() assertThat(initialState.listData).isEmpty()
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) assertThat(initialState.currentIndex).isEqualTo(0)
assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isTrue() assertThat(initialState.canShowInfo).isTrue()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@ -116,9 +126,9 @@ class MediaViewerPresenterTest {
) )
) )
presenter.test { presenter.test {
skipItems(2) val initialState = awaitFirstItem()
val initialState = awaitItem() assertThat(initialState.listData).isEmpty()
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) assertThat(initialState.currentIndex).isEqualTo(0)
assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isTrue() assertThat(initialState.canShowInfo).isTrue()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@ -126,114 +136,280 @@ class MediaViewerPresenterTest {
} }
@Test @Test
fun `present - download media success scenario`() = runTest { fun `present - data source update`() = runTest {
val presenter = createMediaViewerPresenter( val mediaGalleryDataSource = FakeMediaGalleryDataSource(
room = FakeMatrixRoom( startLambda = { },
canRedactOwnResult = { Result.success(true) },
)
) )
moleculeFlow(RecompositionMode.Immediate) { val presenter = createMediaViewerPresenter(
presenter.present() mediaGalleryDataSource = mediaGalleryDataSource,
}.test { )
var state = awaitItem() val anImage = aMediaItemImage()
assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized) presenter.test {
assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) val initialState = awaitFirstItem()
state = awaitItem() assertThat(initialState.listData).isEmpty()
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) mediaGalleryDataSource.emitGroupedMediaItems(
state = awaitItem() AsyncData.Success(
val successData = state.downloadedMedia.dataOrNull() GroupedMediaItems(
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) imageAndVideoItems = persistentListOf(anImage),
assertThat(successData).isNotNull() 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 @Test
fun `present - check all actions`() = runTest { fun `present - load media`() = runTest {
val mediaActions = FakeLocalMediaActions() val mediaGalleryDataSource = FakeMediaGalleryDataSource(
val snackbarDispatcher = SnackbarDispatcher() startLambda = { },
val presenter = createMediaViewerPresenter(
localMediaActions = mediaActions,
snackbarDispatcher = snackbarDispatcher,
room = FakeMatrixRoom(
canRedactOwnResult = { Result.success(true) },
)
) )
moleculeFlow(RecompositionMode.Immediate) { val presenter = createMediaViewerPresenter(
presenter.present() mediaGalleryDataSource = mediaGalleryDataSource,
}.test { )
var state = awaitItem() val anImage = aMediaItemImage(
assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized) mediaSourceUrl = aUrl,
state = awaitItem() )
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) presenter.test {
// no state changes while media is loading awaitFirstItem()
state.eventSink(MediaViewerEvents.OpenWith) mediaGalleryDataSource.emitGroupedMediaItems(
state.eventSink(MediaViewerEvents.Share) AsyncData.Success(
state.eventSink(MediaViewerEvents.SaveOnDisk) GroupedMediaItems(
state = awaitItem() imageAndVideoItems = persistentListOf(anImage),
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) fileItems = persistentListOf(),
// Should succeed without change of state )
state.eventSink(MediaViewerEvents.OpenWith) )
// Should succeed without change of state )
state.eventSink(MediaViewerEvents.Share) val updatedState = awaitItem()
state.eventSink(MediaViewerEvents.SaveOnDisk) updatedState.eventSink(
state = awaitItem() MediaViewerEvents.LoadMedia(
assertThat(state.snackbarMessage).isNotNull() aMediaViewerPageData(
snackbarDispatcher.clear() mediaSource = MediaSource(aUrl)
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()
} }
} }
@Test @Test
fun `present - download media failure then retry with success scenario`() = runTest { fun `present - open info`() = runTest {
val matrixMediaLoader = FakeMatrixMediaLoader() val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter( val presenter = createMediaViewerPresenter(
matrixMediaLoader = matrixMediaLoader, mediaGalleryDataSource = mediaGalleryDataSource,
room = FakeMatrixRoom( room = FakeMatrixRoom(
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
) )
) )
moleculeFlow(RecompositionMode.Immediate) { val anImage = aMediaItemImage(
presenter.present() mediaSourceUrl = aUrl,
}.test { )
matrixMediaLoader.shouldFail = true presenter.test {
val initialState = awaitItem() awaitFirstItem()
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized) mediaGalleryDataSource.emitGroupedMediaItems(
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) AsyncData.Success(
val loadingState = awaitItem() GroupedMediaItems(
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) imageAndVideoItems = persistentListOf(anImage),
val failureState = awaitItem() fileItems = persistentListOf(),
assertThat(failureState.downloadedMedia).isInstanceOf(AsyncData.Failure::class.java) )
matrixMediaLoader.shouldFail = false )
failureState.eventSink(MediaViewerEvents.RetryLoading) )
// There is one recomposition because of the retry mechanism val updatedState = awaitItem()
skipItems(1) updatedState.eventSink(
val retryLoadingState = awaitItem() MediaViewerEvents.OpenInfo(
assertThat(retryLoadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) aMediaViewerPageData(
val successState = awaitItem() mediaSource = MediaSource(aUrl)
val successData = successState.downloadedMedia.dataOrNull() )
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) )
assertThat(successData).isNotNull() )
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 @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<EventOrTransactionId, String?, Result<Unit>> { _, _ -> val redactEventLambda = lambdaRecorder<EventOrTransactionId, String?, Result<Unit>> { _, _ ->
Result.success(Unit) Result.success(Unit)
} }
@ -241,26 +417,51 @@ class MediaViewerPresenterTest {
this.redactEventLambda = redactEventLambda this.redactEventLambda = redactEventLambda
} }
val onItemDeletedLambda = lambdaRecorder<Unit> { } val onItemDeletedLambda = lambdaRecorder<Unit> { }
val navigator = FakeMediaViewerNavigator( val mediaGalleryDataSource = FakeMediaGalleryDataSource(
onItemDeletedLambda = onItemDeletedLambda, startLambda = { },
) )
val presenter = createMediaViewerPresenter( val presenter = createMediaViewerPresenter(
room = FakeMatrixRoom( room = FakeMatrixRoom(
liveTimeline = timeline, liveTimeline = timeline,
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
), ),
mediaViewerNavigator = navigator, mediaGalleryDataSource = mediaGalleryDataSource,
mediaViewerNavigator = FakeMediaViewerNavigator(
onItemDeletedLambda = onItemDeletedLambda
)
)
val anImage = aMediaItemImage(
eventId = AN_EVENT_ID,
mediaSourceUrl = aUrl,
) )
presenter.test { presenter.test {
val initialState = awaitItem() awaitFirstItem()
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized) mediaGalleryDataSource.emitGroupedMediaItems(
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) AsyncData.Success(
val loadingState = awaitItem() GroupedMediaItems(
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) imageAndVideoItems = persistentListOf(anImage),
val successState = awaitItem() fileItems = persistentListOf(),
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) )
successState.eventSink(MediaViewerEvents.Delete(AN_EVENT_ID)) )
)
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() redactEventLambda.assertions()
.isCalledOnce() .isCalledOnce()
.with( .with(
@ -272,7 +473,71 @@ class MediaViewerPresenterTest {
} }
@Test @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<Timeline.PaginationDirection, Unit> { }
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<EventId, Unit> { } val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
val navigator = FakeMediaViewerNavigator( val navigator = FakeMediaViewerNavigator(
onViewInTimelineClickLambda = onViewInTimelineClickLambda, onViewInTimelineClickLambda = onViewInTimelineClickLambda,
@ -285,22 +550,28 @@ class MediaViewerPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized) initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData()))
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) val withBottomSheetState = awaitItem()
val loadingState = awaitItem() assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) initialState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID))
val successState = awaitItem() val finalState = awaitItem()
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
successState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID))
onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
} }
} }
private fun createMediaViewerPresenter( private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem()
}
private fun TestScope.createMediaViewerPresenter(
eventId: EventId? = null, eventId: EventId? = null,
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(), matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(), localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
),
canShowInfo: Boolean = true, canShowInfo: Boolean = true,
mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(), mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(),
room: MatrixRoom = FakeMatrixRoom( room: MatrixRoom = FakeMatrixRoom(
@ -309,18 +580,25 @@ class MediaViewerPresenterTest {
): MediaViewerPresenter { ): MediaViewerPresenter {
return MediaViewerPresenter( return MediaViewerPresenter(
inputs = MediaViewerEntryPoint.Params( inputs = MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = eventId, eventId = eventId,
mediaInfo = TESTED_MEDIA_INFO, mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(), mediaSource = aMediaSource(),
thumbnailSource = null, thumbnailSource = null,
canShowInfo = canShowInfo, canShowInfo = canShowInfo,
), ),
localMediaFactory = localMediaFactory, navigator = mediaViewerNavigator,
mediaLoader = matrixMediaLoader, dataSource = MediaViewerDataSource(
galleryMode = MediaGalleryMode.Images,
dispatcher = testCoroutineDispatchers().computation,
galleryDataSource = mediaGalleryDataSource,
mediaLoader = matrixMediaLoader,
localMediaFactory = localMediaFactory,
systemClock = FakeSystemClock(),
),
room = room,
localMediaActions = localMediaActions, localMediaActions = localMediaActions,
snackbarDispatcher = snackbarDispatcher, snackbarDispatcher = snackbarDispatcher,
navigator = mediaViewerNavigator,
room = room,
) )
} }
} }

View file

@ -18,15 +18,15 @@ import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown import androidx.compose.ui.test.swipeDown
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncData 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.impl.details.aMediaDetailsBottomSheetState
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import io.mockk.mockk
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -36,78 +36,127 @@ import org.junit.runner.RunWith
class MediaViewerViewTest { class MediaViewerViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>() @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val mockMediaUrl: Uri = mockk("localMediaUri")
@Test @Test
fun `clicking on back invokes expected callback`() { fun `clicking on back invokes expected callback`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val state = aMediaViewerState(
eventSink = eventsRecorder
)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setMediaViewerView( rule.setMediaViewerView(
aMediaViewerState( state = state,
eventSink = eventsRecorder
),
onBackClick = callback, onBackClick = callback,
) )
rule.pressBack() rule.pressBack()
} }
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
)
)
} }
@Test @Test
fun `clicking on open emit expected Event`() { 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<MediaViewerEvents>() val eventsRecorder = EventsRecorder<MediaViewerEvents>()
rule.setMediaViewerView( rule.setMediaViewerView(
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listData = listOf(data),
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
mediaInfo = anImageMediaInfo(),
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
val contentDescription = rule.activity.getString(contentDescriptionRes) val contentDescription = rule.activity.getString(contentDescriptionRes)
rule.onNodeWithContentDescription(contentDescription).performClick() rule.onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertSingle(expectedEvent) eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
expectedEvent,
)
)
} }
@Test @Test
fun `clicking on save emit expected Event`() { 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 @Test
fun `clicking on share emit expected Event`() { 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<MediaViewerEvents>() val eventsRecorder = EventsRecorder<MediaViewerEvents>()
rule.setMediaViewerView( rule.setMediaViewerView(
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listData = listOf(data),
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
mediaInfo = anImageMediaInfo(),
mediaBottomSheetState = aMediaDetailsBottomSheetState(), mediaBottomSheetState = aMediaDetailsBottomSheetState(),
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(contentDescriptionRes) rule.clickOn(contentDescriptionRes)
eventsRecorder.assertSingle(expectedEvent) eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
expectedEvent,
)
)
} }
@Test @Test
fun `clicking on image hides the overlay`() { fun `clicking on image hides the overlay`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val state = aMediaViewerState(
eventSink = eventsRecorder
)
rule.setMediaViewerView( rule.setMediaViewerView(
aMediaViewerState( state = state,
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
mediaInfo = anImageMediaInfo(),
eventSink = eventsRecorder
),
) )
// Ensure that the action are visible // Ensure that the action are visible
val contentDescription = rule.activity.getString(CommonStrings.action_open_with) val contentDescription = rule.activity.getString(CommonStrings.action_open_with)
@ -120,54 +169,79 @@ class MediaViewerViewTest {
rule.mainClock.advanceTimeBy(1_000) rule.mainClock.advanceTimeBy(1_000)
rule.onNodeWithContentDescription(contentDescription) rule.onNodeWithContentDescription(contentDescription)
.assertDoesNotExist() .assertDoesNotExist()
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
)
)
} }
@Test @Test
fun `clicking swipe on the image invokes the expected callback`() { fun `clicking swipe on the image invokes the expected callback`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val state = aMediaViewerState(
eventSink = eventsRecorder
)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setMediaViewerView( rule.setMediaViewerView(
aMediaViewerState( state = state,
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
mediaInfo = anImageMediaInfo(),
eventSink = eventsRecorder
),
onBackClick = callback, onBackClick = callback,
) )
val imageContentDescription = rule.activity.getString(CommonStrings.common_image) val imageContentDescription = rule.activity.getString(CommonStrings.common_image)
rule.onNodeWithContentDescription(imageContentDescription).performTouchInput { swipeDown(startY = centerY) } rule.onNodeWithContentDescription(imageContentDescription).performTouchInput { swipeDown(startY = centerY) }
rule.mainClock.advanceTimeBy(1_000) rule.mainClock.advanceTimeBy(1_000)
} }
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
)
)
} }
@Test @Test
fun `error case, click on retry emits the expected Event`() { fun `error case, click on retry emits the expected Event`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>() val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
)
rule.setMediaViewerView( rule.setMediaViewerView(
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Failure(IllegalStateException("error")), listData = listOf(data),
mediaInfo = anImageMediaInfo(),
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(CommonStrings.action_retry) rule.clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(MediaViewerEvents.RetryLoading) eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
MediaViewerEvents.LoadMedia(data),
)
)
} }
@Test @Test
fun `error case, click on cancel emits the expected Event`() { fun `error case, click on cancel emits the expected Event`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>() val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
)
rule.setMediaViewerView( rule.setMediaViewerView(
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Failure(IllegalStateException("error")), listData = listOf(data),
mediaInfo = anImageMediaInfo(),
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(CommonStrings.action_cancel) rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(MediaViewerEvents.ClearLoadingError) eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
MediaViewerEvents.ClearLoadingError(data)
)
)
} }
} }

View file

@ -43,6 +43,7 @@ class FakeLocalMediaFactory(
dateSent = null, dateSent = null,
dateSentFull = null, dateSentFull = null,
waveform = null, waveform = null,
duration = null,
) )
return aLocalMedia(uri, mediaInfo) return aLocalMedia(uri, mediaInfo)
} }

View file

@ -29,6 +29,7 @@
<string name="a11y_show_password">"Show password"</string> <string name="a11y_show_password">"Show password"</string>
<string name="a11y_start_call">"Start a call"</string> <string name="a11y_start_call">"Start a call"</string>
<string name="a11y_user_menu">"User menu"</string> <string name="a11y_user_menu">"User menu"</string>
<string name="a11y_view_details">"View details"</string>
<string name="a11y_voice_message_record">"Record voice message."</string> <string name="a11y_voice_message_record">"Record voice message."</string>
<string name="a11y_voice_message_stop_recording">"Stop recording"</string> <string name="a11y_voice_message_stop_recording">"Stop recording"</string>
<string name="action_accept">"Accept"</string> <string name="action_accept">"Accept"</string>
@ -181,6 +182,7 @@ Reason: %1$s."</string>
<string name="common_light">"Light"</string> <string name="common_light">"Light"</string>
<string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string> <string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string>
<string name="common_loading">"Loading…"</string> <string name="common_loading">"Loading…"</string>
<string name="common_loading_more">"Loading more…"</string>
<plurals name="common_member_count"> <plurals name="common_member_count">
<item quantity="one">"%1$d member"</item> <item quantity="one">"%1$d member"</item>
<item quantity="other">"%1$d members"</item> <item quantity="other">"%1$d members"</item>

View file

@ -11,8 +11,8 @@ import io.element.android.services.toolbox.api.systemclock.SystemClock
const val A_FAKE_TIMESTAMP = 123L const val A_FAKE_TIMESTAMP = 123L
class FakeSystemClock : SystemClock { class FakeSystemClock(
override fun epochMillis(): Long { var epochMillisResult: Long = A_FAKE_TIMESTAMP
return A_FAKE_TIMESTAMP ) : SystemClock {
} override fun epochMillis() = epochMillisResult
} }

View file

@ -122,6 +122,7 @@ class KonsistClassNameTest {
.withoutName( .withoutName(
"Factory", "Factory",
"TimelineController", "TimelineController",
"TimelineMediaGalleryDataSource",
) )
.withoutNameStartingWith( .withoutNameStartingWith(
"Accompanist", "Accompanist",

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7c7d4201ed9aa37995f4ab8ac982404f59e77374f316a057685886f14e698c35 oid sha256:8d8842663702441ce586c7e2141c0cdf47032a26b6015e592abb5682e3cd2c60
size 24680 size 25152

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b94fd31b7ed71eacfe8f136bfd59405b85d31a0fe557800311794f4ba7006271 oid sha256:e57fc21cd01917630a08324f49a3d76d82823ee4a2f90f23624c120126426689
size 22749 size 23190

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:333a21a41a7d5f8d47946648a7381dadeccf213a1176696b3512c08ae929d4d6 oid sha256:ddb495eaf8113f0be1ba572697083bd5b8ccd4c308780478b6c4691dd0f8d922
size 7819 size 8019

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cba8f49caf65856569266a561206437840a43da2d41ed5f08321ecda99204329 oid sha256:22e4744300d62e550a9c545420621fe6aa8db1674a876731be31a12fbc426cbc
size 8236 size 7320

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a6d04e0ee068682ebb0a3842ba73407855f2b83b7389d26fa0f3e2ec20d42dc8 oid sha256:5076cbf15e1d0ec2c70432c88a5f48eb074490bdf8431cfb51109f91ad4e9576
size 7389 size 7495

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:267d4be8b727a0ecb5af1e5f1e69adfd68f50f32f1de78f4f9fde60f635244b5 oid sha256:b3599a3a6fb43f98d928ce71c3ef7a8b8102d558c31ea1f735a948e337738f95
size 13045 size 13533

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:66437179fb0b851d4d4d647d00cab94cc7422d625f559839c675b378dbf1af38 oid sha256:1e4ef6ed6fe4c858ac4c67bf2bc5d428f4d4734cf8b01244a3bc963a815a540a
size 389408 size 389328

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2b286342ff4d46637beac1f980294f77b3e2eb6824d56448cdbdce7b41c911ab oid sha256:67ca752577251e9e57e7680ae1bbdef3324c84ec6a1037aa9f7c228bb8206f4c
size 388612 size 388634

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8165bcb4b0d52a227aad4e1f3951fc3628ef647947ef2584ed50d8ede8a6a344 oid sha256:96941aab9596583187e4a089bd448252be551e3ef6b2fb7550c3bad5c7ba60fb
size 38248 size 37905

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c9efb4ed1ee82bba30351bf213f0873637e8194140a3bbea669321bf76bc6483 oid sha256:add442c1cabc79cde42e65775b22413c92918bba93d96bb72a9ff214b9ae7fac
size 31449 size 31126

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5d2882d79b9f66726c6d16c8c6fc84cb9f65a5674222fe85794229dc5ba12a6b oid sha256:0c288f75fcccb93e074afd8178219887ce8a541d8e444df18ca041647114d340
size 24679 size 24491

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9a60bd02d969c7c6ccc0eb2e8f4f2c5551b8d910e5bb2f96710589040541691d
size 7973

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:049993637421009db857dbd8a647d268241cd0f88a12a376804444647f94e885
size 5069

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:da172fdf40dc8702bc6dcb89bcc75e93bd279f6bbb9454f5283febe4da25d399 oid sha256:248ad0bbfc8c8a56975c2fc6ddfa5275ff4f3ad39b9a76126c8d4bdd0c566e88
size 389440 size 389354

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ee478f10b781385a5bd472a9fb1047e869ca2e46e1bab28758315722ff911bbc oid sha256:ecdd1219635a61be5473773464d3796ea6a8f17ac2c384e131265e9557dadf42
size 94992 size 95129

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7ad6e45382dec9bb27593b6e2ed92ed633204479040ccebaf4d362bb2f41fec7 oid sha256:c89d437ccf40ac25227d19d1b775ae0992cad7970784e29586438a60f6bf950b
size 396403 size 396206

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d10cb9be5b5139f0fdfdfb11cc3d3eca1955297180e5db8142bfea6250f20d73 oid sha256:9ac33be436135d5022024108ec403ea3e53995d8e42dcc52bbd3d8041e5e2975
size 25811 size 25053

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:69b5d7572ae6e4ff084867fac1bae41a55c75c9a5236cb6ccb4c31b89ef77898 oid sha256:4d0288375c9e746d4cbb9b270c1c7f5e97633d76027186bd9568555edeaf8700
size 5442 size 5411

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f8d3e3f8733424870b254be90599ed1ff6ba784089600bcb200fbef62c81537c oid sha256:c756b50710b1ed10eea01d8f97d8cca79a3b3440c559e1f593c790b55e5f6556
size 14562 size 14194

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d1785d90957316791969f047e66bd779da62d675004914099f2af2b69bebe405 oid sha256:2c5a6e30127fe88d1d55da6daebff64981e88bbf7a37c712c99f831849e56172
size 14700 size 14374

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1ddcd8e9e20de4171a3d9f8175806a268723e47a14dca431849c2c29edaf5d0b oid sha256:1ddd7d087dac24b5f2861b59c946eb89046b9bcb1a709dca423b8893f55a81f9
size 26267 size 26217

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:035ef0079af6e9825a52b86e2eab50667404a66d70dd2756596b60cc1cea376a oid sha256:8a0e84ee0e17fa30222ffb5fe58f353ca847b251a8da7088a58faa467f1f6742
size 26404 size 26258