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
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.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.duration
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.BackstackWithOverlayBox
@@ -58,6 +59,7 @@ import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
@@ -246,6 +248,8 @@ class MessagesFlowNode @AssistedInject constructor(
}
is NavTarget.MediaViewer -> {
val params = MediaViewerEntryPoint.Params(
// TODO When we will be able to load a media timeline from a EventId, change mode here (and use a mixed mode?)
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
@@ -447,6 +451,7 @@ class MessagesFlowNode @AssistedInject constructor(
mode = DateFormatterMode.Full,
),
waveform = (content as? TimelineItemVoiceContent)?.waveform,
duration = content.duration()?.toHumanReadableDuration(),
),
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,

View File

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

View File

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

View File

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

View File

@@ -8,10 +8,12 @@
package io.element.android.libraries.matrix.impl.fixtures.fakes
import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.PaginationStatusListener
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.Timeline
import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineListener
import uniffi.matrix_sdk_ui.LiveBackPaginationStatus
class FakeRustTimeline : Timeline(NoPointer) {
private var listener: TimelineListener? = null
@@ -23,4 +25,16 @@ class FakeRustTimeline : Timeline(NoPointer) {
fun emitDiff(diff: List<TimelineDiff>) {
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 dateSentFull: String?,
val waveform: List<Float>?,
val duration: String?,
) : Parcelable
fun anImageMediaInfo(
@@ -45,6 +46,7 @@ fun anImageMediaInfo(
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
duration = null,
)
fun aVideoMediaInfo(
@@ -52,6 +54,7 @@ fun aVideoMediaInfo(
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
duration: String? = null,
): MediaInfo = MediaInfo(
filename = "a video file.mp4",
caption = caption,
@@ -64,6 +67,7 @@ fun aVideoMediaInfo(
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
duration = duration,
)
fun aPdfMediaInfo(
@@ -84,6 +88,7 @@ fun aPdfMediaInfo(
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
duration = null,
)
fun anApkMediaInfo(
@@ -103,6 +108,7 @@ fun anApkMediaInfo(
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
duration = null,
)
fun anAudioMediaInfo(
@@ -112,6 +118,7 @@ fun anAudioMediaInfo(
dateSent: String? = null,
dateSentFull: String? = null,
waveForm: List<Float>? = null,
duration: String? = null,
): MediaInfo = MediaInfo(
filename = filename,
caption = caption,
@@ -124,6 +131,7 @@ fun anAudioMediaInfo(
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = waveForm,
duration = duration,
)
fun aVoiceMediaInfo(
@@ -133,6 +141,7 @@ fun aVoiceMediaInfo(
dateSent: String? = null,
dateSentFull: String? = null,
waveForm: List<Float>? = null,
duration: String? = null,
): MediaInfo = MediaInfo(
filename = filename,
caption = caption,
@@ -145,4 +154,5 @@ fun aVoiceMediaInfo(
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = waveForm,
duration = duration,
)

View File

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

View File

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

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

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 androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@@ -33,30 +30,21 @@ import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class MediaGalleryPresenter @AssistedInject constructor(
@Assisted private val navigator: MediaGalleryNavigator,
private val room: MatrixRoom,
private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
private val mediaGalleryDataSource: MediaGalleryDataSource,
private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader,
private val localMediaActions: LocalMediaActions,
private val snackbarDispatcher: SnackbarDispatcher,
private val mediaItemsPostProcessor: MediaItemsPostProcessor,
) : Presenter<MediaGalleryState> {
@AssistedFactory
interface Factory {
@@ -74,56 +62,36 @@ class MediaGalleryPresenter @AssistedInject constructor(
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
var mediaItems by remember {
mutableStateOf<AsyncData<ImmutableList<MediaItem>>>(AsyncData.Uninitialized)
}
val groupedMediaItems by remember {
derivedStateOf {
mediaItemsPostProcessor.process(
mediaItems = mediaItems,
)
}
mediaGalleryDataSource.groupedMediaItemsFlow()
}
.collectAsState(AsyncData.Uninitialized)
LaunchedEffect(Unit) {
mediaGalleryDataSource.start()
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure()
var timeline by remember { mutableStateOf<AsyncData<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) {
when (event) {
is MediaGalleryEvents.ChangeMode -> {
mode = event.mode
}
is MediaGalleryEvents.LoadMore -> coroutineScope.launch {
timeline.dataOrNull()?.paginate(event.direction)
mediaGalleryDataSource.loadMore(event.direction)
}
is MediaGalleryEvents.Delete -> coroutineScope.launch {
mediaGalleryDataSource.deleteItem(event.eventId)
}
is MediaGalleryEvents.Delete -> coroutineScope.delete(timeline, event.eventId)
is MediaGalleryEvents.SaveOnDisk -> coroutineScope.launch {
mediaItems.dataOrNull().find(event.eventId)?.let {
groupedMediaItems.dataOrNull().find(event.eventId)?.let {
saveOnDisk(it)
}
}
is MediaGalleryEvents.Share -> coroutineScope.launch {
mediaItems.dataOrNull().find(event.eventId)?.let {
groupedMediaItems.dataOrNull().find(event.eventId)?.let {
share(it)
}
}
@@ -172,49 +140,6 @@ class MediaGalleryPresenter @AssistedInject constructor(
)
}
@Composable
private fun MediaListEffect(
timeline: AsyncData<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> {
return mediaLoader.downloadMediaFile(
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) {
return null
}
return filterIsInstance<MediaItem.Event>()
return (imageAndVideoItems + fileItems).filterIsInstance<MediaItem.Event>()
.firstOrNull { it.eventId() == eventId }
}

View File

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

View File

@@ -108,15 +108,15 @@ fun MediaGalleryView(
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.fillMaxSize(),
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
MediaGalleryMode.entries.forEach { mode ->
SegmentedButton(
@@ -137,7 +137,6 @@ fun MediaGalleryView(
HorizontalPager(
state = pagerState,
userScrollEnabled = false,
modifier = Modifier,
) { page ->
val mode = MediaGalleryMode.entries[page]
MediaGalleryPage(
@@ -198,6 +197,13 @@ private fun MediaGalleryPage(
) {
val groupedMediaItems = state.groupedMediaItems
if (groupedMediaItems.isLoadingItems(mode)) {
// Need to trigger a pagination now if there is only one LoadingIndicator.
val loadingItem = groupedMediaItems.dataOrNull()?.getItems(mode)?.singleOrNull() as? MediaItem.LoadingIndicator
if (loadingItem != null) {
LaunchedEffect(loadingItem.timestamp) {
state.eventSink(MediaGalleryEvents.LoadMore(loadingItem.direction))
}
}
LoadingContent(mode)
} else {
when (groupedMediaItems) {
@@ -348,8 +354,8 @@ private fun MediaGalleryImageGrid(
) {
LazyVerticalGrid(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
.fillMaxSize()
.padding(horizontal = 16.dp),
columns = GridCells.Adaptive(80.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
@@ -420,9 +426,9 @@ private fun LoadingMoreIndicator(
Timeline.PaginationDirection.FORWARDS -> {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp)
.height(1.dp)
.fillMaxWidth()
.padding(top = 2.dp)
.height(1.dp)
)
}
Timeline.PaginationDirection.BACKWARDS -> {
@@ -460,9 +466,9 @@ private fun EmptyContent(
OnboardingBackground()
PageTitle(
modifier = Modifier
.fillMaxWidth()
.padding(top = 44.dp)
.padding(24.dp),
.fillMaxWidth()
.padding(top = 44.dp)
.padding(24.dp),
title = stringResource(titleRes),
iconStyle = BigIcon.Style.Default(icon),
subtitle = stringResource(subtitleRes),
@@ -480,9 +486,9 @@ private fun LoadingContent(
OnboardingBackground()
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = 48.dp)
.padding(24.dp),
.fillMaxSize()
.padding(top = 48.dp)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {

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

View File

@@ -7,32 +7,19 @@
package io.element.android.libraries.mediaviewer.impl.gallery
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
class MediaItemsPostProcessor @Inject constructor() {
fun process(
mediaItems: AsyncData<ImmutableList<MediaItem>>,
): AsyncData<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 {
mediaItems: List<MediaItem>,
): GroupedMediaItems {
val imageAndVideoItems = mutableListOf<MediaItem>()
val fileItems = mutableListOf<MediaItem>()
val imageAndVideoItemsSubList = mutableListOf<MediaItem.Event>()
val fileItemsSublist = mutableListOf<MediaItem.Event>()
forEach { item ->
mediaItems.forEach { item ->
when (item) {
is MediaItem.DateSeparator -> {
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
data class MediaViewer(
val mode: MediaViewerEntryPoint.MediaViewerMode,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
@@ -92,8 +93,16 @@ class MediaGalleryRootNode @AssistedInject constructor(
}
override fun onItemClick(item: MediaItem.Event) {
val mode = when (item) {
is MediaItem.Audio,
is MediaItem.Voice,
is MediaItem.File -> MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios
is MediaItem.Image,
is MediaItem.Video -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos
}
overlay.show(
NavTarget.MediaViewer(
mode = mode,
eventId = item.eventId(),
mediaInfo = item.mediaInfo(),
mediaSource = item.mediaSource(),
@@ -117,6 +126,7 @@ class MediaGalleryRootNode @AssistedInject constructor(
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.params(
MediaViewerEntryPoint.Params(
mode = navTarget.mode,
eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -115,7 +115,7 @@ private fun VoiceInfoRow(
}
Spacer(Modifier.width(8.dp))
Text(
text = if (state.progress > 0f) state.time else voice.duration ?: state.time,
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdMedium,
maxLines = 1,
@@ -128,7 +128,7 @@ private fun VoiceInfoRow(
.height(34.dp),
showCursor = state.showCursor,
playbackProgress = state.progress,
waveform = voice.waveform.toPersistentList(),
waveform = voice.mediaInfo.waveform.orEmpty().toPersistentList(),
onSeek = {
state.eventSink(VoiceMessageEvents.Seek(it))
},

View File

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

View File

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

View File

@@ -83,9 +83,11 @@ fun MediaAudioView(
localMedia: LocalMedia?,
info: MediaInfo?,
modifier: Modifier = Modifier,
isDisplayed: Boolean = true,
) {
val exoPlayer = rememberExoPlayer()
ExoPlayerMediaAudioView(
isDisplayed = isDisplayed,
localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels,
exoPlayer = exoPlayer,
@@ -98,6 +100,7 @@ fun MediaAudioView(
@SuppressLint("UnsafeOptInUsageError")
@Composable
private fun ExoPlayerMediaAudioView(
isDisplayed: Boolean,
localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int,
exoPlayer: ExoPlayer,
@@ -176,6 +179,12 @@ private fun ExoPlayerMediaAudioView(
)
}
}
LaunchedEffect(isDisplayed) {
// If not displayed, make sure to pause the audio
if (!isDisplayed) {
exoPlayer.pause()
}
}
if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)

View File

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

View File

@@ -57,6 +57,7 @@ import kotlin.time.Duration.Companion.seconds
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun MediaVideoView(
isDisplayed: Boolean,
localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int,
localMedia: LocalMedia?,
@@ -64,6 +65,7 @@ fun MediaVideoView(
) {
val exoPlayer = rememberExoPlayer()
ExoPlayerMediaVideoView(
isDisplayed = isDisplayed,
localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels,
exoPlayer = exoPlayer,
@@ -75,6 +77,7 @@ fun MediaVideoView(
@SuppressLint("UnsafeOptInUsageError")
@Composable
private fun ExoPlayerMediaVideoView(
isDisplayed: Boolean,
localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int,
exoPlayer: ExoPlayer,
@@ -161,6 +164,12 @@ private fun ExoPlayerMediaVideoView(
)
}
}
LaunchedEffect(isDisplayed) {
// If not displayed, make sure to pause the video
if (!isDisplayed) {
exoPlayer.pause()
}
}
if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)
@@ -245,6 +254,7 @@ private fun ExoPlayerMediaVideoView(
@Composable
internal fun MediaVideoViewPreview() = ElementPreview {
MediaVideoView(
isDisplayed = true,
modifier = Modifier.fillMaxSize(),
bottomPaddingInPixels = 0,
localMediaViewState = rememberLocalMediaViewState(),

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
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
sealed interface MediaViewerEvents {
data object SaveOnDisk : MediaViewerEvents
data object Share : MediaViewerEvents
data object OpenWith : MediaViewerEvents
data object RetryLoading : MediaViewerEvents
data object ClearLoadingError : MediaViewerEvents
data class LoadMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class SaveOnDisk(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class Share(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ClearLoadingError(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents
data object OpenInfo : MediaViewerEvents
data class ConfirmDelete(val eventId: EventId) : MediaViewerEvents
data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ConfirmDelete(
val eventId: EventId,
val data: MediaViewerPageData.MediaViewerData,
) : MediaViewerEvents
data object CloseBottomSheet : MediaViewerEvents
data class Delete(val eventId: EventId) : MediaViewerEvents
data class OnNavigateTo(val index: Int) : MediaViewerEvents
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvents
}

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.compound.theme.ForcedDarkElementTheme
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode
import io.element.android.libraries.mediaviewer.impl.gallery.SingleMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.gallery.TimelineMediaGalleryDataSource
import io.element.android.services.toolbox.api.systemclock.SystemClock
@ContributesNode(RoomScope::class)
class MediaViewerNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: MediaViewerPresenter.Factory,
timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource,
mediaLoader: MatrixMediaLoader,
localMediaFactory: LocalMediaFactory,
coroutineDispatchers: CoroutineDispatchers,
systemClock: SystemClock,
) : Node(buildContext, plugins = plugins),
MediaViewerNavigator {
private val inputs = inputs<MediaViewerEntryPoint.Params>()
@@ -47,9 +59,29 @@ class MediaViewerNode @AssistedInject constructor(
onDone()
}
private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) {
SingleMediaGalleryDataSource.createFrom(inputs)
} else {
timelineMediaGalleryDataSource
}
private val galleryMode = when (inputs.mode) {
MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
}
private val presenter = presenterFactory.create(
inputs = inputs,
navigator = this,
dataSource = MediaViewerDataSource(
dispatcher = coroutineDispatchers.computation,
galleryMode = galleryMode,
galleryDataSource = mediaGallerySource,
mediaLoader = mediaLoader,
localMediaFactory = localMediaFactory,
systemClock = systemClock,
)
)
@Composable

View File

@@ -10,7 +10,6 @@ package io.element.android.libraries.mediaviewer.impl.viewer
import android.content.ActivityNotFoundException
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -26,15 +25,12 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
import io.element.android.libraries.ui.strings.CommonStrings
@@ -45,9 +41,8 @@ import io.element.android.libraries.androidutils.R as UtilsR
class MediaViewerPresenter @AssistedInject constructor(
@Assisted private val inputs: MediaViewerEntryPoint.Params,
@Assisted private val navigator: MediaViewerNavigator,
@Assisted private val dataSource: MediaViewerDataSource,
private val room: MatrixRoom,
private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader,
private val localMediaActions: LocalMediaActions,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MediaViewerState> {
@@ -56,83 +51,89 @@ class MediaViewerPresenter @AssistedInject constructor(
fun create(
inputs: MediaViewerEntryPoint.Params,
navigator: MediaViewerNavigator,
dataSource: MediaViewerDataSource,
): MediaViewerPresenter
}
@Composable
override fun present(): MediaViewerState {
val coroutineScope = rememberCoroutineScope()
var loadMediaTrigger by remember { mutableIntStateOf(0) }
val mediaFile: MutableState<MediaFile?> = remember {
mutableStateOf(null)
}
val localMedia: MutableState<AsyncData<LocalMedia>> = remember {
mutableStateOf(AsyncData.Uninitialized)
}
val data by dataSource.collectAsState()
var currentIndex by remember { mutableIntStateOf(searchIndex(data, inputs.eventId)) }
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure()
DisposableEffect(loadMediaTrigger) {
coroutineScope.downloadMedia(mediaFile, localMedia)
onDispose {
mediaFile.value?.close()
}
}
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
fun handleEvents(mediaViewerEvents: MediaViewerEvents) {
when (mediaViewerEvents) {
MediaViewerEvents.RetryLoading -> loadMediaTrigger++
MediaViewerEvents.ClearLoadingError -> localMedia.value = AsyncData.Uninitialized
MediaViewerEvents.SaveOnDisk -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.saveOnDisk(localMedia.value)
DisposableEffect(Unit) {
dataSource.setup()
onDispose {
dataSource.dispose()
}
}
localMediaActions.Configure()
fun handleEvents(event: MediaViewerEvents) {
when (event) {
is MediaViewerEvents.LoadMedia -> {
coroutineScope.downloadMedia(data = event.data)
}
MediaViewerEvents.Share -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.share(localMedia.value)
is MediaViewerEvents.ClearLoadingError -> {
dataSource.clearLoadingError(event.data)
}
MediaViewerEvents.OpenWith -> {
is MediaViewerEvents.SaveOnDisk -> {
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 -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.delete(mediaViewerEvents.eventId)
coroutineScope.delete(event.eventId)
}
is MediaViewerEvents.ViewInTimeline -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onViewInTimelineClick(mediaViewerEvents.eventId)
navigator.onViewInTimelineClick(event.eventId)
}
MediaViewerEvents.OpenInfo -> coroutineScope.launch {
is MediaViewerEvents.OpenInfo -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState(
eventId = inputs.eventId,
canDelete = when (inputs.mediaInfo.senderId) {
eventId = event.data.eventId,
canDelete = when (event.data.mediaInfo.senderId) {
null -> false
room.sessionId -> room.canRedactOwn().getOrElse { false } && inputs.eventId != null
else -> room.canRedactOther().getOrElse { false } && inputs.eventId != null
room.sessionId -> room.canRedactOwn().getOrElse { false } && event.data.eventId != null
else -> room.canRedactOther().getOrElse { false } && event.data.eventId != null
},
mediaInfo = inputs.mediaInfo,
thumbnailSource = inputs.thumbnailSource,
mediaInfo = event.data.mediaInfo,
thumbnailSource = event.data.thumbnailSource,
)
}
is MediaViewerEvents.ConfirmDelete -> {
mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState(
eventId = mediaViewerEvents.eventId,
mediaInfo = inputs.mediaInfo,
thumbnailSource = inputs.thumbnailSource ?: inputs.mediaSource,
eventId = event.eventId,
mediaInfo = event.data.mediaInfo,
thumbnailSource = event.data.thumbnailSource ?: event.data.mediaSource,
)
}
MediaViewerEvents.CloseBottomSheet -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
}
is MediaViewerEvents.OnNavigateTo -> {
currentIndex = event.index
}
is MediaViewerEvents.LoadMore -> coroutineScope.launch {
dataSource.loadMore(event.direction)
}
}
}
return MediaViewerState(
eventId = inputs.eventId,
mediaInfo = inputs.mediaInfo,
thumbnailSource = inputs.thumbnailSource,
downloadedMedia = localMedia.value,
listData = data,
currentIndex = currentIndex,
snackbarMessage = snackbarMessage,
canShowInfo = inputs.canShowInfo,
mediaBottomSheetState = mediaBottomSheetState,
@@ -140,28 +141,10 @@ class MediaViewerPresenter @AssistedInject constructor(
)
}
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<AsyncData<LocalMedia>>) = launch {
localMedia.value = AsyncData.Loading()
mediaLoader.downloadMediaFile(
source = inputs.mediaSource,
mimeType = inputs.mediaInfo.mimeType,
filename = inputs.mediaInfo.filename
)
.onSuccess {
mediaFile.value = it
}
.mapCatching { mediaFile ->
localMediaFactory.createFromMediaFile(
mediaFile = mediaFile,
mediaInfo = inputs.mediaInfo
)
}
.onSuccess {
localMedia.value = AsyncData.Success(it)
}
.onFailure {
localMedia.value = AsyncData.Failure(it)
}
private fun CoroutineScope.downloadMedia(
data: MediaViewerPageData.MediaViewerData,
) = launch {
dataSource.loadMedia(data)
}
private fun CoroutineScope.saveOnDisk(localMedia: AsyncData<LocalMedia>) = launch {
@@ -216,4 +199,13 @@ class MediaViewerPresenter @AssistedInject constructor(
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
import androidx.compose.runtime.State
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import kotlinx.collections.immutable.ImmutableList
data class MediaViewerState(
val eventId: EventId?,
val mediaInfo: MediaInfo,
val thumbnailSource: MediaSource?,
val downloadedMedia: AsyncData<LocalMedia>,
val listData: ImmutableList<MediaViewerPageData>,
val currentIndex: Int,
val snackbarMessage: SnackbarMessage?,
val canShowInfo: Boolean,
val mediaBottomSheetState: MediaBottomSheetState,
val eventSink: (MediaViewerEvents) -> Unit,
)
sealed interface MediaViewerPageData {
data class Failure(
val throwable: Throwable,
) : MediaViewerPageData
data class Loading(
val direction: Timeline.PaginationDirection,
val timestamp: Long,
) : MediaViewerPageData
data class MediaViewerData(
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val downloadedMedia: State<AsyncData<LocalMedia>>,
) : MediaViewerPageData
}

View File

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

View File

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

View File

@@ -14,6 +14,8 @@
<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_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_title">"Delete file?"</string>
<string name="screen_media_details_uploaded_by">"Uploaded by"</string>

View File

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

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

View File

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

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",
dateSentFull = "full",
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
import android.net.Uri
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -30,14 +30,23 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.gallery.FakeMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.gallery.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -52,6 +61,7 @@ class MediaViewerPresenterTest {
private val mockMediaUri: Uri = mockk("localMediaUri")
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
private val aUrl = "aUrl"
@Test
fun `present - initial state null Event`() = runTest {
@@ -61,9 +71,9 @@ class MediaViewerPresenterTest {
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
val initialState = awaitFirstItem()
assertThat(initialState.listData).isEmpty()
assertThat(initialState.currentIndex).isEqualTo(0)
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isTrue()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@@ -79,9 +89,9 @@ class MediaViewerPresenterTest {
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
val initialState = awaitFirstItem()
assertThat(initialState.listData).isEmpty()
assertThat(initialState.currentIndex).isEqualTo(0)
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isFalse()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@@ -97,9 +107,9 @@ class MediaViewerPresenterTest {
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
val initialState = awaitFirstItem()
assertThat(initialState.listData).isEmpty()
assertThat(initialState.currentIndex).isEqualTo(0)
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isTrue()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@@ -116,9 +126,9 @@ class MediaViewerPresenterTest {
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
val initialState = awaitFirstItem()
assertThat(initialState.listData).isEmpty()
assertThat(initialState.currentIndex).isEqualTo(0)
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isTrue()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@@ -126,114 +136,280 @@ class MediaViewerPresenterTest {
}
@Test
fun `present - download media success scenario`() = runTest {
val presenter = createMediaViewerPresenter(
room = FakeMatrixRoom(
canRedactOwnResult = { Result.success(true) },
)
fun `present - data source update`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
var state = awaitItem()
assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
state = awaitItem()
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
state = awaitItem()
val successData = state.downloadedMedia.dataOrNull()
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
assertThat(successData).isNotNull()
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage()
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.listData).isEmpty()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitFirstItem()
assertThat(updatedState.listData).hasSize(1)
val item = updatedState.listData.first() as MediaViewerPageData.MediaViewerData
assertThat(item.eventId).isNull()
assertThat(item.mediaInfo).isEqualTo(anImage.mediaInfo)
assertThat(item.mediaSource).isEqualTo(anImage.mediaSource)
assertThat(item.thumbnailSource).isEqualTo(anImage.thumbnailSource)
assertThat(item.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized)
}
}
@Test
fun `present - check all actions`() = runTest {
val mediaActions = FakeLocalMediaActions()
val snackbarDispatcher = SnackbarDispatcher()
val presenter = createMediaViewerPresenter(
localMediaActions = mediaActions,
snackbarDispatcher = snackbarDispatcher,
room = FakeMatrixRoom(
canRedactOwnResult = { Result.success(true) },
)
fun `present - load media`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
var state = awaitItem()
assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
state = awaitItem()
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
// no state changes while media is loading
state.eventSink(MediaViewerEvents.OpenWith)
state.eventSink(MediaViewerEvents.Share)
state.eventSink(MediaViewerEvents.SaveOnDisk)
state = awaitItem()
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
// Should succeed without change of state
state.eventSink(MediaViewerEvents.OpenWith)
// Should succeed without change of state
state.eventSink(MediaViewerEvents.Share)
state.eventSink(MediaViewerEvents.SaveOnDisk)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
snackbarDispatcher.clear()
assertThat(awaitItem().snackbarMessage).isNull()
// Check failures
mediaActions.shouldFail = true
state.eventSink(MediaViewerEvents.OpenWith)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
snackbarDispatcher.clear()
assertThat(awaitItem().snackbarMessage).isNull()
state.eventSink(MediaViewerEvents.Share)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
snackbarDispatcher.clear()
assertThat(awaitItem().snackbarMessage).isNull()
state.eventSink(MediaViewerEvents.SaveOnDisk)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.LoadMedia(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
}
}
@Test
fun `present - download media failure then retry with success scenario`() = runTest {
val matrixMediaLoader = FakeMatrixMediaLoader()
fun `present - open info`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
matrixMediaLoader = matrixMediaLoader,
mediaGalleryDataSource = mediaGalleryDataSource,
room = FakeMatrixRoom(
canRedactOwnResult = { Result.success(true) },
)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
matrixMediaLoader.shouldFail = true
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
val failureState = awaitItem()
assertThat(failureState.downloadedMedia).isInstanceOf(AsyncData.Failure::class.java)
matrixMediaLoader.shouldFail = false
failureState.eventSink(MediaViewerEvents.RetryLoading)
// There is one recomposition because of the retry mechanism
skipItems(1)
val retryLoadingState = awaitItem()
assertThat(retryLoadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
val successData = successState.downloadedMedia.dataOrNull()
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
assertThat(successData).isNotNull()
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.OpenInfo(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
val withInfoState = awaitItem()
assertThat(withInfoState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
withInfoState.eventSink(
MediaViewerEvents.CloseBottomSheet
)
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
}
}
@Test
fun `present - delete media success scenario`() = runTest {
fun `present - clear loading error`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.ClearLoadingError(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
}
}
@Test
fun `present - share`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.Share(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
}
}
@Test
fun `present - save on disk`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.SaveOnDisk(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
}
}
@Test
fun `present - open with`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.OpenWith(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
}
}
@Test
fun `present - delete and cancel`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.ConfirmDelete(
eventId = AN_EVENT_ID,
data = aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java)
withBottomSheetState.eventSink(
MediaViewerEvents.CloseBottomSheet
)
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
}
}
@Test
fun `present - delete`() = runTest {
val redactEventLambda = lambdaRecorder<EventOrTransactionId, String?, Result<Unit>> { _, _ ->
Result.success(Unit)
}
@@ -241,26 +417,51 @@ class MediaViewerPresenterTest {
this.redactEventLambda = redactEventLambda
}
val onItemDeletedLambda = lambdaRecorder<Unit> { }
val navigator = FakeMediaViewerNavigator(
onItemDeletedLambda = onItemDeletedLambda,
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
room = FakeMatrixRoom(
liveTimeline = timeline,
canRedactOwnResult = { Result.success(true) },
),
mediaViewerNavigator = navigator,
mediaGalleryDataSource = mediaGalleryDataSource,
mediaViewerNavigator = FakeMediaViewerNavigator(
onItemDeletedLambda = onItemDeletedLambda
)
)
val anImage = aMediaItemImage(
eventId = AN_EVENT_ID,
mediaSourceUrl = aUrl,
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
successState.eventSink(MediaViewerEvents.Delete(AN_EVENT_ID))
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.ConfirmDelete(
eventId = AN_EVENT_ID,
data = aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java)
updatedState.eventSink(
MediaViewerEvents.Delete(
eventId = AN_EVENT_ID,
)
)
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
redactEventLambda.assertions()
.isCalledOnce()
.with(
@@ -272,7 +473,71 @@ class MediaViewerPresenterTest {
}
@Test
fun `present - view in timeline invokes the navigator`() = runTest {
fun `present - on navigate to`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
val anImage2 = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage, anImage2),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.OnNavigateTo(1)
)
val finalState = awaitItem()
assertThat(finalState.currentIndex).isEqualTo(1)
}
}
@Test
fun `present - load more`() = runTest {
val loadMoreLambda = lambdaRecorder<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 navigator = FakeMediaViewerNavigator(
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
@@ -285,22 +550,28 @@ class MediaViewerPresenterTest {
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
successState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID))
initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData()))
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
initialState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
private fun createMediaViewerPresenter(
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem()
}
private fun TestScope.createMediaViewerPresenter(
eventId: EventId? = null,
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
),
canShowInfo: Boolean = true,
mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(),
room: MatrixRoom = FakeMatrixRoom(
@@ -309,18 +580,25 @@ class MediaViewerPresenterTest {
): MediaViewerPresenter {
return MediaViewerPresenter(
inputs = MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = eventId,
mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(),
thumbnailSource = null,
canShowInfo = canShowInfo,
),
localMediaFactory = localMediaFactory,
mediaLoader = matrixMediaLoader,
navigator = mediaViewerNavigator,
dataSource = MediaViewerDataSource(
galleryMode = MediaGalleryMode.Images,
dispatcher = testCoroutineDispatchers().computation,
galleryDataSource = mediaGalleryDataSource,
mediaLoader = matrixMediaLoader,
localMediaFactory = localMediaFactory,
systemClock = FakeSystemClock(),
),
room = room,
localMediaActions = localMediaActions,
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.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import io.mockk.mockk
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
@@ -36,78 +36,127 @@ import org.junit.runner.RunWith
class MediaViewerViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val mockMediaUrl: Uri = mockk("localMediaUri")
@Test
fun `clicking on back invokes expected callback`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false)
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val state = aMediaViewerState(
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setMediaViewerView(
aMediaViewerState(
eventSink = eventsRecorder
),
state = state,
onBackClick = callback,
)
rule.pressBack()
}
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
)
)
}
@Test
fun `clicking on open emit expected Event`() {
testMenuAction(CommonStrings.action_open_with, MediaViewerEvents.OpenWith)
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)),
)
testMenuAction(
data,
CommonStrings.action_open_with,
MediaViewerEvents.OpenWith(data),
)
}
private fun testMenuAction(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) {
@Test
fun `clicking on info emit expected Event`() {
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)),
)
testMenuAction(
data,
CommonStrings.a11y_view_details,
MediaViewerEvents.OpenInfo(data),
)
}
private fun testMenuAction(
data: MediaViewerPageData.MediaViewerData,
contentDescriptionRes: Int,
expectedEvent: MediaViewerEvents,
) {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
rule.setMediaViewerView(
aMediaViewerState(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
mediaInfo = anImageMediaInfo(),
listData = listOf(data),
eventSink = eventsRecorder
),
)
val contentDescription = rule.activity.getString(contentDescriptionRes)
rule.onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertSingle(expectedEvent)
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
expectedEvent,
)
)
}
@Test
fun `clicking on save emit expected Event`() {
testBottomSheetAction(CommonStrings.action_save, MediaViewerEvents.SaveOnDisk)
val data = aMediaViewerPageData()
testBottomSheetAction(
data,
CommonStrings.action_save,
MediaViewerEvents.SaveOnDisk(data),
)
}
@Test
fun `clicking on share emit expected Event`() {
testBottomSheetAction(CommonStrings.action_share, MediaViewerEvents.Share)
val data = aMediaViewerPageData()
testBottomSheetAction(
data,
CommonStrings.action_share,
MediaViewerEvents.Share(data),
)
}
private fun testBottomSheetAction(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) {
private fun testBottomSheetAction(
data: MediaViewerPageData.MediaViewerData,
contentDescriptionRes: Int,
expectedEvent: MediaViewerEvents,
) {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
rule.setMediaViewerView(
aMediaViewerState(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
mediaInfo = anImageMediaInfo(),
listData = listOf(data),
mediaBottomSheetState = aMediaDetailsBottomSheetState(),
eventSink = eventsRecorder
),
)
rule.clickOn(contentDescriptionRes)
eventsRecorder.assertSingle(expectedEvent)
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
expectedEvent,
)
)
}
@Test
fun `clicking on image hides the overlay`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false)
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val state = aMediaViewerState(
eventSink = eventsRecorder
)
rule.setMediaViewerView(
aMediaViewerState(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
mediaInfo = anImageMediaInfo(),
eventSink = eventsRecorder
),
state = state,
)
// Ensure that the action are visible
val contentDescription = rule.activity.getString(CommonStrings.action_open_with)
@@ -120,54 +169,79 @@ class MediaViewerViewTest {
rule.mainClock.advanceTimeBy(1_000)
rule.onNodeWithContentDescription(contentDescription)
.assertDoesNotExist()
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
)
)
}
@Test
fun `clicking swipe on the image invokes the expected callback`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false)
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val state = aMediaViewerState(
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setMediaViewerView(
aMediaViewerState(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
mediaInfo = anImageMediaInfo(),
eventSink = eventsRecorder
),
state = state,
onBackClick = callback,
)
val imageContentDescription = rule.activity.getString(CommonStrings.common_image)
rule.onNodeWithContentDescription(imageContentDescription).performTouchInput { swipeDown(startY = centerY) }
rule.mainClock.advanceTimeBy(1_000)
}
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
)
)
}
@Test
fun `error case, click on retry emits the expected Event`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
)
rule.setMediaViewerView(
aMediaViewerState(
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
mediaInfo = anImageMediaInfo(),
listData = listOf(data),
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(MediaViewerEvents.RetryLoading)
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
MediaViewerEvents.LoadMedia(data),
)
)
}
@Test
fun `error case, click on cancel emits the expected Event`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
)
rule.setMediaViewerView(
aMediaViewerState(
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
mediaInfo = anImageMediaInfo(),
listData = listOf(data),
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(MediaViewerEvents.ClearLoadingError)
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
MediaViewerEvents.ClearLoadingError(data)
)
)
}
}

View File

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

View File

@@ -29,6 +29,7 @@
<string name="a11y_show_password">"Show password"</string>
<string name="a11y_start_call">"Start a call"</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_stop_recording">"Stop recording"</string>
<string name="action_accept">"Accept"</string>
@@ -181,6 +182,7 @@ Reason: %1$s."</string>
<string name="common_light">"Light"</string>
<string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string>
<string name="common_loading">"Loading…"</string>
<string name="common_loading_more">"Loading more…"</string>
<plurals name="common_member_count">
<item quantity="one">"%1$d member"</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
class FakeSystemClock : SystemClock {
override fun epochMillis(): Long {
return A_FAKE_TIMESTAMP
}
class FakeSystemClock(
var epochMillisResult: Long = A_FAKE_TIMESTAMP
) : SystemClock {
override fun epochMillis() = epochMillisResult
}

View File

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