Merge pull request #4161 from element-hq/feature/bma/mediaNavigation
Media navigation with swipe gesture
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ private fun aMediaGalleryState(
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
private fun aGroupedMediaItems(
|
||||
fun aGroupedMediaItems(
|
||||
imageAndVideoItems: List<MediaItem> = emptyList(),
|
||||
fileItems: List<MediaItem> = emptyList(),
|
||||
) = GroupedMediaItems(
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 won’t 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ class AndroidLocalMediaFactoryTest {
|
||||
dateSent = "12:34",
|
||||
dateSentFull = "full",
|
||||
waveform = null,
|
||||
duration = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ class FakeLocalMediaFactory(
|
||||
dateSent = null,
|
||||
dateSentFull = null,
|
||||
waveform = null,
|
||||
duration = null,
|
||||
)
|
||||
return aLocalMedia(uri, mediaInfo)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ class KonsistClassNameTest {
|
||||
.withoutName(
|
||||
"Factory",
|
||||
"TimelineController",
|
||||
"TimelineMediaGalleryDataSource",
|
||||
)
|
||||
.withoutNameStartingWith(
|
||||
"Accompanist",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user