Merge pull request #4161 from element-hq/feature/bma/mediaNavigation
Media navigation with swipe gesture
This commit is contained in:
commit
26e2172edd
76 changed files with 2641 additions and 679 deletions
|
|
@ -48,6 +48,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.duration
|
||||||
import io.element.android.features.poll.api.create.CreatePollEntryPoint
|
import io.element.android.features.poll.api.create.CreatePollEntryPoint
|
||||||
import io.element.android.features.poll.api.create.CreatePollMode
|
import io.element.android.features.poll.api.create.CreatePollMode
|
||||||
import io.element.android.libraries.architecture.BackstackWithOverlayBox
|
import io.element.android.libraries.architecture.BackstackWithOverlayBox
|
||||||
|
|
@ -58,6 +59,7 @@ import io.element.android.libraries.architecture.overlay.operation.hide
|
||||||
import io.element.android.libraries.architecture.overlay.operation.show
|
import io.element.android.libraries.architecture.overlay.operation.show
|
||||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||||
|
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
|
@ -246,6 +248,8 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
is NavTarget.MediaViewer -> {
|
is NavTarget.MediaViewer -> {
|
||||||
val params = MediaViewerEntryPoint.Params(
|
val params = MediaViewerEntryPoint.Params(
|
||||||
|
// TODO When we will be able to load a media timeline from a EventId, change mode here (and use a mixed mode?)
|
||||||
|
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
|
||||||
eventId = navTarget.eventId,
|
eventId = navTarget.eventId,
|
||||||
mediaInfo = navTarget.mediaInfo,
|
mediaInfo = navTarget.mediaInfo,
|
||||||
mediaSource = navTarget.mediaSource,
|
mediaSource = navTarget.mediaSource,
|
||||||
|
|
@ -447,6 +451,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
mode = DateFormatterMode.Full,
|
mode = DateFormatterMode.Full,
|
||||||
),
|
),
|
||||||
waveform = (content as? TimelineItemVoiceContent)?.waveform,
|
waveform = (content as? TimelineItemVoiceContent)?.waveform,
|
||||||
|
duration = content.duration()?.toHumanReadableDuration(),
|
||||||
),
|
),
|
||||||
mediaSource = mediaSource,
|
mediaSource = mediaSource,
|
||||||
thumbnailSource = thumbnailSource,
|
thumbnailSource = thumbnailSource,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface TimelineItemEventContent {
|
sealed interface TimelineItemEventContent {
|
||||||
|
|
@ -90,3 +91,12 @@ fun TimelineItemEventContent.isEdited(): Boolean = when (this) {
|
||||||
is TimelineItemEventMutableContent -> isEdited
|
is TimelineItemEventMutableContent -> isEdited
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
|
||||||
|
return when (this) {
|
||||||
|
is TimelineItemAudioContent -> duration
|
||||||
|
is TimelineItemVideoContent -> duration
|
||||||
|
is TimelineItemVoiceContent -> duration
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
package io.element.android.libraries.dateformatter.api
|
package io.element.android.libraries.dateformatter.api
|
||||||
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert milliseconds to human readable duration.
|
* Convert milliseconds to human readable duration.
|
||||||
|
|
@ -38,3 +39,5 @@ fun Long.toHumanReadableDuration(): String {
|
||||||
String.format(Locale.US, "%d:%02d", minutes, seconds)
|
String.format(Locale.US, "%d:%02d", minutes, seconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Duration.toHumanReadableDuration() = inWholeMilliseconds.toHumanReadableDuration()
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.getAndUpdate
|
import kotlinx.coroutines.flow.getAndUpdate
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
|
@ -213,8 +211,8 @@ class RustTimeline(
|
||||||
|
|
||||||
override val timelineItems: Flow<List<MatrixTimelineItem>> = combine(
|
override val timelineItems: Flow<List<MatrixTimelineItem>> = combine(
|
||||||
_timelineItems,
|
_timelineItems,
|
||||||
backPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(),
|
backPaginationStatus,
|
||||||
forwardPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(),
|
forwardPaginationStatus,
|
||||||
matrixRoom.roomInfoFlow.map { it.creator },
|
matrixRoom.roomInfoFlow.map { it.creator },
|
||||||
isTimelineInitialized,
|
isTimelineInitialized,
|
||||||
) { timelineItems,
|
) { timelineItems,
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@
|
||||||
package io.element.android.libraries.matrix.impl.fixtures.fakes
|
package io.element.android.libraries.matrix.impl.fixtures.fakes
|
||||||
|
|
||||||
import org.matrix.rustcomponents.sdk.NoPointer
|
import org.matrix.rustcomponents.sdk.NoPointer
|
||||||
|
import org.matrix.rustcomponents.sdk.PaginationStatusListener
|
||||||
import org.matrix.rustcomponents.sdk.TaskHandle
|
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||||
import org.matrix.rustcomponents.sdk.Timeline
|
import org.matrix.rustcomponents.sdk.Timeline
|
||||||
import org.matrix.rustcomponents.sdk.TimelineDiff
|
import org.matrix.rustcomponents.sdk.TimelineDiff
|
||||||
import org.matrix.rustcomponents.sdk.TimelineListener
|
import org.matrix.rustcomponents.sdk.TimelineListener
|
||||||
|
import uniffi.matrix_sdk_ui.LiveBackPaginationStatus
|
||||||
|
|
||||||
class FakeRustTimeline : Timeline(NoPointer) {
|
class FakeRustTimeline : Timeline(NoPointer) {
|
||||||
private var listener: TimelineListener? = null
|
private var listener: TimelineListener? = null
|
||||||
|
|
@ -23,4 +25,16 @@ class FakeRustTimeline : Timeline(NoPointer) {
|
||||||
fun emitDiff(diff: List<TimelineDiff>) {
|
fun emitDiff(diff: List<TimelineDiff>) {
|
||||||
listener!!.onUpdate(diff)
|
listener!!.onUpdate(diff)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var paginationStatusListener: PaginationStatusListener? = null
|
||||||
|
override suspend fun subscribeToBackPaginationStatus(listener: PaginationStatusListener): TaskHandle {
|
||||||
|
this.paginationStatusListener = listener
|
||||||
|
return FakeRustTaskHandle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitPaginationStatus(status: LiveBackPaginationStatus) {
|
||||||
|
paginationStatusListener!!.onUpdate(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun fetchMembers() = Unit
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 dateSent: String?,
|
||||||
val dateSentFull: String?,
|
val dateSentFull: String?,
|
||||||
val waveform: List<Float>?,
|
val waveform: List<Float>?,
|
||||||
|
val duration: String?,
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
|
|
||||||
fun anImageMediaInfo(
|
fun anImageMediaInfo(
|
||||||
|
|
@ -45,6 +46,7 @@ fun anImageMediaInfo(
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aVideoMediaInfo(
|
fun aVideoMediaInfo(
|
||||||
|
|
@ -52,6 +54,7 @@ fun aVideoMediaInfo(
|
||||||
senderName: String? = null,
|
senderName: String? = null,
|
||||||
dateSent: String? = null,
|
dateSent: String? = null,
|
||||||
dateSentFull: String? = null,
|
dateSentFull: String? = null,
|
||||||
|
duration: String? = null,
|
||||||
): MediaInfo = MediaInfo(
|
): MediaInfo = MediaInfo(
|
||||||
filename = "a video file.mp4",
|
filename = "a video file.mp4",
|
||||||
caption = caption,
|
caption = caption,
|
||||||
|
|
@ -64,6 +67,7 @@ fun aVideoMediaInfo(
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aPdfMediaInfo(
|
fun aPdfMediaInfo(
|
||||||
|
|
@ -84,6 +88,7 @@ fun aPdfMediaInfo(
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun anApkMediaInfo(
|
fun anApkMediaInfo(
|
||||||
|
|
@ -103,6 +108,7 @@ fun anApkMediaInfo(
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun anAudioMediaInfo(
|
fun anAudioMediaInfo(
|
||||||
|
|
@ -112,6 +118,7 @@ fun anAudioMediaInfo(
|
||||||
dateSent: String? = null,
|
dateSent: String? = null,
|
||||||
dateSentFull: String? = null,
|
dateSentFull: String? = null,
|
||||||
waveForm: List<Float>? = null,
|
waveForm: List<Float>? = null,
|
||||||
|
duration: String? = null,
|
||||||
): MediaInfo = MediaInfo(
|
): MediaInfo = MediaInfo(
|
||||||
filename = filename,
|
filename = filename,
|
||||||
caption = caption,
|
caption = caption,
|
||||||
|
|
@ -124,6 +131,7 @@ fun anAudioMediaInfo(
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
waveform = waveForm,
|
waveform = waveForm,
|
||||||
|
duration = duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aVoiceMediaInfo(
|
fun aVoiceMediaInfo(
|
||||||
|
|
@ -133,6 +141,7 @@ fun aVoiceMediaInfo(
|
||||||
dateSent: String? = null,
|
dateSent: String? = null,
|
||||||
dateSentFull: String? = null,
|
dateSentFull: String? = null,
|
||||||
waveForm: List<Float>? = null,
|
waveForm: List<Float>? = null,
|
||||||
|
duration: String? = null,
|
||||||
): MediaInfo = MediaInfo(
|
): MediaInfo = MediaInfo(
|
||||||
filename = filename,
|
filename = filename,
|
||||||
caption = caption,
|
caption = caption,
|
||||||
|
|
@ -145,4 +154,5 @@ fun aVoiceMediaInfo(
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
waveform = waveForm,
|
waveform = waveForm,
|
||||||
|
duration = duration,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,17 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Params(
|
data class Params(
|
||||||
|
val mode: MediaViewerMode,
|
||||||
val eventId: EventId?,
|
val eventId: EventId?,
|
||||||
val mediaInfo: MediaInfo,
|
val mediaInfo: MediaInfo,
|
||||||
val mediaSource: MediaSource,
|
val mediaSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
val canShowInfo: Boolean,
|
val canShowInfo: Boolean,
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
|
enum class MediaViewerMode {
|
||||||
|
SingleMedia,
|
||||||
|
TimelineImagesAndVideos,
|
||||||
|
TimelineFilesAndAudios,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
|
||||||
val mimeType = MimeTypes.Images
|
val mimeType = MimeTypes.Images
|
||||||
return params(
|
return params(
|
||||||
MediaViewerEntryPoint.Params(
|
MediaViewerEntryPoint.Params(
|
||||||
|
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
|
||||||
eventId = null,
|
eventId = null,
|
||||||
mediaInfo = MediaInfo(
|
mediaInfo = MediaInfo(
|
||||||
filename = filename,
|
filename = filename,
|
||||||
|
|
@ -55,6 +56,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
|
||||||
dateSent = null,
|
dateSent = null,
|
||||||
dateSentFull = null,
|
dateSentFull = null,
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(url = avatarUrl),
|
mediaSource = MediaSource(url = avatarUrl),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
|
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -102,6 +101,7 @@ class EventItemFactory @Inject constructor(
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
),
|
),
|
||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
)
|
)
|
||||||
|
|
@ -120,8 +120,10 @@ class EventItemFactory @Inject constructor(
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
),
|
),
|
||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
|
// TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource
|
||||||
)
|
)
|
||||||
is ImageMessageType -> MediaItem.Image(
|
is ImageMessageType -> MediaItem.Image(
|
||||||
id = currentTimelineItem.uniqueId,
|
id = currentTimelineItem.uniqueId,
|
||||||
|
|
@ -138,9 +140,10 @@ class EventItemFactory @Inject constructor(
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
),
|
),
|
||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
thumbnailSource = null,
|
thumbnailSource = type.info?.thumbnailSource,
|
||||||
)
|
)
|
||||||
is StickerMessageType -> MediaItem.Image(
|
is StickerMessageType -> MediaItem.Image(
|
||||||
id = currentTimelineItem.uniqueId,
|
id = currentTimelineItem.uniqueId,
|
||||||
|
|
@ -157,9 +160,10 @@ class EventItemFactory @Inject constructor(
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
),
|
),
|
||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
thumbnailSource = null,
|
thumbnailSource = type.info?.thumbnailSource,
|
||||||
)
|
)
|
||||||
is VideoMessageType -> MediaItem.Video(
|
is VideoMessageType -> MediaItem.Video(
|
||||||
id = currentTimelineItem.uniqueId,
|
id = currentTimelineItem.uniqueId,
|
||||||
|
|
@ -176,10 +180,10 @@ class EventItemFactory @Inject constructor(
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
||||||
),
|
),
|
||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
thumbnailSource = type.info?.thumbnailSource,
|
thumbnailSource = type.info?.thumbnailSource,
|
||||||
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
|
||||||
)
|
)
|
||||||
is VoiceMessageType -> MediaItem.Voice(
|
is VoiceMessageType -> MediaItem.Voice(
|
||||||
id = currentTimelineItem.uniqueId,
|
id = currentTimelineItem.uniqueId,
|
||||||
|
|
@ -196,10 +200,9 @@ class EventItemFactory @Inject constructor(
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
waveform = type.details?.waveform.orEmpty(),
|
waveform = type.details?.waveform.orEmpty(),
|
||||||
|
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
||||||
),
|
),
|
||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
|
||||||
waveform = type.details?.waveform ?: persistentListOf(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 android.content.ActivityNotFoundException
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
|
|
@ -33,30 +30,21 @@ import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
|
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
|
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||||
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
|
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MediaGalleryPresenter @AssistedInject constructor(
|
class MediaGalleryPresenter @AssistedInject constructor(
|
||||||
@Assisted private val navigator: MediaGalleryNavigator,
|
@Assisted private val navigator: MediaGalleryNavigator,
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
|
private val mediaGalleryDataSource: MediaGalleryDataSource,
|
||||||
private val localMediaFactory: LocalMediaFactory,
|
private val localMediaFactory: LocalMediaFactory,
|
||||||
private val mediaLoader: MatrixMediaLoader,
|
private val mediaLoader: MatrixMediaLoader,
|
||||||
private val localMediaActions: LocalMediaActions,
|
private val localMediaActions: LocalMediaActions,
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
private val mediaItemsPostProcessor: MediaItemsPostProcessor,
|
|
||||||
) : Presenter<MediaGalleryState> {
|
) : Presenter<MediaGalleryState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -74,56 +62,36 @@ class MediaGalleryPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
|
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
|
||||||
|
|
||||||
var mediaItems by remember {
|
|
||||||
mutableStateOf<AsyncData<ImmutableList<MediaItem>>>(AsyncData.Uninitialized)
|
|
||||||
}
|
|
||||||
val groupedMediaItems by remember {
|
val groupedMediaItems by remember {
|
||||||
derivedStateOf {
|
mediaGalleryDataSource.groupedMediaItemsFlow()
|
||||||
mediaItemsPostProcessor.process(
|
|
||||||
mediaItems = mediaItems,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.collectAsState(AsyncData.Uninitialized)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
mediaGalleryDataSource.start()
|
||||||
|
}
|
||||||
|
|
||||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||||
localMediaActions.Configure()
|
localMediaActions.Configure()
|
||||||
|
|
||||||
var timeline by remember { mutableStateOf<AsyncData<Timeline>>(AsyncData.Uninitialized) }
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
room.mediaTimeline()
|
|
||||||
.fold(
|
|
||||||
{ timeline = AsyncData.Success(it) },
|
|
||||||
{ timeline = AsyncData.Failure(it) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
timeline.dataOrNull()?.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MediaListEffect(
|
|
||||||
timeline = timeline,
|
|
||||||
onItemsChange = { newItems ->
|
|
||||||
mediaItems = newItems
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
fun handleEvents(event: MediaGalleryEvents) {
|
fun handleEvents(event: MediaGalleryEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is MediaGalleryEvents.ChangeMode -> {
|
is MediaGalleryEvents.ChangeMode -> {
|
||||||
mode = event.mode
|
mode = event.mode
|
||||||
}
|
}
|
||||||
is MediaGalleryEvents.LoadMore -> coroutineScope.launch {
|
is MediaGalleryEvents.LoadMore -> coroutineScope.launch {
|
||||||
timeline.dataOrNull()?.paginate(event.direction)
|
mediaGalleryDataSource.loadMore(event.direction)
|
||||||
|
}
|
||||||
|
is MediaGalleryEvents.Delete -> coroutineScope.launch {
|
||||||
|
mediaGalleryDataSource.deleteItem(event.eventId)
|
||||||
}
|
}
|
||||||
is MediaGalleryEvents.Delete -> coroutineScope.delete(timeline, event.eventId)
|
|
||||||
is MediaGalleryEvents.SaveOnDisk -> coroutineScope.launch {
|
is MediaGalleryEvents.SaveOnDisk -> coroutineScope.launch {
|
||||||
mediaItems.dataOrNull().find(event.eventId)?.let {
|
groupedMediaItems.dataOrNull().find(event.eventId)?.let {
|
||||||
saveOnDisk(it)
|
saveOnDisk(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is MediaGalleryEvents.Share -> coroutineScope.launch {
|
is MediaGalleryEvents.Share -> coroutineScope.launch {
|
||||||
mediaItems.dataOrNull().find(event.eventId)?.let {
|
groupedMediaItems.dataOrNull().find(event.eventId)?.let {
|
||||||
share(it)
|
share(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -172,49 +140,6 @@ class MediaGalleryPresenter @AssistedInject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MediaListEffect(
|
|
||||||
timeline: AsyncData<Timeline>,
|
|
||||||
onItemsChange: (AsyncData<ImmutableList<MediaItem>>) -> Unit,
|
|
||||||
) {
|
|
||||||
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
|
|
||||||
|
|
||||||
LaunchedEffect(timeline) {
|
|
||||||
when (timeline) {
|
|
||||||
AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized)
|
|
||||||
is AsyncData.Failure -> flowOf(AsyncData.Failure(timeline.error))
|
|
||||||
is AsyncData.Loading -> flowOf(AsyncData.Loading())
|
|
||||||
is AsyncData.Success -> {
|
|
||||||
timeline.data.timelineItems
|
|
||||||
.onEach { items ->
|
|
||||||
timelineMediaItemsFactory.replaceWith(
|
|
||||||
timelineItems = items,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.launchIn(this)
|
|
||||||
|
|
||||||
timelineMediaItemsFactory.timelineItems.map { timelineItems ->
|
|
||||||
AsyncData.Success(timelineItems)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEach { items ->
|
|
||||||
updatedOnItemsChange(items)
|
|
||||||
}
|
|
||||||
.launchIn(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun CoroutineScope.delete(
|
|
||||||
timeline: AsyncData<Timeline>,
|
|
||||||
eventId: EventId,
|
|
||||||
) = launch {
|
|
||||||
timeline.dataOrNull()?.redactEvent(
|
|
||||||
eventOrTransactionId = eventId.toEventOrTransactionId(),
|
|
||||||
reason = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun downloadMedia(mediaItem: MediaItem.Event): Result<LocalMedia> {
|
private suspend fun downloadMedia(mediaItem: MediaItem.Event): Result<LocalMedia> {
|
||||||
return mediaLoader.downloadMediaFile(
|
return mediaLoader.downloadMediaFile(
|
||||||
source = mediaItem.mediaSource(),
|
source = mediaItem.mediaSource(),
|
||||||
|
|
@ -264,10 +189,10 @@ class MediaGalleryPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<MediaItem>?.find(eventId: EventId?): MediaItem.Event? {
|
private fun GroupedMediaItems?.find(eventId: EventId?): MediaItem.Event? {
|
||||||
if (this == null || eventId == null) {
|
if (this == null || eventId == null) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return filterIsInstance<MediaItem.Event>()
|
return (imageAndVideoItems + fileItems).filterIsInstance<MediaItem.Event>()
|
||||||
.firstOrNull { it.eventId() == eventId }
|
.firstOrNull { it.eventId() == eventId }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ private fun aMediaGalleryState(
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun aGroupedMediaItems(
|
fun aGroupedMediaItems(
|
||||||
imageAndVideoItems: List<MediaItem> = emptyList(),
|
imageAndVideoItems: List<MediaItem> = emptyList(),
|
||||||
fileItems: List<MediaItem> = emptyList(),
|
fileItems: List<MediaItem> = emptyList(),
|
||||||
) = GroupedMediaItems(
|
) = GroupedMediaItems(
|
||||||
|
|
|
||||||
|
|
@ -108,15 +108,15 @@ fun MediaGalleryView(
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
.consumeWindowInsets(paddingValues)
|
.consumeWindowInsets(paddingValues)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
SingleChoiceSegmentedButtonRow(
|
SingleChoiceSegmentedButtonRow(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
MediaGalleryMode.entries.forEach { mode ->
|
MediaGalleryMode.entries.forEach { mode ->
|
||||||
SegmentedButton(
|
SegmentedButton(
|
||||||
|
|
@ -137,7 +137,6 @@ fun MediaGalleryView(
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
userScrollEnabled = false,
|
userScrollEnabled = false,
|
||||||
modifier = Modifier,
|
|
||||||
) { page ->
|
) { page ->
|
||||||
val mode = MediaGalleryMode.entries[page]
|
val mode = MediaGalleryMode.entries[page]
|
||||||
MediaGalleryPage(
|
MediaGalleryPage(
|
||||||
|
|
@ -198,6 +197,13 @@ private fun MediaGalleryPage(
|
||||||
) {
|
) {
|
||||||
val groupedMediaItems = state.groupedMediaItems
|
val groupedMediaItems = state.groupedMediaItems
|
||||||
if (groupedMediaItems.isLoadingItems(mode)) {
|
if (groupedMediaItems.isLoadingItems(mode)) {
|
||||||
|
// Need to trigger a pagination now if there is only one LoadingIndicator.
|
||||||
|
val loadingItem = groupedMediaItems.dataOrNull()?.getItems(mode)?.singleOrNull() as? MediaItem.LoadingIndicator
|
||||||
|
if (loadingItem != null) {
|
||||||
|
LaunchedEffect(loadingItem.timestamp) {
|
||||||
|
state.eventSink(MediaGalleryEvents.LoadMore(loadingItem.direction))
|
||||||
|
}
|
||||||
|
}
|
||||||
LoadingContent(mode)
|
LoadingContent(mode)
|
||||||
} else {
|
} else {
|
||||||
when (groupedMediaItems) {
|
when (groupedMediaItems) {
|
||||||
|
|
@ -348,8 +354,8 @@ private fun MediaGalleryImageGrid(
|
||||||
) {
|
) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
columns = GridCells.Adaptive(80.dp),
|
columns = GridCells.Adaptive(80.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
|
@ -420,9 +426,9 @@ private fun LoadingMoreIndicator(
|
||||||
Timeline.PaginationDirection.FORWARDS -> {
|
Timeline.PaginationDirection.FORWARDS -> {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 2.dp)
|
.padding(top = 2.dp)
|
||||||
.height(1.dp)
|
.height(1.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Timeline.PaginationDirection.BACKWARDS -> {
|
Timeline.PaginationDirection.BACKWARDS -> {
|
||||||
|
|
@ -460,9 +466,9 @@ private fun EmptyContent(
|
||||||
OnboardingBackground()
|
OnboardingBackground()
|
||||||
PageTitle(
|
PageTitle(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 44.dp)
|
.padding(top = 44.dp)
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
title = stringResource(titleRes),
|
title = stringResource(titleRes),
|
||||||
iconStyle = BigIcon.Style.Default(icon),
|
iconStyle = BigIcon.Style.Default(icon),
|
||||||
subtitle = stringResource(subtitleRes),
|
subtitle = stringResource(subtitleRes),
|
||||||
|
|
@ -480,9 +486,9 @@ private fun LoadingContent(
|
||||||
OnboardingBackground()
|
OnboardingBackground()
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(top = 48.dp)
|
.padding(top = 48.dp)
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
|
|
||||||
sealed interface MediaItem {
|
sealed interface MediaItem {
|
||||||
data class DateSeparator(
|
data class DateSeparator(
|
||||||
|
|
@ -46,7 +45,6 @@ sealed interface MediaItem {
|
||||||
val mediaInfo: MediaInfo,
|
val mediaInfo: MediaInfo,
|
||||||
val mediaSource: MediaSource,
|
val mediaSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
val duration: String?,
|
|
||||||
) : Event {
|
) : Event {
|
||||||
val thumbnailMediaRequestData: MediaRequestData
|
val thumbnailMediaRequestData: MediaRequestData
|
||||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
||||||
|
|
@ -64,8 +62,6 @@ sealed interface MediaItem {
|
||||||
val eventId: EventId?,
|
val eventId: EventId?,
|
||||||
val mediaInfo: MediaInfo,
|
val mediaInfo: MediaInfo,
|
||||||
val mediaSource: MediaSource,
|
val mediaSource: MediaSource,
|
||||||
val duration: String?,
|
|
||||||
val waveform: ImmutableList<Float>,
|
|
||||||
) : Event
|
) : Event
|
||||||
|
|
||||||
data class File(
|
data class File(
|
||||||
|
|
|
||||||
|
|
@ -7,32 +7,19 @@
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.impl.gallery
|
package io.element.android.libraries.mediaviewer.impl.gallery
|
||||||
|
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class MediaItemsPostProcessor @Inject constructor() {
|
class MediaItemsPostProcessor @Inject constructor() {
|
||||||
fun process(
|
fun process(
|
||||||
mediaItems: AsyncData<ImmutableList<MediaItem>>,
|
mediaItems: List<MediaItem>,
|
||||||
): AsyncData<GroupedMediaItems> {
|
): GroupedMediaItems {
|
||||||
return when (mediaItems) {
|
|
||||||
is AsyncData.Uninitialized -> AsyncData.Uninitialized
|
|
||||||
is AsyncData.Loading -> AsyncData.Loading()
|
|
||||||
is AsyncData.Failure -> AsyncData.Failure(mediaItems.error)
|
|
||||||
is AsyncData.Success -> AsyncData.Success(
|
|
||||||
mediaItems.data.process()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<MediaItem>.process(): GroupedMediaItems {
|
|
||||||
val imageAndVideoItems = mutableListOf<MediaItem>()
|
val imageAndVideoItems = mutableListOf<MediaItem>()
|
||||||
val fileItems = mutableListOf<MediaItem>()
|
val fileItems = mutableListOf<MediaItem>()
|
||||||
|
|
||||||
val imageAndVideoItemsSubList = mutableListOf<MediaItem.Event>()
|
val imageAndVideoItemsSubList = mutableListOf<MediaItem.Event>()
|
||||||
val fileItemsSublist = mutableListOf<MediaItem.Event>()
|
val fileItemsSublist = mutableListOf<MediaItem.Event>()
|
||||||
forEach { item ->
|
mediaItems.forEach { item ->
|
||||||
when (item) {
|
when (item) {
|
||||||
is MediaItem.DateSeparator -> {
|
is MediaItem.DateSeparator -> {
|
||||||
if (imageAndVideoItemsSubList.isNotEmpty()) {
|
if (imageAndVideoItemsSubList.isNotEmpty()) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
@Parcelize
|
||||||
data class MediaViewer(
|
data class MediaViewer(
|
||||||
|
val mode: MediaViewerEntryPoint.MediaViewerMode,
|
||||||
val eventId: EventId?,
|
val eventId: EventId?,
|
||||||
val mediaInfo: MediaInfo,
|
val mediaInfo: MediaInfo,
|
||||||
val mediaSource: MediaSource,
|
val mediaSource: MediaSource,
|
||||||
|
|
@ -92,8 +93,16 @@ class MediaGalleryRootNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemClick(item: MediaItem.Event) {
|
override fun onItemClick(item: MediaItem.Event) {
|
||||||
|
val mode = when (item) {
|
||||||
|
is MediaItem.Audio,
|
||||||
|
is MediaItem.Voice,
|
||||||
|
is MediaItem.File -> MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios
|
||||||
|
is MediaItem.Image,
|
||||||
|
is MediaItem.Video -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos
|
||||||
|
}
|
||||||
overlay.show(
|
overlay.show(
|
||||||
NavTarget.MediaViewer(
|
NavTarget.MediaViewer(
|
||||||
|
mode = mode,
|
||||||
eventId = item.eventId(),
|
eventId = item.eventId(),
|
||||||
mediaInfo = item.mediaInfo(),
|
mediaInfo = item.mediaInfo(),
|
||||||
mediaSource = item.mediaSource(),
|
mediaSource = item.mediaSource(),
|
||||||
|
|
@ -117,6 +126,7 @@ class MediaGalleryRootNode @AssistedInject constructor(
|
||||||
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
|
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
|
||||||
.params(
|
.params(
|
||||||
MediaViewerEntryPoint.Params(
|
MediaViewerEntryPoint.Params(
|
||||||
|
mode = navTarget.mode,
|
||||||
eventId = navTarget.eventId,
|
eventId = navTarget.eventId,
|
||||||
mediaInfo = navTarget.mediaInfo,
|
mediaInfo = navTarget.mediaInfo,
|
||||||
mediaSource = navTarget.mediaSource,
|
mediaSource = navTarget.mediaSource,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.core.preview.loremIpsum
|
import io.element.android.libraries.core.preview.loremIpsum
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
||||||
|
|
@ -30,12 +31,13 @@ class MediaItemFileProvider : PreviewParameterProvider<MediaItem.File> {
|
||||||
|
|
||||||
fun aMediaItemFile(
|
fun aMediaItemFile(
|
||||||
id: UniqueId = UniqueId("fileId"),
|
id: UniqueId = UniqueId("fileId"),
|
||||||
|
eventId: EventId? = null,
|
||||||
filename: String = "filename",
|
filename: String = "filename",
|
||||||
caption: String? = null,
|
caption: String? = null,
|
||||||
): MediaItem.File {
|
): MediaItem.File {
|
||||||
return MediaItem.File(
|
return MediaItem.File(
|
||||||
id = id,
|
id = id,
|
||||||
eventId = null,
|
eventId = eventId,
|
||||||
mediaInfo = aPdfMediaInfo(
|
mediaInfo = aPdfMediaInfo(
|
||||||
filename = filename,
|
filename = filename,
|
||||||
caption = caption,
|
caption = caption,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ fun aMediaItemImage(
|
||||||
id: UniqueId = UniqueId("imageId"),
|
id: UniqueId = UniqueId("imageId"),
|
||||||
eventId: EventId? = null,
|
eventId: EventId? = null,
|
||||||
senderId: UserId? = null,
|
senderId: UserId? = null,
|
||||||
|
mediaSourceUrl: String = "",
|
||||||
): MediaItem.Image {
|
): MediaItem.Image {
|
||||||
return MediaItem.Image(
|
return MediaItem.Image(
|
||||||
id = id,
|
id = id,
|
||||||
|
|
@ -25,7 +26,7 @@ fun aMediaItemImage(
|
||||||
mediaInfo = anImageMediaInfo(
|
mediaInfo = anImageMediaInfo(
|
||||||
senderId = senderId,
|
senderId = senderId,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(mediaSourceUrl),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,11 @@ import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
||||||
|
|
||||||
fun aMediaItemLoadingIndicator(
|
fun aMediaItemLoadingIndicator(
|
||||||
id: UniqueId = UniqueId("loadingId"),
|
id: UniqueId = UniqueId("loadingId"),
|
||||||
|
direction: Timeline.PaginationDirection = Timeline.PaginationDirection.BACKWARDS,
|
||||||
): MediaItem.LoadingIndicator {
|
): MediaItem.LoadingIndicator {
|
||||||
return MediaItem.LoadingIndicator(
|
return MediaItem.LoadingIndicator(
|
||||||
id = id,
|
id = id,
|
||||||
direction = Timeline.PaginationDirection.BACKWARDS,
|
direction = direction,
|
||||||
timestamp = 123,
|
timestamp = 123,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,10 @@ fun aMediaItemVideo(
|
||||||
return MediaItem.Video(
|
return MediaItem.Video(
|
||||||
id = id,
|
id = id,
|
||||||
eventId = null,
|
eventId = null,
|
||||||
mediaInfo = aVideoMediaInfo(),
|
mediaInfo = aVideoMediaInfo(
|
||||||
|
duration = duration
|
||||||
|
),
|
||||||
mediaSource = mediaSource,
|
mediaSource = mediaSource,
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
duration = duration,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
|
import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
|
||||||
|
|
||||||
class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> {
|
class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> {
|
||||||
override val values: Sequence<MediaItem.Voice>
|
override val values: Sequence<MediaItem.Voice>
|
||||||
|
|
@ -46,9 +45,9 @@ fun aMediaItemVoice(
|
||||||
mediaInfo = aVoiceMediaInfo(
|
mediaInfo = aVoiceMediaInfo(
|
||||||
filename = filename,
|
filename = filename,
|
||||||
caption = caption,
|
caption = caption,
|
||||||
|
duration = duration,
|
||||||
|
waveForm = waveform,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
duration = duration,
|
|
||||||
waveform = waveform.toImmutableList(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,10 +101,10 @@ private fun VideoInfoRow(
|
||||||
imageVector = CompoundIcons.VideoCallSolid(),
|
imageVector = CompoundIcons.VideoCallSolid(),
|
||||||
contentDescription = null
|
contentDescription = null
|
||||||
)
|
)
|
||||||
if (video.duration != null) {
|
video.mediaInfo.duration?.let { duration ->
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
Text(
|
Text(
|
||||||
text = video.duration,
|
text = duration,
|
||||||
style = ElementTheme.typography.fontBodySmMedium,
|
style = ElementTheme.typography.fontBodySmMedium,
|
||||||
color = ElementTheme.colors.textPrimary,
|
color = ElementTheme.colors.textPrimary,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ private fun VoiceInfoRow(
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = if (state.progress > 0f) state.time else voice.duration ?: state.time,
|
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
|
||||||
color = ElementTheme.colors.textSecondary,
|
color = ElementTheme.colors.textSecondary,
|
||||||
style = ElementTheme.typography.fontBodyMdMedium,
|
style = ElementTheme.typography.fontBodyMdMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
@ -128,7 +128,7 @@ private fun VoiceInfoRow(
|
||||||
.height(34.dp),
|
.height(34.dp),
|
||||||
showCursor = state.showCursor,
|
showCursor = state.showCursor,
|
||||||
playbackProgress = state.progress,
|
playbackProgress = state.progress,
|
||||||
waveform = voice.waveform.toPersistentList(),
|
waveform = voice.mediaInfo.waveform.orEmpty().toPersistentList(),
|
||||||
onSeek = {
|
onSeek = {
|
||||||
state.eventSink(VoiceMessageEvents.Seek(it))
|
state.eventSink(VoiceMessageEvents.Seek(it))
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ class AndroidLocalMediaFactory @Inject constructor(
|
||||||
dateSent = mediaInfo.dateSent,
|
dateSent = mediaInfo.dateSent,
|
||||||
dateSentFull = mediaInfo.dateSentFull,
|
dateSentFull = mediaInfo.dateSentFull,
|
||||||
waveform = mediaInfo.waveform,
|
waveform = mediaInfo.waveform,
|
||||||
|
duration = mediaInfo.duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun createFromUri(
|
override fun createFromUri(
|
||||||
|
|
@ -67,6 +68,7 @@ class AndroidLocalMediaFactory @Inject constructor(
|
||||||
dateSent = null,
|
dateSent = null,
|
||||||
dateSentFull = null,
|
dateSentFull = null,
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun createFromUri(
|
private fun createFromUri(
|
||||||
|
|
@ -81,6 +83,7 @@ class AndroidLocalMediaFactory @Inject constructor(
|
||||||
dateSent: String?,
|
dateSent: String?,
|
||||||
dateSentFull: String?,
|
dateSentFull: String?,
|
||||||
waveform: List<Float>?,
|
waveform: List<Float>?,
|
||||||
|
duration: String?,
|
||||||
): LocalMedia {
|
): LocalMedia {
|
||||||
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
|
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
|
||||||
val fileName = name ?: context.getFileName(uri) ?: ""
|
val fileName = name ?: context.getFileName(uri) ?: ""
|
||||||
|
|
@ -100,6 +103,7 @@ class AndroidLocalMediaFactory @Inject constructor(
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
waveform = waveform,
|
waveform = waveform,
|
||||||
|
duration = duration,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ fun LocalMediaView(
|
||||||
bottomPaddingInPixels: Int,
|
bottomPaddingInPixels: Int,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
isDisplayed: Boolean = true,
|
||||||
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
|
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
|
||||||
mediaInfo: MediaInfo? = localMedia?.info,
|
mediaInfo: MediaInfo? = localMedia?.info,
|
||||||
) {
|
) {
|
||||||
|
|
@ -39,6 +40,7 @@ fun LocalMediaView(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
)
|
)
|
||||||
mimeType.isMimeTypeVideo() -> MediaVideoView(
|
mimeType.isMimeTypeVideo() -> MediaVideoView(
|
||||||
|
isDisplayed = isDisplayed,
|
||||||
localMediaViewState = localMediaViewState,
|
localMediaViewState = localMediaViewState,
|
||||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
|
|
@ -51,6 +53,7 @@ fun LocalMediaView(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
)
|
)
|
||||||
mimeType.isMimeTypeAudio() -> MediaAudioView(
|
mimeType.isMimeTypeAudio() -> MediaAudioView(
|
||||||
|
isDisplayed = isDisplayed,
|
||||||
localMediaViewState = localMediaViewState,
|
localMediaViewState = localMediaViewState,
|
||||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,11 @@ fun MediaAudioView(
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
info: MediaInfo?,
|
info: MediaInfo?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
isDisplayed: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val exoPlayer = rememberExoPlayer()
|
val exoPlayer = rememberExoPlayer()
|
||||||
ExoPlayerMediaAudioView(
|
ExoPlayerMediaAudioView(
|
||||||
|
isDisplayed = isDisplayed,
|
||||||
localMediaViewState = localMediaViewState,
|
localMediaViewState = localMediaViewState,
|
||||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||||
exoPlayer = exoPlayer,
|
exoPlayer = exoPlayer,
|
||||||
|
|
@ -98,6 +100,7 @@ fun MediaAudioView(
|
||||||
@SuppressLint("UnsafeOptInUsageError")
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
@Composable
|
@Composable
|
||||||
private fun ExoPlayerMediaAudioView(
|
private fun ExoPlayerMediaAudioView(
|
||||||
|
isDisplayed: Boolean,
|
||||||
localMediaViewState: LocalMediaViewState,
|
localMediaViewState: LocalMediaViewState,
|
||||||
bottomPaddingInPixels: Int,
|
bottomPaddingInPixels: Int,
|
||||||
exoPlayer: ExoPlayer,
|
exoPlayer: ExoPlayer,
|
||||||
|
|
@ -176,6 +179,12 @@ private fun ExoPlayerMediaAudioView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(isDisplayed) {
|
||||||
|
// If not displayed, make sure to pause the audio
|
||||||
|
if (!isDisplayed) {
|
||||||
|
exoPlayer.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
if (localMedia?.uri != null) {
|
if (localMedia?.uri != null) {
|
||||||
LaunchedEffect(localMedia.uri) {
|
LaunchedEffect(localMedia.uri) {
|
||||||
val mediaItem = MediaItem.fromUri(localMedia.uri)
|
val mediaItem = MediaItem.fromUri(localMedia.uri)
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
|
@ -40,6 +39,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Slider
|
import io.element.android.libraries.designsystem.theme.components.Slider
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -58,7 +58,7 @@ fun MediaPlayerControllerView(
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(color = Color(0x99101317))
|
.background(color = bgCanvasWithTransparency)
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
@SuppressLint("UnsafeOptInUsageError")
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaVideoView(
|
fun MediaVideoView(
|
||||||
|
isDisplayed: Boolean,
|
||||||
localMediaViewState: LocalMediaViewState,
|
localMediaViewState: LocalMediaViewState,
|
||||||
bottomPaddingInPixels: Int,
|
bottomPaddingInPixels: Int,
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
|
|
@ -64,6 +65,7 @@ fun MediaVideoView(
|
||||||
) {
|
) {
|
||||||
val exoPlayer = rememberExoPlayer()
|
val exoPlayer = rememberExoPlayer()
|
||||||
ExoPlayerMediaVideoView(
|
ExoPlayerMediaVideoView(
|
||||||
|
isDisplayed = isDisplayed,
|
||||||
localMediaViewState = localMediaViewState,
|
localMediaViewState = localMediaViewState,
|
||||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||||
exoPlayer = exoPlayer,
|
exoPlayer = exoPlayer,
|
||||||
|
|
@ -75,6 +77,7 @@ fun MediaVideoView(
|
||||||
@SuppressLint("UnsafeOptInUsageError")
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
@Composable
|
@Composable
|
||||||
private fun ExoPlayerMediaVideoView(
|
private fun ExoPlayerMediaVideoView(
|
||||||
|
isDisplayed: Boolean,
|
||||||
localMediaViewState: LocalMediaViewState,
|
localMediaViewState: LocalMediaViewState,
|
||||||
bottomPaddingInPixels: Int,
|
bottomPaddingInPixels: Int,
|
||||||
exoPlayer: ExoPlayer,
|
exoPlayer: ExoPlayer,
|
||||||
|
|
@ -161,6 +164,12 @@ private fun ExoPlayerMediaVideoView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(isDisplayed) {
|
||||||
|
// If not displayed, make sure to pause the video
|
||||||
|
if (!isDisplayed) {
|
||||||
|
exoPlayer.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
if (localMedia?.uri != null) {
|
if (localMedia?.uri != null) {
|
||||||
LaunchedEffect(localMedia.uri) {
|
LaunchedEffect(localMedia.uri) {
|
||||||
val mediaItem = MediaItem.fromUri(localMedia.uri)
|
val mediaItem = MediaItem.fromUri(localMedia.uri)
|
||||||
|
|
@ -245,6 +254,7 @@ private fun ExoPlayerMediaVideoView(
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MediaVideoViewPreview() = ElementPreview {
|
internal fun MediaVideoViewPreview() = ElementPreview {
|
||||||
MediaVideoView(
|
MediaVideoView(
|
||||||
|
isDisplayed = true,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
bottomPaddingInPixels = 0,
|
bottomPaddingInPixels = 0,
|
||||||
localMediaViewState = rememberLocalMediaViewState(),
|
localMediaViewState = rememberLocalMediaViewState(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
|
||||||
sealed interface MediaViewerEvents {
|
sealed interface MediaViewerEvents {
|
||||||
data object SaveOnDisk : MediaViewerEvents
|
data class LoadMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
|
||||||
data object Share : MediaViewerEvents
|
data class SaveOnDisk(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
|
||||||
data object OpenWith : MediaViewerEvents
|
data class Share(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
|
||||||
data object RetryLoading : MediaViewerEvents
|
data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
|
||||||
data object ClearLoadingError : MediaViewerEvents
|
data class ClearLoadingError(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
|
||||||
data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents
|
data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents
|
||||||
data object OpenInfo : MediaViewerEvents
|
data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
|
||||||
data class ConfirmDelete(val eventId: EventId) : MediaViewerEvents
|
data class ConfirmDelete(
|
||||||
|
val eventId: EventId,
|
||||||
|
val data: MediaViewerPageData.MediaViewerData,
|
||||||
|
) : MediaViewerEvents
|
||||||
|
|
||||||
data object CloseBottomSheet : MediaViewerEvents
|
data object CloseBottomSheet : MediaViewerEvents
|
||||||
data class Delete(val eventId: EventId) : MediaViewerEvents
|
data class Delete(val eventId: EventId) : MediaViewerEvents
|
||||||
|
data class OnNavigateTo(val index: Int) : MediaViewerEvents
|
||||||
|
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.anvilannotations.ContributesNode
|
||||||
import io.element.android.compound.theme.ForcedDarkElementTheme
|
import io.element.android.compound.theme.ForcedDarkElementTheme
|
||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.SingleMediaGalleryDataSource
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.TimelineMediaGalleryDataSource
|
||||||
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
|
|
||||||
@ContributesNode(RoomScope::class)
|
@ContributesNode(RoomScope::class)
|
||||||
class MediaViewerNode @AssistedInject constructor(
|
class MediaViewerNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
presenterFactory: MediaViewerPresenter.Factory,
|
presenterFactory: MediaViewerPresenter.Factory,
|
||||||
|
timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource,
|
||||||
|
mediaLoader: MatrixMediaLoader,
|
||||||
|
localMediaFactory: LocalMediaFactory,
|
||||||
|
coroutineDispatchers: CoroutineDispatchers,
|
||||||
|
systemClock: SystemClock,
|
||||||
) : Node(buildContext, plugins = plugins),
|
) : Node(buildContext, plugins = plugins),
|
||||||
MediaViewerNavigator {
|
MediaViewerNavigator {
|
||||||
private val inputs = inputs<MediaViewerEntryPoint.Params>()
|
private val inputs = inputs<MediaViewerEntryPoint.Params>()
|
||||||
|
|
@ -47,9 +59,29 @@ class MediaViewerNode @AssistedInject constructor(
|
||||||
onDone()
|
onDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) {
|
||||||
|
SingleMediaGalleryDataSource.createFrom(inputs)
|
||||||
|
} else {
|
||||||
|
timelineMediaGalleryDataSource
|
||||||
|
}
|
||||||
|
|
||||||
|
private val galleryMode = when (inputs.mode) {
|
||||||
|
MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
|
||||||
|
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
|
||||||
|
MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
|
||||||
|
}
|
||||||
|
|
||||||
private val presenter = presenterFactory.create(
|
private val presenter = presenterFactory.create(
|
||||||
inputs = inputs,
|
inputs = inputs,
|
||||||
navigator = this,
|
navigator = this,
|
||||||
|
dataSource = MediaViewerDataSource(
|
||||||
|
dispatcher = coroutineDispatchers.computation,
|
||||||
|
galleryMode = galleryMode,
|
||||||
|
galleryDataSource = mediaGallerySource,
|
||||||
|
mediaLoader = mediaLoader,
|
||||||
|
localMediaFactory = localMediaFactory,
|
||||||
|
systemClock = systemClock,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ package io.element.android.libraries.mediaviewer.impl.viewer
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -26,15 +25,12 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
|
||||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
|
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
|
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||||
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
|
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
@ -45,9 +41,8 @@ import io.element.android.libraries.androidutils.R as UtilsR
|
||||||
class MediaViewerPresenter @AssistedInject constructor(
|
class MediaViewerPresenter @AssistedInject constructor(
|
||||||
@Assisted private val inputs: MediaViewerEntryPoint.Params,
|
@Assisted private val inputs: MediaViewerEntryPoint.Params,
|
||||||
@Assisted private val navigator: MediaViewerNavigator,
|
@Assisted private val navigator: MediaViewerNavigator,
|
||||||
|
@Assisted private val dataSource: MediaViewerDataSource,
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val localMediaFactory: LocalMediaFactory,
|
|
||||||
private val mediaLoader: MatrixMediaLoader,
|
|
||||||
private val localMediaActions: LocalMediaActions,
|
private val localMediaActions: LocalMediaActions,
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
) : Presenter<MediaViewerState> {
|
) : Presenter<MediaViewerState> {
|
||||||
|
|
@ -56,83 +51,89 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||||
fun create(
|
fun create(
|
||||||
inputs: MediaViewerEntryPoint.Params,
|
inputs: MediaViewerEntryPoint.Params,
|
||||||
navigator: MediaViewerNavigator,
|
navigator: MediaViewerNavigator,
|
||||||
|
dataSource: MediaViewerDataSource,
|
||||||
): MediaViewerPresenter
|
): MediaViewerPresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): MediaViewerState {
|
override fun present(): MediaViewerState {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var loadMediaTrigger by remember { mutableIntStateOf(0) }
|
val data by dataSource.collectAsState()
|
||||||
val mediaFile: MutableState<MediaFile?> = remember {
|
var currentIndex by remember { mutableIntStateOf(searchIndex(data, inputs.eventId)) }
|
||||||
mutableStateOf(null)
|
|
||||||
}
|
|
||||||
val localMedia: MutableState<AsyncData<LocalMedia>> = remember {
|
|
||||||
mutableStateOf(AsyncData.Uninitialized)
|
|
||||||
}
|
|
||||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||||
localMediaActions.Configure()
|
|
||||||
DisposableEffect(loadMediaTrigger) {
|
|
||||||
coroutineScope.downloadMedia(mediaFile, localMedia)
|
|
||||||
onDispose {
|
|
||||||
mediaFile.value?.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
|
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
|
||||||
|
|
||||||
fun handleEvents(mediaViewerEvents: MediaViewerEvents) {
|
DisposableEffect(Unit) {
|
||||||
when (mediaViewerEvents) {
|
dataSource.setup()
|
||||||
MediaViewerEvents.RetryLoading -> loadMediaTrigger++
|
onDispose {
|
||||||
MediaViewerEvents.ClearLoadingError -> localMedia.value = AsyncData.Uninitialized
|
dataSource.dispose()
|
||||||
MediaViewerEvents.SaveOnDisk -> {
|
}
|
||||||
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
}
|
||||||
coroutineScope.saveOnDisk(localMedia.value)
|
localMediaActions.Configure()
|
||||||
|
|
||||||
|
fun handleEvents(event: MediaViewerEvents) {
|
||||||
|
when (event) {
|
||||||
|
is MediaViewerEvents.LoadMedia -> {
|
||||||
|
coroutineScope.downloadMedia(data = event.data)
|
||||||
}
|
}
|
||||||
MediaViewerEvents.Share -> {
|
is MediaViewerEvents.ClearLoadingError -> {
|
||||||
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
dataSource.clearLoadingError(event.data)
|
||||||
coroutineScope.share(localMedia.value)
|
|
||||||
}
|
}
|
||||||
MediaViewerEvents.OpenWith -> {
|
is MediaViewerEvents.SaveOnDisk -> {
|
||||||
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
||||||
coroutineScope.open(localMedia.value)
|
coroutineScope.saveOnDisk(event.data.downloadedMedia.value)
|
||||||
|
}
|
||||||
|
is MediaViewerEvents.Share -> {
|
||||||
|
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
||||||
|
coroutineScope.share(event.data.downloadedMedia.value)
|
||||||
|
}
|
||||||
|
is MediaViewerEvents.OpenWith -> {
|
||||||
|
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
||||||
|
coroutineScope.open(event.data.downloadedMedia.value)
|
||||||
}
|
}
|
||||||
is MediaViewerEvents.Delete -> {
|
is MediaViewerEvents.Delete -> {
|
||||||
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
||||||
coroutineScope.delete(mediaViewerEvents.eventId)
|
coroutineScope.delete(event.eventId)
|
||||||
}
|
}
|
||||||
is MediaViewerEvents.ViewInTimeline -> {
|
is MediaViewerEvents.ViewInTimeline -> {
|
||||||
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
||||||
navigator.onViewInTimelineClick(mediaViewerEvents.eventId)
|
navigator.onViewInTimelineClick(event.eventId)
|
||||||
}
|
}
|
||||||
MediaViewerEvents.OpenInfo -> coroutineScope.launch {
|
is MediaViewerEvents.OpenInfo -> coroutineScope.launch {
|
||||||
mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState(
|
mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState(
|
||||||
eventId = inputs.eventId,
|
eventId = event.data.eventId,
|
||||||
canDelete = when (inputs.mediaInfo.senderId) {
|
canDelete = when (event.data.mediaInfo.senderId) {
|
||||||
null -> false
|
null -> false
|
||||||
room.sessionId -> room.canRedactOwn().getOrElse { false } && inputs.eventId != null
|
room.sessionId -> room.canRedactOwn().getOrElse { false } && event.data.eventId != null
|
||||||
else -> room.canRedactOther().getOrElse { false } && inputs.eventId != null
|
else -> room.canRedactOther().getOrElse { false } && event.data.eventId != null
|
||||||
},
|
},
|
||||||
mediaInfo = inputs.mediaInfo,
|
mediaInfo = event.data.mediaInfo,
|
||||||
thumbnailSource = inputs.thumbnailSource,
|
thumbnailSource = event.data.thumbnailSource,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is MediaViewerEvents.ConfirmDelete -> {
|
is MediaViewerEvents.ConfirmDelete -> {
|
||||||
mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState(
|
mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState(
|
||||||
eventId = mediaViewerEvents.eventId,
|
eventId = event.eventId,
|
||||||
mediaInfo = inputs.mediaInfo,
|
mediaInfo = event.data.mediaInfo,
|
||||||
thumbnailSource = inputs.thumbnailSource ?: inputs.mediaSource,
|
thumbnailSource = event.data.thumbnailSource ?: event.data.mediaSource,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MediaViewerEvents.CloseBottomSheet -> {
|
MediaViewerEvents.CloseBottomSheet -> {
|
||||||
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
||||||
}
|
}
|
||||||
|
is MediaViewerEvents.OnNavigateTo -> {
|
||||||
|
currentIndex = event.index
|
||||||
|
}
|
||||||
|
is MediaViewerEvents.LoadMore -> coroutineScope.launch {
|
||||||
|
dataSource.loadMore(event.direction)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return MediaViewerState(
|
return MediaViewerState(
|
||||||
eventId = inputs.eventId,
|
listData = data,
|
||||||
mediaInfo = inputs.mediaInfo,
|
currentIndex = currentIndex,
|
||||||
thumbnailSource = inputs.thumbnailSource,
|
|
||||||
downloadedMedia = localMedia.value,
|
|
||||||
snackbarMessage = snackbarMessage,
|
snackbarMessage = snackbarMessage,
|
||||||
canShowInfo = inputs.canShowInfo,
|
canShowInfo = inputs.canShowInfo,
|
||||||
mediaBottomSheetState = mediaBottomSheetState,
|
mediaBottomSheetState = mediaBottomSheetState,
|
||||||
|
|
@ -140,28 +141,10 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<AsyncData<LocalMedia>>) = launch {
|
private fun CoroutineScope.downloadMedia(
|
||||||
localMedia.value = AsyncData.Loading()
|
data: MediaViewerPageData.MediaViewerData,
|
||||||
mediaLoader.downloadMediaFile(
|
) = launch {
|
||||||
source = inputs.mediaSource,
|
dataSource.loadMedia(data)
|
||||||
mimeType = inputs.mediaInfo.mimeType,
|
|
||||||
filename = inputs.mediaInfo.filename
|
|
||||||
)
|
|
||||||
.onSuccess {
|
|
||||||
mediaFile.value = it
|
|
||||||
}
|
|
||||||
.mapCatching { mediaFile ->
|
|
||||||
localMediaFactory.createFromMediaFile(
|
|
||||||
mediaFile = mediaFile,
|
|
||||||
mediaInfo = inputs.mediaInfo
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onSuccess {
|
|
||||||
localMedia.value = AsyncData.Success(it)
|
|
||||||
}
|
|
||||||
.onFailure {
|
|
||||||
localMedia.value = AsyncData.Failure(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.saveOnDisk(localMedia: AsyncData<LocalMedia>) = launch {
|
private fun CoroutineScope.saveOnDisk(localMedia: AsyncData<LocalMedia>) = launch {
|
||||||
|
|
@ -216,4 +199,13 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||||
CommonStrings.error_unknown
|
CommonStrings.error_unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun searchIndex(data: List<MediaViewerPageData>, eventId: EventId?): Int {
|
||||||
|
if (eventId == null) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return data.indexOfFirst {
|
||||||
|
(it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId
|
||||||
|
}.coerceAtLeast(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,21 +7,41 @@
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.impl.viewer
|
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||||
|
|
||||||
|
import androidx.compose.runtime.State
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
data class MediaViewerState(
|
data class MediaViewerState(
|
||||||
val eventId: EventId?,
|
val listData: ImmutableList<MediaViewerPageData>,
|
||||||
val mediaInfo: MediaInfo,
|
val currentIndex: Int,
|
||||||
val thumbnailSource: MediaSource?,
|
|
||||||
val downloadedMedia: AsyncData<LocalMedia>,
|
|
||||||
val snackbarMessage: SnackbarMessage?,
|
val snackbarMessage: SnackbarMessage?,
|
||||||
val canShowInfo: Boolean,
|
val canShowInfo: Boolean,
|
||||||
val mediaBottomSheetState: MediaBottomSheetState,
|
val mediaBottomSheetState: MediaBottomSheetState,
|
||||||
val eventSink: (MediaViewerEvents) -> Unit,
|
val eventSink: (MediaViewerEvents) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sealed interface MediaViewerPageData {
|
||||||
|
data class Failure(
|
||||||
|
val throwable: Throwable,
|
||||||
|
) : MediaViewerPageData
|
||||||
|
|
||||||
|
data class Loading(
|
||||||
|
val direction: Timeline.PaginationDirection,
|
||||||
|
val timestamp: Long,
|
||||||
|
) : MediaViewerPageData
|
||||||
|
|
||||||
|
data class MediaViewerData(
|
||||||
|
val eventId: EventId?,
|
||||||
|
val mediaInfo: MediaInfo,
|
||||||
|
val mediaSource: MediaSource,
|
||||||
|
val thumbnailSource: MediaSource?,
|
||||||
|
val downloadedMedia: State<AsyncData<LocalMedia>>,
|
||||||
|
) : MediaViewerPageData
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,12 @@
|
||||||
package io.element.android.libraries.mediaviewer.impl.viewer
|
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.components.media.aWaveForm
|
import io.element.android.libraries.designsystem.components.media.aWaveForm
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
||||||
|
|
@ -21,23 +24,28 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirmationState
|
import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirmationState
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
|
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
|
||||||
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
|
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
|
||||||
override val values: Sequence<MediaViewerState>
|
override val values: Sequence<MediaViewerState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aMediaViewerState(),
|
aMediaViewerState(),
|
||||||
aMediaViewerState(AsyncData.Loading()),
|
aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Loading()))),
|
||||||
aMediaViewerState(AsyncData.Failure(IllegalStateException("error"))),
|
aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Failure(IllegalStateException("error"))))),
|
||||||
anImageMediaInfo(
|
anImageMediaInfo(
|
||||||
senderName = "Sally Sanderson",
|
senderName = "Sally Sanderson",
|
||||||
dateSent = "21 NOV, 2024",
|
dateSent = "21 NOV, 2024",
|
||||||
caption = "A caption",
|
caption = "A caption",
|
||||||
).let {
|
).let {
|
||||||
aMediaViewerState(
|
aMediaViewerState(
|
||||||
downloadedMedia = AsyncData.Success(
|
listOf(
|
||||||
LocalMedia(Uri.EMPTY, it)
|
aMediaViewerPageData(
|
||||||
),
|
downloadedMedia = AsyncData.Success(
|
||||||
mediaInfo = it,
|
LocalMedia(Uri.EMPTY, it)
|
||||||
|
),
|
||||||
|
mediaInfo = it,
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
aVideoMediaInfo(
|
aVideoMediaInfo(
|
||||||
|
|
@ -46,50 +54,78 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
|
||||||
caption = "A caption",
|
caption = "A caption",
|
||||||
).let {
|
).let {
|
||||||
aMediaViewerState(
|
aMediaViewerState(
|
||||||
downloadedMedia = AsyncData.Success(
|
listOf(
|
||||||
LocalMedia(Uri.EMPTY, it)
|
aMediaViewerPageData(
|
||||||
),
|
downloadedMedia = AsyncData.Success(
|
||||||
mediaInfo = it,
|
LocalMedia(Uri.EMPTY, it)
|
||||||
|
),
|
||||||
|
mediaInfo = it,
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
aPdfMediaInfo().let {
|
aPdfMediaInfo().let {
|
||||||
aMediaViewerState(
|
aMediaViewerState(
|
||||||
downloadedMedia = AsyncData.Success(
|
listOf(
|
||||||
LocalMedia(Uri.EMPTY, it)
|
aMediaViewerPageData(
|
||||||
),
|
downloadedMedia = AsyncData.Success(
|
||||||
mediaInfo = it,
|
LocalMedia(Uri.EMPTY, it)
|
||||||
|
),
|
||||||
|
mediaInfo = it,
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
aMediaViewerState(
|
aMediaViewerState(
|
||||||
downloadedMedia = AsyncData.Loading(),
|
listOf(
|
||||||
mediaInfo = anApkMediaInfo(),
|
aMediaViewerPageData(
|
||||||
|
downloadedMedia = AsyncData.Loading(),
|
||||||
|
mediaInfo = anApkMediaInfo(),
|
||||||
|
)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
anApkMediaInfo().let {
|
anApkMediaInfo().let {
|
||||||
aMediaViewerState(
|
aMediaViewerState(
|
||||||
downloadedMedia = AsyncData.Success(
|
listOf(
|
||||||
LocalMedia(Uri.EMPTY, it)
|
aMediaViewerPageData(
|
||||||
),
|
downloadedMedia = AsyncData.Success(
|
||||||
mediaInfo = it,
|
LocalMedia(Uri.EMPTY, it)
|
||||||
|
),
|
||||||
|
mediaInfo = it,
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
aMediaViewerState(
|
aMediaViewerState(
|
||||||
downloadedMedia = AsyncData.Loading(),
|
listOf(
|
||||||
mediaInfo = anAudioMediaInfo(),
|
aMediaViewerPageData(
|
||||||
|
downloadedMedia = AsyncData.Loading(),
|
||||||
|
mediaInfo = anAudioMediaInfo(),
|
||||||
|
)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
anAudioMediaInfo().let {
|
anAudioMediaInfo().let {
|
||||||
aMediaViewerState(
|
aMediaViewerState(
|
||||||
downloadedMedia = AsyncData.Success(
|
listOf(
|
||||||
LocalMedia(Uri.EMPTY, it)
|
aMediaViewerPageData(
|
||||||
),
|
downloadedMedia = AsyncData.Success(
|
||||||
mediaInfo = it,
|
LocalMedia(Uri.EMPTY, it)
|
||||||
|
),
|
||||||
|
mediaInfo = it,
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
anImageMediaInfo().let {
|
anImageMediaInfo().let {
|
||||||
aMediaViewerState(
|
aMediaViewerState(
|
||||||
downloadedMedia = AsyncData.Success(
|
listOf(
|
||||||
LocalMedia(Uri.EMPTY, it)
|
aMediaViewerPageData(
|
||||||
|
downloadedMedia = AsyncData.Success(
|
||||||
|
LocalMedia(Uri.EMPTY, it)
|
||||||
|
),
|
||||||
|
mediaInfo = it,
|
||||||
|
)
|
||||||
),
|
),
|
||||||
mediaInfo = it,
|
|
||||||
canShowInfo = false,
|
canShowInfo = false,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -103,26 +139,60 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
|
||||||
waveForm = aWaveForm(),
|
waveForm = aWaveForm(),
|
||||||
).let {
|
).let {
|
||||||
aMediaViewerState(
|
aMediaViewerState(
|
||||||
downloadedMedia = AsyncData.Success(
|
listOf(
|
||||||
LocalMedia(Uri.EMPTY, it)
|
aMediaViewerPageData(
|
||||||
),
|
downloadedMedia = AsyncData.Success(
|
||||||
mediaInfo = it,
|
LocalMedia(Uri.EMPTY, it)
|
||||||
|
),
|
||||||
|
mediaInfo = it,
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
aMediaViewerState(
|
||||||
|
listOf(
|
||||||
|
aMediaViewerPageDataLoading()
|
||||||
|
),
|
||||||
|
),
|
||||||
|
aMediaViewerState(
|
||||||
|
listOf(
|
||||||
|
MediaViewerPageData.Failure(Exception("error"))
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aMediaViewerState(
|
fun aMediaViewerPageDataLoading(
|
||||||
|
direction: Timeline.PaginationDirection = Timeline.PaginationDirection.BACKWARDS,
|
||||||
|
timestamp: Long = 0L,
|
||||||
|
): MediaViewerPageData {
|
||||||
|
return MediaViewerPageData.Loading(
|
||||||
|
direction = direction,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aMediaViewerPageData(
|
||||||
downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
|
downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
|
||||||
mediaInfo: MediaInfo = anImageMediaInfo(),
|
mediaInfo: MediaInfo = anImageMediaInfo(),
|
||||||
|
mediaSource: MediaSource = MediaSource(""),
|
||||||
|
): MediaViewerPageData.MediaViewerData = MediaViewerPageData.MediaViewerData(
|
||||||
|
eventId = null,
|
||||||
|
mediaInfo = mediaInfo,
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
thumbnailSource = null,
|
||||||
|
downloadedMedia = mutableStateOf(downloadedMedia),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aMediaViewerState(
|
||||||
|
listData: List<MediaViewerPageData> = listOf(aMediaViewerPageData()),
|
||||||
|
currentIndex: Int = 0,
|
||||||
canShowInfo: Boolean = true,
|
canShowInfo: Boolean = true,
|
||||||
mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
|
mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
|
||||||
eventSink: (MediaViewerEvents) -> Unit = {},
|
eventSink: (MediaViewerEvents) -> Unit = {},
|
||||||
) = MediaViewerState(
|
) = MediaViewerState(
|
||||||
eventId = null,
|
listData = listData.toPersistentList(),
|
||||||
mediaInfo = mediaInfo,
|
currentIndex = currentIndex,
|
||||||
thumbnailSource = null,
|
|
||||||
downloadedMedia = downloadedMedia,
|
|
||||||
snackbarMessage = null,
|
snackbarMessage = null,
|
||||||
canShowInfo = canShowInfo,
|
canShowInfo = canShowInfo,
|
||||||
mediaBottomSheetState = mediaBottomSheetState,
|
mediaBottomSheetState = mediaBottomSheetState,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ package io.element.android.libraries.mediaviewer.impl.viewer
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
@ -22,6 +21,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
|
@ -35,6 +36,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
@ -52,6 +54,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncLoading
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
|
@ -74,14 +78,12 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaDetailsBottomS
|
||||||
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView
|
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView
|
||||||
import io.element.android.libraries.mediaviewer.impl.local.PlayableState
|
import io.element.android.libraries.mediaviewer.impl.local.PlayableState
|
||||||
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
|
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import me.saket.telephoto.flick.FlickToDismiss
|
|
||||||
import me.saket.telephoto.flick.FlickToDismissState
|
|
||||||
import me.saket.telephoto.flick.rememberFlickToDismissState
|
|
||||||
import me.saket.telephoto.zoomable.ZoomSpec
|
import me.saket.telephoto.zoomable.ZoomSpec
|
||||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||||
import kotlin.time.Duration
|
import timber.log.Timber
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaViewerView(
|
fun MediaViewerView(
|
||||||
|
|
@ -93,51 +95,129 @@ fun MediaViewerView(
|
||||||
var showOverlay by remember { mutableStateOf(true) }
|
var showOverlay by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0
|
val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0
|
||||||
var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) }
|
val currentData = state.listData.getOrNull(state.currentIndex)
|
||||||
BackHandler { onBackClick() }
|
BackHandler { onBackClick() }
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier,
|
modifier,
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) {
|
) {
|
||||||
MediaViewerPage(
|
val pagerState = rememberPagerState(state.currentIndex, 0f) {
|
||||||
showOverlay = showOverlay,
|
state.listData.size
|
||||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
}
|
||||||
state = state,
|
LaunchedEffect(pagerState) {
|
||||||
onDismiss = {
|
snapshotFlow { pagerState.currentPage }.collect { page ->
|
||||||
onBackClick()
|
state.eventSink(MediaViewerEvents.OnNavigateTo(page))
|
||||||
},
|
|
||||||
onShowOverlayChange = {
|
|
||||||
showOverlay = it
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
LaunchedEffect(state.listData) {
|
||||||
|
Timber.d("MediaViewerView: state.listData: ${state.listData}")
|
||||||
|
}
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier,
|
||||||
|
// Pre-load previous and next pages
|
||||||
|
beyondViewportPageCount = 1,
|
||||||
|
) { page ->
|
||||||
|
when (val dataForPage = state.listData[page]) {
|
||||||
|
is MediaViewerPageData.Failure -> {
|
||||||
|
MediaViewerErrorPage(
|
||||||
|
throwable = dataForPage.throwable,
|
||||||
|
onDismiss = onBackClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is MediaViewerPageData.Loading -> {
|
||||||
|
LaunchedEffect(dataForPage.timestamp) {
|
||||||
|
state.eventSink(MediaViewerEvents.LoadMore(dataForPage.direction))
|
||||||
|
}
|
||||||
|
MediaViewerLoadingPage(
|
||||||
|
onDismiss = onBackClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is MediaViewerPageData.MediaViewerData -> {
|
||||||
|
var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
state.eventSink(MediaViewerEvents.LoadMedia(dataForPage))
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
MediaViewerPage(
|
||||||
|
isDisplayed = page == pagerState.settledPage,
|
||||||
|
showOverlay = showOverlay,
|
||||||
|
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||||
|
data = dataForPage,
|
||||||
|
onDismiss = onBackClick,
|
||||||
|
onRetry = {
|
||||||
|
state.eventSink(MediaViewerEvents.LoadMedia(dataForPage))
|
||||||
|
},
|
||||||
|
onDismissError = {
|
||||||
|
state.eventSink(MediaViewerEvents.ClearLoadingError(dataForPage))
|
||||||
|
},
|
||||||
|
onShowOverlayChange = {
|
||||||
|
showOverlay = it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Bottom bar
|
||||||
|
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
) {
|
||||||
|
MediaViewerBottomBar(
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
|
showDivider = dataForPage.mediaInfo.mimeType.isMimeTypeVideo(),
|
||||||
|
caption = dataForPage.mediaInfo.caption,
|
||||||
|
onHeightChange = { bottomPaddingInPixels = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Top bar
|
||||||
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
|
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
) {
|
) {
|
||||||
MediaViewerTopBar(
|
when (currentData) {
|
||||||
actionsEnabled = state.downloadedMedia is AsyncData.Success,
|
is MediaViewerPageData.MediaViewerData -> {
|
||||||
mimeType = state.mediaInfo.mimeType,
|
MediaViewerTopBar(
|
||||||
senderName = state.mediaInfo.senderName,
|
data = currentData,
|
||||||
dateSent = state.mediaInfo.dateSent,
|
canShowInfo = state.canShowInfo,
|
||||||
canShowInfo = state.canShowInfo,
|
onBackClick = onBackClick,
|
||||||
onBackClick = onBackClick,
|
onInfoClick = {
|
||||||
onInfoClick = {
|
state.eventSink(MediaViewerEvents.OpenInfo(currentData))
|
||||||
state.eventSink(MediaViewerEvents.OpenInfo)
|
},
|
||||||
},
|
eventSink = state.eventSink
|
||||||
eventSink = state.eventSink
|
)
|
||||||
)
|
}
|
||||||
MediaViewerBottomBar(
|
else -> {
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
TopAppBar(
|
||||||
showDivider = state.mediaInfo.mimeType.isMimeTypeVideo(),
|
title = {
|
||||||
caption = state.mediaInfo.caption,
|
if (currentData is MediaViewerPageData.Loading) {
|
||||||
onHeightChange = { bottomPaddingInPixels = it },
|
Text(
|
||||||
)
|
text = stringResource(id = CommonStrings.common_loading_more),
|
||||||
|
style = ElementTheme.typography.fontBodyMdMedium,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = bgCanvasWithTransparency,
|
||||||
|
),
|
||||||
|
navigationIcon = { BackButton(onClick = onBackClick) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (val bottomSheetState = state.mediaBottomSheetState) {
|
when (val bottomSheetState = state.mediaBottomSheetState) {
|
||||||
MediaBottomSheetState.Hidden -> Unit
|
MediaBottomSheetState.Hidden -> Unit
|
||||||
is MediaBottomSheetState.MediaDetailsBottomSheetState -> {
|
is MediaBottomSheetState.MediaDetailsBottomSheetState -> {
|
||||||
|
|
@ -147,13 +227,24 @@ fun MediaViewerView(
|
||||||
state.eventSink(MediaViewerEvents.ViewInTimeline(it))
|
state.eventSink(MediaViewerEvents.ViewInTimeline(it))
|
||||||
},
|
},
|
||||||
onShare = {
|
onShare = {
|
||||||
state.eventSink(MediaViewerEvents.Share)
|
(currentData as? MediaViewerPageData.MediaViewerData)?.let {
|
||||||
|
state.eventSink(MediaViewerEvents.Share(currentData))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDownload = {
|
onDownload = {
|
||||||
state.eventSink(MediaViewerEvents.SaveOnDisk)
|
(currentData as? MediaViewerPageData.MediaViewerData)?.let {
|
||||||
|
state.eventSink(MediaViewerEvents.SaveOnDisk(currentData))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDelete = { eventId ->
|
onDelete = { eventId ->
|
||||||
state.eventSink(MediaViewerEvents.ConfirmDelete(eventId))
|
(currentData as? MediaViewerPageData.MediaViewerData)?.let {
|
||||||
|
state.eventSink(
|
||||||
|
MediaViewerEvents.ConfirmDelete(
|
||||||
|
eventId,
|
||||||
|
currentData,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
state.eventSink(MediaViewerEvents.CloseBottomSheet)
|
state.eventSink(MediaViewerEvents.CloseBottomSheet)
|
||||||
|
|
@ -176,41 +267,31 @@ fun MediaViewerView(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MediaViewerPage(
|
private fun MediaViewerPage(
|
||||||
|
isDisplayed: Boolean,
|
||||||
showOverlay: Boolean,
|
showOverlay: Boolean,
|
||||||
bottomPaddingInPixels: Int,
|
bottomPaddingInPixels: Int,
|
||||||
state: MediaViewerState,
|
data: MediaViewerPageData.MediaViewerData,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onDismissError: () -> Unit,
|
||||||
onShowOverlayChange: (Boolean) -> Unit,
|
onShowOverlayChange: (Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
fun onRetry() {
|
|
||||||
state.eventSink(MediaViewerEvents.RetryLoading)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onDismissError() {
|
|
||||||
state.eventSink(MediaViewerEvents.ClearLoadingError)
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentShowOverlay by rememberUpdatedState(showOverlay)
|
val currentShowOverlay by rememberUpdatedState(showOverlay)
|
||||||
val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange)
|
val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange)
|
||||||
val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false)
|
|
||||||
|
|
||||||
DismissFlickEffects(
|
MediaViewerFlickToDismiss(
|
||||||
flickState = flickState,
|
onDismiss = onDismiss,
|
||||||
onDismissing = { animationDuration ->
|
|
||||||
delay(animationDuration / 3)
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
onDragging = {
|
onDragging = {
|
||||||
currentOnShowOverlayChange(false)
|
currentOnShowOverlayChange(false)
|
||||||
}
|
},
|
||||||
)
|
onResetting = {
|
||||||
|
currentOnShowOverlayChange(true)
|
||||||
FlickToDismiss(
|
},
|
||||||
state = flickState,
|
modifier = modifier,
|
||||||
modifier = modifier.background(backgroundColorFor(flickState))
|
|
||||||
) {
|
) {
|
||||||
val showProgress = rememberShowProgress(state.downloadedMedia)
|
val downloadedMedia by data.downloadedMedia
|
||||||
|
val showProgress = rememberShowProgress(downloadedMedia)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -224,7 +305,7 @@ private fun MediaViewerPage(
|
||||||
val localMediaViewState = rememberLocalMediaViewState(zoomableState)
|
val localMediaViewState = rememberLocalMediaViewState(zoomableState)
|
||||||
val showThumbnail = !localMediaViewState.isReady
|
val showThumbnail = !localMediaViewState.isReady
|
||||||
val playableState = localMediaViewState.playableState
|
val playableState = localMediaViewState.playableState
|
||||||
val showError = state.downloadedMedia is AsyncData.Failure
|
val showError = downloadedMedia.isFailure()
|
||||||
|
|
||||||
LaunchedEffect(playableState) {
|
LaunchedEffect(playableState) {
|
||||||
if (playableState is PlayableState.Playable) {
|
if (playableState is PlayableState.Playable) {
|
||||||
|
|
@ -234,10 +315,11 @@ private fun MediaViewerPage(
|
||||||
|
|
||||||
LocalMediaView(
|
LocalMediaView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
isDisplayed = isDisplayed,
|
||||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||||
localMediaViewState = localMediaViewState,
|
localMediaViewState = localMediaViewState,
|
||||||
localMedia = state.downloadedMedia.dataOrNull(),
|
localMedia = downloadedMedia.dataOrNull(),
|
||||||
mediaInfo = state.mediaInfo,
|
mediaInfo = data.mediaInfo,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (playableState is PlayableState.NotPlayable) {
|
if (playableState is PlayableState.NotPlayable) {
|
||||||
currentOnShowOverlayChange(!currentShowOverlay)
|
currentOnShowOverlayChange(!currentShowOverlay)
|
||||||
|
|
@ -245,15 +327,15 @@ private fun MediaViewerPage(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
ThumbnailView(
|
ThumbnailView(
|
||||||
mediaInfo = state.mediaInfo,
|
mediaInfo = data.mediaInfo,
|
||||||
thumbnailSource = state.thumbnailSource,
|
thumbnailSource = data.thumbnailSource,
|
||||||
isVisible = showThumbnail,
|
isVisible = showThumbnail,
|
||||||
)
|
)
|
||||||
if (showError) {
|
if (showError) {
|
||||||
ErrorView(
|
ErrorView(
|
||||||
errorMessage = stringResource(id = CommonStrings.error_unknown),
|
errorMessage = stringResource(id = CommonStrings.error_unknown),
|
||||||
onRetry = ::onRetry,
|
onRetry = onRetry,
|
||||||
onDismiss = ::onDismissError
|
onDismiss = onDismissError
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -269,26 +351,46 @@ private fun MediaViewerPage(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DismissFlickEffects(
|
private fun MediaViewerLoadingPage(
|
||||||
flickState: FlickToDismissState,
|
onDismiss: () -> Unit,
|
||||||
onDismissing: suspend (Duration) -> Unit,
|
modifier: Modifier = Modifier,
|
||||||
onDragging: suspend () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val currentOnDismissing by rememberUpdatedState(onDismissing)
|
MediaViewerFlickToDismiss(
|
||||||
val currentOnDragging by rememberUpdatedState(onDragging)
|
onDismiss = onDismiss,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
AsyncLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
when (val gestureState = flickState.gestureState) {
|
@Composable
|
||||||
is FlickToDismissState.GestureState.Dismissing -> {
|
private fun MediaViewerErrorPage(
|
||||||
LaunchedEffect(Unit) {
|
throwable: Throwable,
|
||||||
currentOnDismissing(gestureState.animationDuration)
|
onDismiss: () -> Unit,
|
||||||
}
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
MediaViewerFlickToDismiss(
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
AsyncFailure(
|
||||||
|
throwable = throwable,
|
||||||
|
onRetry = null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
is FlickToDismissState.GestureState.Dragging -> {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
currentOnDragging()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,15 +418,17 @@ private fun rememberShowProgress(downloadedMedia: AsyncData<LocalMedia>): Boolea
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun MediaViewerTopBar(
|
private fun MediaViewerTopBar(
|
||||||
actionsEnabled: Boolean,
|
data: MediaViewerPageData.MediaViewerData,
|
||||||
mimeType: String,
|
|
||||||
senderName: String?,
|
|
||||||
dateSent: String?,
|
|
||||||
canShowInfo: Boolean,
|
canShowInfo: Boolean,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onInfoClick: () -> Unit,
|
onInfoClick: () -> Unit,
|
||||||
eventSink: (MediaViewerEvents) -> Unit,
|
eventSink: (MediaViewerEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val downloadedMedia by data.downloadedMedia
|
||||||
|
val actionsEnabled = downloadedMedia.isSuccess()
|
||||||
|
val mimeType = data.mediaInfo.mimeType
|
||||||
|
val senderName = data.mediaInfo.senderName
|
||||||
|
val dateSent = data.mediaInfo.dateSent
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
if (senderName != null && dateSent != null) {
|
if (senderName != null && dateSent != null) {
|
||||||
|
|
@ -350,14 +454,14 @@ private fun MediaViewerTopBar(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = Color.Transparent.copy(0.6f),
|
containerColor = bgCanvasWithTransparency,
|
||||||
),
|
),
|
||||||
navigationIcon = { BackButton(onClick = onBackClick) },
|
navigationIcon = { BackButton(onClick = onBackClick) },
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(
|
IconButton(
|
||||||
enabled = actionsEnabled,
|
enabled = actionsEnabled,
|
||||||
onClick = {
|
onClick = {
|
||||||
eventSink(MediaViewerEvents.OpenWith)
|
eventSink(MediaViewerEvents.OpenWith(data))
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
when (mimeType) {
|
when (mimeType) {
|
||||||
|
|
@ -378,7 +482,7 @@ private fun MediaViewerTopBar(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = CompoundIcons.Info(),
|
imageVector = CompoundIcons.Info(),
|
||||||
contentDescription = null,
|
contentDescription = stringResource(id = CommonStrings.a11y_view_details),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -396,7 +500,7 @@ private fun MediaViewerBottomBar(
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(Color(0x99101317))
|
.background(bgCanvasWithTransparency)
|
||||||
.onSizeChanged {
|
.onSizeChanged {
|
||||||
onHeightChange(it.height)
|
onHeightChange(it.height)
|
||||||
},
|
},
|
||||||
|
|
@ -457,21 +561,6 @@ private fun ErrorView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun backgroundColorFor(flickState: FlickToDismissState): Color {
|
|
||||||
val animatedAlpha by animateFloatAsState(
|
|
||||||
targetValue = when (flickState.gestureState) {
|
|
||||||
is FlickToDismissState.GestureState.Dismissed,
|
|
||||||
is FlickToDismissState.GestureState.Dismissing -> 0f
|
|
||||||
is FlickToDismissState.GestureState.Dragging,
|
|
||||||
is FlickToDismissState.GestureState.Idle,
|
|
||||||
is FlickToDismissState.GestureState.Resetting -> 1f - flickState.offsetFraction
|
|
||||||
},
|
|
||||||
label = "Background alpha",
|
|
||||||
)
|
|
||||||
return Color.Black.copy(alpha = animatedAlpha)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only preview in dark, dark theme is forced on the Node.
|
// Only preview in dark, dark theme is forced on the Node.
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
<string name="screen_media_browser_title">"Media and files"</string>
|
<string name="screen_media_browser_title">"Media and files"</string>
|
||||||
<string name="screen_media_details_file_format">"File format"</string>
|
<string name="screen_media_details_file_format">"File format"</string>
|
||||||
<string name="screen_media_details_filename">"File name"</string>
|
<string name="screen_media_details_filename">"File name"</string>
|
||||||
|
<string name="screen_media_details_no_more_files_to_show">"No more files to show"</string>
|
||||||
|
<string name="screen_media_details_no_more_media_to_show">"No more media to show"</string>
|
||||||
<string name="screen_media_details_redact_confirmation_message">"This file will be removed from the room and members won’t have access to it."</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_redact_confirmation_title">"Delete file?"</string>
|
||||||
<string name="screen_media_details_uploaded_by">"Uploaded by"</string>
|
<string name="screen_media_details_uploaded_by">"Uploaded by"</string>
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,7 @@ class DefaultEventItemFactoryTest {
|
||||||
dateSent = "0 Day false",
|
dateSent = "0 Day false",
|
||||||
dateSentFull = "0 Full false",
|
dateSentFull = "0 Full false",
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
)
|
)
|
||||||
|
|
@ -214,6 +215,7 @@ class DefaultEventItemFactoryTest {
|
||||||
dateSent = "0 Day false",
|
dateSent = "0 Day false",
|
||||||
dateSentFull = "0 Full false",
|
dateSentFull = "0 Full false",
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
|
|
@ -260,6 +262,7 @@ class DefaultEventItemFactoryTest {
|
||||||
dateSent = "0 Day false",
|
dateSent = "0 Day false",
|
||||||
dateSentFull = "0 Full false",
|
dateSentFull = "0 Full false",
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
)
|
)
|
||||||
|
|
@ -310,10 +313,10 @@ class DefaultEventItemFactoryTest {
|
||||||
dateSent = "0 Day false",
|
dateSent = "0 Day false",
|
||||||
dateSentFull = "0 Full false",
|
dateSentFull = "0 Full false",
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = "2:03",
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
duration = "2:03",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -361,10 +364,9 @@ class DefaultEventItemFactoryTest {
|
||||||
dateSent = "0 Day false",
|
dateSent = "0 Day false",
|
||||||
dateSentFull = "0 Full false",
|
dateSentFull = "0 Full false",
|
||||||
waveform = listOf(1f, 2f).toImmutableList(),
|
waveform = listOf(1f, 2f).toImmutableList(),
|
||||||
|
duration = "7:36",
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
duration = "7:36",
|
|
||||||
waveform = listOf(1f, 2f).toImmutableList(),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -412,6 +414,7 @@ class DefaultEventItemFactoryTest {
|
||||||
dateSent = "0 Day false",
|
dateSent = "0 Day false",
|
||||||
dateSentFull = "0 Full false",
|
dateSentFull = "0 Full false",
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
|
|
|
||||||
|
|
@ -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
|
package io.element.android.libraries.mediaviewer.impl.gallery
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import app.cash.turbine.ReceiveTurbine
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
|
@ -25,15 +25,11 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetSta
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
|
||||||
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
|
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
|
||||||
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
|
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
|
||||||
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
|
||||||
import kotlinx.coroutines.test.TestScope
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -47,49 +43,37 @@ class MediaGalleryPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
|
val startLambda = lambdaRecorder<Unit> { }
|
||||||
val navigator = FakeMediaGalleryNavigator(
|
|
||||||
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
|
|
||||||
)
|
|
||||||
val presenter = createMediaGalleryPresenter(
|
val presenter = createMediaGalleryPresenter(
|
||||||
navigator = navigator,
|
mediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
|
startLambda = startLambda,
|
||||||
|
),
|
||||||
room = FakeMatrixRoom(
|
room = FakeMatrixRoom(
|
||||||
displayName = A_ROOM_NAME,
|
displayName = A_ROOM_NAME,
|
||||||
mediaTimelineResult = { Result.success(FakeTimeline()) },
|
mediaTimelineResult = { Result.success(FakeTimeline()) },
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(2)
|
val initialState = awaitFirstItem()
|
||||||
val initialState = awaitItem()
|
|
||||||
assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images)
|
assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images)
|
||||||
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
||||||
assertThat(initialState.roomName).isEqualTo(A_ROOM_NAME)
|
assertThat(initialState.roomName).isEqualTo(A_ROOM_NAME)
|
||||||
assertThat(initialState.groupedMediaItems.dataOrNull()).isEqualTo(
|
assertThat(initialState.groupedMediaItems.isUninitialized()).isTrue()
|
||||||
GroupedMediaItems(
|
|
||||||
imageAndVideoItems = persistentListOf(),
|
|
||||||
fileItems = persistentListOf(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assertThat(initialState.snackbarMessage).isNull()
|
assertThat(initialState.snackbarMessage).isNull()
|
||||||
}
|
}
|
||||||
|
startLambda.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - change mode`() = runTest {
|
fun `present - change mode`() = runTest {
|
||||||
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
|
|
||||||
val navigator = FakeMediaGalleryNavigator(
|
|
||||||
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
|
|
||||||
)
|
|
||||||
val presenter = createMediaGalleryPresenter(
|
val presenter = createMediaGalleryPresenter(
|
||||||
navigator = navigator,
|
|
||||||
room = FakeMatrixRoom(
|
room = FakeMatrixRoom(
|
||||||
displayName = A_ROOM_NAME,
|
displayName = A_ROOM_NAME,
|
||||||
mediaTimelineResult = { Result.success(FakeTimeline()) },
|
mediaTimelineResult = { Result.success(FakeTimeline()) },
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(2)
|
val initialState = awaitFirstItem()
|
||||||
val initialState = awaitItem()
|
|
||||||
assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images)
|
assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images)
|
||||||
initialState.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Files))
|
initialState.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Files))
|
||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
|
|
@ -110,7 +94,7 @@ class MediaGalleryPresenterTest {
|
||||||
`present - bottom sheet state - own message`(canDeleteOwn = false)
|
`present - bottom sheet state - own message`(canDeleteOwn = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun TestScope.`present - bottom sheet state - own message`(canDeleteOwn: Boolean) {
|
private suspend fun `present - bottom sheet state - own message`(canDeleteOwn: Boolean) {
|
||||||
val presenter = createMediaGalleryPresenter(
|
val presenter = createMediaGalleryPresenter(
|
||||||
room = FakeMatrixRoom(
|
room = FakeMatrixRoom(
|
||||||
sessionId = A_USER_ID,
|
sessionId = A_USER_ID,
|
||||||
|
|
@ -120,8 +104,7 @@ class MediaGalleryPresenterTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(2)
|
val initialState = awaitFirstItem()
|
||||||
val initialState = awaitItem()
|
|
||||||
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
||||||
val item = aMediaItemImage(
|
val item = aMediaItemImage(
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
|
|
@ -154,7 +137,7 @@ class MediaGalleryPresenterTest {
|
||||||
`present - bottom sheet state - other message`(canDeleteOther = false)
|
`present - bottom sheet state - other message`(canDeleteOther = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun TestScope.`present - bottom sheet state - other message`(canDeleteOther: Boolean) {
|
private suspend fun `present - bottom sheet state - other message`(canDeleteOther: Boolean) {
|
||||||
val presenter = createMediaGalleryPresenter(
|
val presenter = createMediaGalleryPresenter(
|
||||||
room = FakeMatrixRoom(
|
room = FakeMatrixRoom(
|
||||||
sessionId = A_USER_ID,
|
sessionId = A_USER_ID,
|
||||||
|
|
@ -164,8 +147,7 @@ class MediaGalleryPresenterTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(2)
|
val initialState = awaitFirstItem()
|
||||||
val initialState = awaitItem()
|
|
||||||
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
||||||
val item = aMediaItemImage(
|
val item = aMediaItemImage(
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
|
|
@ -197,8 +179,7 @@ class MediaGalleryPresenterTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(2)
|
val initialState = awaitFirstItem()
|
||||||
val initialState = awaitItem()
|
|
||||||
// Delete bottom sheet
|
// Delete bottom sheet
|
||||||
val item = aMediaItemImage()
|
val item = aMediaItemImage()
|
||||||
initialState.eventSink(MediaGalleryEvents.ConfirmDelete(AN_EVENT_ID, item.mediaInfo, item.thumbnailSource))
|
initialState.eventSink(MediaGalleryEvents.ConfirmDelete(AN_EVENT_ID, item.mediaInfo, item.thumbnailSource))
|
||||||
|
|
@ -217,6 +198,42 @@ class MediaGalleryPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - delete item`() = runTest {
|
||||||
|
val deleteItemLambda = lambdaRecorder<EventId, Unit> { }
|
||||||
|
val presenter = createMediaGalleryPresenter(
|
||||||
|
mediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
|
startLambda = { },
|
||||||
|
deleteItemLambda = deleteItemLambda,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
initialState.eventSink(MediaGalleryEvents.Delete(AN_EVENT_ID))
|
||||||
|
deleteItemLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - share item`() = runTest {
|
||||||
|
val presenter = createMediaGalleryPresenter()
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
initialState.eventSink(MediaGalleryEvents.Share(AN_EVENT_ID))
|
||||||
|
}
|
||||||
|
// TODO Add more test on this part
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - save on disk`() = runTest {
|
||||||
|
val presenter = createMediaGalleryPresenter()
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
initialState.eventSink(MediaGalleryEvents.SaveOnDisk(AN_EVENT_ID))
|
||||||
|
}
|
||||||
|
// TODO Add more test on this part
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - view in timeline invokes the navigator`() = runTest {
|
fun `present - view in timeline invokes the navigator`() = runTest {
|
||||||
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
|
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
|
||||||
|
|
@ -230,15 +247,37 @@ class MediaGalleryPresenterTest {
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(2)
|
val initialState = awaitFirstItem()
|
||||||
val initialState = awaitItem()
|
|
||||||
initialState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID))
|
initialState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID))
|
||||||
onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
|
onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TestScope.createMediaGalleryPresenter(
|
@Test
|
||||||
|
fun `present - load more`() = runTest {
|
||||||
|
val loadMoreLambda = lambdaRecorder<Timeline.PaginationDirection, Unit> { }
|
||||||
|
val presenter = createMediaGalleryPresenter(
|
||||||
|
mediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
|
startLambda = { },
|
||||||
|
loadMoreLambda = loadMoreLambda,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
initialState.eventSink(MediaGalleryEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
|
||||||
|
loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||||
|
return awaitItem()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMediaGalleryPresenter(
|
||||||
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
|
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
|
||||||
|
mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
|
startLambda = { },
|
||||||
|
),
|
||||||
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
|
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
|
||||||
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
|
||||||
navigator: MediaGalleryNavigator = FakeMediaGalleryNavigator(),
|
navigator: MediaGalleryNavigator = FakeMediaGalleryNavigator(),
|
||||||
|
|
@ -249,22 +288,11 @@ class MediaGalleryPresenterTest {
|
||||||
return MediaGalleryPresenter(
|
return MediaGalleryPresenter(
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
room = room,
|
room = room,
|
||||||
timelineMediaItemsFactory = TimelineMediaItemsFactory(
|
mediaGalleryDataSource = mediaGalleryDataSource,
|
||||||
dispatchers = testCoroutineDispatchers(),
|
|
||||||
virtualItemFactory = VirtualItemFactory(
|
|
||||||
dateFormatter = FakeDateFormatter(),
|
|
||||||
),
|
|
||||||
eventItemFactory = EventItemFactory(
|
|
||||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
|
||||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
|
||||||
dateFormatter = FakeDateFormatter(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
localMediaFactory = localMediaFactory,
|
localMediaFactory = localMediaFactory,
|
||||||
mediaLoader = matrixMediaLoader,
|
mediaLoader = matrixMediaLoader,
|
||||||
localMediaActions = localMediaActions,
|
localMediaActions = localMediaActions,
|
||||||
snackbarDispatcher = snackbarDispatcher,
|
snackbarDispatcher = snackbarDispatcher,
|
||||||
mediaItemsPostProcessor = MediaItemsPostProcessor(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,7 @@
|
||||||
package io.element.android.libraries.mediaviewer.impl.gallery
|
package io.element.android.libraries.mediaviewer.impl.gallery
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemAudio
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemAudio
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
|
||||||
|
|
@ -42,27 +40,6 @@ class MediaItemsPostProcessorTest {
|
||||||
private val date3 = aMediaItemDateSeparator(id = UniqueId("3"))
|
private val date3 = aMediaItemDateSeparator(id = UniqueId("3"))
|
||||||
private val loading1 = aMediaItemLoadingIndicator(id = UniqueId("1"))
|
private val loading1 = aMediaItemLoadingIndicator(id = UniqueId("1"))
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `process Uninitialized`() {
|
|
||||||
val sut = MediaItemsPostProcessor()
|
|
||||||
val result = sut.process(AsyncData.Uninitialized)
|
|
||||||
assertThat(result).isEqualTo(AsyncData.Uninitialized)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `process Loading`() {
|
|
||||||
val sut = MediaItemsPostProcessor()
|
|
||||||
val result = sut.process(AsyncData.Loading())
|
|
||||||
assertThat(result).isEqualTo(AsyncData.Loading<GroupedMediaItems>())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `process Failure`() {
|
|
||||||
val sut = MediaItemsPostProcessor()
|
|
||||||
val result = sut.process(AsyncData.Failure(AN_EXCEPTION))
|
|
||||||
assertThat(result).isEqualTo(AsyncData.Failure<GroupedMediaItems>(AN_EXCEPTION))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `process Empty`() {
|
fun `process Empty`() {
|
||||||
test(
|
test(
|
||||||
|
|
@ -215,19 +192,16 @@ class MediaItemsPostProcessorTest {
|
||||||
expectedFileItems: List<MediaItem>,
|
expectedFileItems: List<MediaItem>,
|
||||||
) {
|
) {
|
||||||
val sut = MediaItemsPostProcessor()
|
val sut = MediaItemsPostProcessor()
|
||||||
val result = sut.process(AsyncData.Success(mediaItems.toImmutableList()))
|
val result = sut.process(mediaItems.toImmutableList())
|
||||||
val data = result.dataOrNull()!!
|
|
||||||
|
|
||||||
// Compare the lists to have better failure info
|
// Compare the lists to have better failure info
|
||||||
assertThat(data.imageAndVideoItems.toList()).isEqualTo(expectedImageAndVideoItems)
|
assertThat(result.imageAndVideoItems.toList()).isEqualTo(expectedImageAndVideoItems)
|
||||||
assertThat(data.fileItems.toList()).isEqualTo(expectedFileItems)
|
assertThat(result.fileItems.toList()).isEqualTo(expectedFileItems)
|
||||||
|
|
||||||
assertThat(result).isEqualTo(
|
assertThat(result).isEqualTo(
|
||||||
AsyncData.Success(
|
GroupedMediaItems(
|
||||||
GroupedMediaItems(
|
imageAndVideoItems = expectedImageAndVideoItems.toImmutableList(),
|
||||||
imageAndVideoItems = expectedImageAndVideoItems.toImmutableList(),
|
fileItems = expectedFileItems.toImmutableList(),
|
||||||
fileItems = expectedFileItems.toImmutableList(),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
dateSent = "12:34",
|
||||||
dateSentFull = "full",
|
dateSentFull = "full",
|
||||||
waveform = null,
|
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
|
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import app.cash.molecule.RecompositionMode
|
import app.cash.turbine.ReceiveTurbine
|
||||||
import app.cash.molecule.moleculeFlow
|
|
||||||
import app.cash.turbine.test
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
|
@ -30,14 +30,23 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||||
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
|
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.FakeMediaGalleryDataSource
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.GroupedMediaItems
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryDataSource
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
|
||||||
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
|
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
|
||||||
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
|
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
|
||||||
|
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -52,6 +61,7 @@ class MediaViewerPresenterTest {
|
||||||
|
|
||||||
private val mockMediaUri: Uri = mockk("localMediaUri")
|
private val mockMediaUri: Uri = mockk("localMediaUri")
|
||||||
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
|
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
|
||||||
|
private val aUrl = "aUrl"
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state null Event`() = runTest {
|
fun `present - initial state null Event`() = runTest {
|
||||||
|
|
@ -61,9 +71,9 @@ class MediaViewerPresenterTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(2)
|
val initialState = awaitFirstItem()
|
||||||
val initialState = awaitItem()
|
assertThat(initialState.listData).isEmpty()
|
||||||
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(initialState.currentIndex).isEqualTo(0)
|
||||||
assertThat(initialState.snackbarMessage).isNull()
|
assertThat(initialState.snackbarMessage).isNull()
|
||||||
assertThat(initialState.canShowInfo).isTrue()
|
assertThat(initialState.canShowInfo).isTrue()
|
||||||
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
||||||
|
|
@ -79,9 +89,9 @@ class MediaViewerPresenterTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(2)
|
val initialState = awaitFirstItem()
|
||||||
val initialState = awaitItem()
|
assertThat(initialState.listData).isEmpty()
|
||||||
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(initialState.currentIndex).isEqualTo(0)
|
||||||
assertThat(initialState.snackbarMessage).isNull()
|
assertThat(initialState.snackbarMessage).isNull()
|
||||||
assertThat(initialState.canShowInfo).isFalse()
|
assertThat(initialState.canShowInfo).isFalse()
|
||||||
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
||||||
|
|
@ -97,9 +107,9 @@ class MediaViewerPresenterTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(2)
|
val initialState = awaitFirstItem()
|
||||||
val initialState = awaitItem()
|
assertThat(initialState.listData).isEmpty()
|
||||||
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(initialState.currentIndex).isEqualTo(0)
|
||||||
assertThat(initialState.snackbarMessage).isNull()
|
assertThat(initialState.snackbarMessage).isNull()
|
||||||
assertThat(initialState.canShowInfo).isTrue()
|
assertThat(initialState.canShowInfo).isTrue()
|
||||||
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
||||||
|
|
@ -116,9 +126,9 @@ class MediaViewerPresenterTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(2)
|
val initialState = awaitFirstItem()
|
||||||
val initialState = awaitItem()
|
assertThat(initialState.listData).isEmpty()
|
||||||
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(initialState.currentIndex).isEqualTo(0)
|
||||||
assertThat(initialState.snackbarMessage).isNull()
|
assertThat(initialState.snackbarMessage).isNull()
|
||||||
assertThat(initialState.canShowInfo).isTrue()
|
assertThat(initialState.canShowInfo).isTrue()
|
||||||
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
||||||
|
|
@ -126,114 +136,280 @@ class MediaViewerPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - download media success scenario`() = runTest {
|
fun `present - data source update`() = runTest {
|
||||||
val presenter = createMediaViewerPresenter(
|
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
room = FakeMatrixRoom(
|
startLambda = { },
|
||||||
canRedactOwnResult = { Result.success(true) },
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createMediaViewerPresenter(
|
||||||
presenter.present()
|
mediaGalleryDataSource = mediaGalleryDataSource,
|
||||||
}.test {
|
)
|
||||||
var state = awaitItem()
|
val anImage = aMediaItemImage()
|
||||||
assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
|
presenter.test {
|
||||||
assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
|
val initialState = awaitFirstItem()
|
||||||
state = awaitItem()
|
assertThat(initialState.listData).isEmpty()
|
||||||
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
|
mediaGalleryDataSource.emitGroupedMediaItems(
|
||||||
state = awaitItem()
|
AsyncData.Success(
|
||||||
val successData = state.downloadedMedia.dataOrNull()
|
GroupedMediaItems(
|
||||||
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
|
imageAndVideoItems = persistentListOf(anImage),
|
||||||
assertThat(successData).isNotNull()
|
fileItems = persistentListOf(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val updatedState = awaitFirstItem()
|
||||||
|
assertThat(updatedState.listData).hasSize(1)
|
||||||
|
val item = updatedState.listData.first() as MediaViewerPageData.MediaViewerData
|
||||||
|
assertThat(item.eventId).isNull()
|
||||||
|
assertThat(item.mediaInfo).isEqualTo(anImage.mediaInfo)
|
||||||
|
assertThat(item.mediaSource).isEqualTo(anImage.mediaSource)
|
||||||
|
assertThat(item.thumbnailSource).isEqualTo(anImage.thumbnailSource)
|
||||||
|
assertThat(item.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - check all actions`() = runTest {
|
fun `present - load media`() = runTest {
|
||||||
val mediaActions = FakeLocalMediaActions()
|
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
val snackbarDispatcher = SnackbarDispatcher()
|
startLambda = { },
|
||||||
val presenter = createMediaViewerPresenter(
|
|
||||||
localMediaActions = mediaActions,
|
|
||||||
snackbarDispatcher = snackbarDispatcher,
|
|
||||||
room = FakeMatrixRoom(
|
|
||||||
canRedactOwnResult = { Result.success(true) },
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createMediaViewerPresenter(
|
||||||
presenter.present()
|
mediaGalleryDataSource = mediaGalleryDataSource,
|
||||||
}.test {
|
)
|
||||||
var state = awaitItem()
|
val anImage = aMediaItemImage(
|
||||||
assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
|
mediaSourceUrl = aUrl,
|
||||||
state = awaitItem()
|
)
|
||||||
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
|
presenter.test {
|
||||||
// no state changes while media is loading
|
awaitFirstItem()
|
||||||
state.eventSink(MediaViewerEvents.OpenWith)
|
mediaGalleryDataSource.emitGroupedMediaItems(
|
||||||
state.eventSink(MediaViewerEvents.Share)
|
AsyncData.Success(
|
||||||
state.eventSink(MediaViewerEvents.SaveOnDisk)
|
GroupedMediaItems(
|
||||||
state = awaitItem()
|
imageAndVideoItems = persistentListOf(anImage),
|
||||||
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
|
fileItems = persistentListOf(),
|
||||||
// Should succeed without change of state
|
)
|
||||||
state.eventSink(MediaViewerEvents.OpenWith)
|
)
|
||||||
// Should succeed without change of state
|
)
|
||||||
state.eventSink(MediaViewerEvents.Share)
|
val updatedState = awaitItem()
|
||||||
state.eventSink(MediaViewerEvents.SaveOnDisk)
|
updatedState.eventSink(
|
||||||
state = awaitItem()
|
MediaViewerEvents.LoadMedia(
|
||||||
assertThat(state.snackbarMessage).isNotNull()
|
aMediaViewerPageData(
|
||||||
snackbarDispatcher.clear()
|
mediaSource = MediaSource(aUrl)
|
||||||
assertThat(awaitItem().snackbarMessage).isNull()
|
)
|
||||||
|
)
|
||||||
// Check failures
|
)
|
||||||
mediaActions.shouldFail = true
|
|
||||||
state.eventSink(MediaViewerEvents.OpenWith)
|
|
||||||
state = awaitItem()
|
|
||||||
assertThat(state.snackbarMessage).isNotNull()
|
|
||||||
snackbarDispatcher.clear()
|
|
||||||
assertThat(awaitItem().snackbarMessage).isNull()
|
|
||||||
state.eventSink(MediaViewerEvents.Share)
|
|
||||||
state = awaitItem()
|
|
||||||
assertThat(state.snackbarMessage).isNotNull()
|
|
||||||
snackbarDispatcher.clear()
|
|
||||||
assertThat(awaitItem().snackbarMessage).isNull()
|
|
||||||
state.eventSink(MediaViewerEvents.SaveOnDisk)
|
|
||||||
state = awaitItem()
|
|
||||||
assertThat(state.snackbarMessage).isNotNull()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - download media failure then retry with success scenario`() = runTest {
|
fun `present - open info`() = runTest {
|
||||||
val matrixMediaLoader = FakeMatrixMediaLoader()
|
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
|
startLambda = { },
|
||||||
|
)
|
||||||
val presenter = createMediaViewerPresenter(
|
val presenter = createMediaViewerPresenter(
|
||||||
matrixMediaLoader = matrixMediaLoader,
|
mediaGalleryDataSource = mediaGalleryDataSource,
|
||||||
room = FakeMatrixRoom(
|
room = FakeMatrixRoom(
|
||||||
canRedactOwnResult = { Result.success(true) },
|
canRedactOwnResult = { Result.success(true) },
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val anImage = aMediaItemImage(
|
||||||
presenter.present()
|
mediaSourceUrl = aUrl,
|
||||||
}.test {
|
)
|
||||||
matrixMediaLoader.shouldFail = true
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
awaitFirstItem()
|
||||||
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
|
mediaGalleryDataSource.emitGroupedMediaItems(
|
||||||
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
|
AsyncData.Success(
|
||||||
val loadingState = awaitItem()
|
GroupedMediaItems(
|
||||||
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
|
imageAndVideoItems = persistentListOf(anImage),
|
||||||
val failureState = awaitItem()
|
fileItems = persistentListOf(),
|
||||||
assertThat(failureState.downloadedMedia).isInstanceOf(AsyncData.Failure::class.java)
|
)
|
||||||
matrixMediaLoader.shouldFail = false
|
)
|
||||||
failureState.eventSink(MediaViewerEvents.RetryLoading)
|
)
|
||||||
// There is one recomposition because of the retry mechanism
|
val updatedState = awaitItem()
|
||||||
skipItems(1)
|
updatedState.eventSink(
|
||||||
val retryLoadingState = awaitItem()
|
MediaViewerEvents.OpenInfo(
|
||||||
assertThat(retryLoadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
|
aMediaViewerPageData(
|
||||||
val successState = awaitItem()
|
mediaSource = MediaSource(aUrl)
|
||||||
val successData = successState.downloadedMedia.dataOrNull()
|
)
|
||||||
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
|
)
|
||||||
assertThat(successData).isNotNull()
|
)
|
||||||
|
val withInfoState = awaitItem()
|
||||||
|
assertThat(withInfoState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
|
||||||
|
withInfoState.eventSink(
|
||||||
|
MediaViewerEvents.CloseBottomSheet
|
||||||
|
)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - delete media success scenario`() = runTest {
|
fun `present - clear loading error`() = runTest {
|
||||||
|
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
|
startLambda = { },
|
||||||
|
)
|
||||||
|
val presenter = createMediaViewerPresenter(
|
||||||
|
mediaGalleryDataSource = mediaGalleryDataSource,
|
||||||
|
)
|
||||||
|
val anImage = aMediaItemImage(
|
||||||
|
mediaSourceUrl = aUrl,
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
awaitFirstItem()
|
||||||
|
mediaGalleryDataSource.emitGroupedMediaItems(
|
||||||
|
AsyncData.Success(
|
||||||
|
GroupedMediaItems(
|
||||||
|
imageAndVideoItems = persistentListOf(anImage),
|
||||||
|
fileItems = persistentListOf(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val updatedState = awaitItem()
|
||||||
|
updatedState.eventSink(
|
||||||
|
MediaViewerEvents.ClearLoadingError(
|
||||||
|
aMediaViewerPageData(
|
||||||
|
mediaSource = MediaSource(aUrl)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - share`() = runTest {
|
||||||
|
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
|
startLambda = { },
|
||||||
|
)
|
||||||
|
val presenter = createMediaViewerPresenter(
|
||||||
|
mediaGalleryDataSource = mediaGalleryDataSource,
|
||||||
|
)
|
||||||
|
val anImage = aMediaItemImage(
|
||||||
|
mediaSourceUrl = aUrl,
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
awaitFirstItem()
|
||||||
|
mediaGalleryDataSource.emitGroupedMediaItems(
|
||||||
|
AsyncData.Success(
|
||||||
|
GroupedMediaItems(
|
||||||
|
imageAndVideoItems = persistentListOf(anImage),
|
||||||
|
fileItems = persistentListOf(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val updatedState = awaitItem()
|
||||||
|
updatedState.eventSink(
|
||||||
|
MediaViewerEvents.Share(
|
||||||
|
aMediaViewerPageData(
|
||||||
|
mediaSource = MediaSource(aUrl)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - save on disk`() = runTest {
|
||||||
|
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
|
startLambda = { },
|
||||||
|
)
|
||||||
|
val presenter = createMediaViewerPresenter(
|
||||||
|
mediaGalleryDataSource = mediaGalleryDataSource,
|
||||||
|
)
|
||||||
|
val anImage = aMediaItemImage(
|
||||||
|
mediaSourceUrl = aUrl,
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
awaitFirstItem()
|
||||||
|
mediaGalleryDataSource.emitGroupedMediaItems(
|
||||||
|
AsyncData.Success(
|
||||||
|
GroupedMediaItems(
|
||||||
|
imageAndVideoItems = persistentListOf(anImage),
|
||||||
|
fileItems = persistentListOf(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val updatedState = awaitItem()
|
||||||
|
updatedState.eventSink(
|
||||||
|
MediaViewerEvents.SaveOnDisk(
|
||||||
|
aMediaViewerPageData(
|
||||||
|
mediaSource = MediaSource(aUrl)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - open with`() = runTest {
|
||||||
|
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
|
startLambda = { },
|
||||||
|
)
|
||||||
|
val presenter = createMediaViewerPresenter(
|
||||||
|
mediaGalleryDataSource = mediaGalleryDataSource,
|
||||||
|
)
|
||||||
|
val anImage = aMediaItemImage(
|
||||||
|
mediaSourceUrl = aUrl,
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
awaitFirstItem()
|
||||||
|
mediaGalleryDataSource.emitGroupedMediaItems(
|
||||||
|
AsyncData.Success(
|
||||||
|
GroupedMediaItems(
|
||||||
|
imageAndVideoItems = persistentListOf(anImage),
|
||||||
|
fileItems = persistentListOf(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val updatedState = awaitItem()
|
||||||
|
updatedState.eventSink(
|
||||||
|
MediaViewerEvents.OpenWith(
|
||||||
|
aMediaViewerPageData(
|
||||||
|
mediaSource = MediaSource(aUrl)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - delete and cancel`() = runTest {
|
||||||
|
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
|
startLambda = { },
|
||||||
|
)
|
||||||
|
val presenter = createMediaViewerPresenter(
|
||||||
|
mediaGalleryDataSource = mediaGalleryDataSource,
|
||||||
|
)
|
||||||
|
val anImage = aMediaItemImage(
|
||||||
|
mediaSourceUrl = aUrl,
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
awaitFirstItem()
|
||||||
|
mediaGalleryDataSource.emitGroupedMediaItems(
|
||||||
|
AsyncData.Success(
|
||||||
|
GroupedMediaItems(
|
||||||
|
imageAndVideoItems = persistentListOf(anImage),
|
||||||
|
fileItems = persistentListOf(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val updatedState = awaitItem()
|
||||||
|
updatedState.eventSink(
|
||||||
|
MediaViewerEvents.ConfirmDelete(
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
data = aMediaViewerPageData(
|
||||||
|
mediaSource = MediaSource(aUrl)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val withBottomSheetState = awaitItem()
|
||||||
|
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java)
|
||||||
|
withBottomSheetState.eventSink(
|
||||||
|
MediaViewerEvents.CloseBottomSheet
|
||||||
|
)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - delete`() = runTest {
|
||||||
val redactEventLambda = lambdaRecorder<EventOrTransactionId, String?, Result<Unit>> { _, _ ->
|
val redactEventLambda = lambdaRecorder<EventOrTransactionId, String?, Result<Unit>> { _, _ ->
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
@ -241,26 +417,51 @@ class MediaViewerPresenterTest {
|
||||||
this.redactEventLambda = redactEventLambda
|
this.redactEventLambda = redactEventLambda
|
||||||
}
|
}
|
||||||
val onItemDeletedLambda = lambdaRecorder<Unit> { }
|
val onItemDeletedLambda = lambdaRecorder<Unit> { }
|
||||||
val navigator = FakeMediaViewerNavigator(
|
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
onItemDeletedLambda = onItemDeletedLambda,
|
startLambda = { },
|
||||||
)
|
)
|
||||||
|
|
||||||
val presenter = createMediaViewerPresenter(
|
val presenter = createMediaViewerPresenter(
|
||||||
room = FakeMatrixRoom(
|
room = FakeMatrixRoom(
|
||||||
liveTimeline = timeline,
|
liveTimeline = timeline,
|
||||||
canRedactOwnResult = { Result.success(true) },
|
canRedactOwnResult = { Result.success(true) },
|
||||||
),
|
),
|
||||||
mediaViewerNavigator = navigator,
|
mediaGalleryDataSource = mediaGalleryDataSource,
|
||||||
|
mediaViewerNavigator = FakeMediaViewerNavigator(
|
||||||
|
onItemDeletedLambda = onItemDeletedLambda
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val anImage = aMediaItemImage(
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
mediaSourceUrl = aUrl,
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
awaitFirstItem()
|
||||||
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
|
mediaGalleryDataSource.emitGroupedMediaItems(
|
||||||
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
|
AsyncData.Success(
|
||||||
val loadingState = awaitItem()
|
GroupedMediaItems(
|
||||||
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
|
imageAndVideoItems = persistentListOf(anImage),
|
||||||
val successState = awaitItem()
|
fileItems = persistentListOf(),
|
||||||
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
|
)
|
||||||
successState.eventSink(MediaViewerEvents.Delete(AN_EVENT_ID))
|
)
|
||||||
|
)
|
||||||
|
val updatedState = awaitItem()
|
||||||
|
updatedState.eventSink(
|
||||||
|
MediaViewerEvents.ConfirmDelete(
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
data = aMediaViewerPageData(
|
||||||
|
mediaSource = MediaSource(aUrl)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val withBottomSheetState = awaitItem()
|
||||||
|
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java)
|
||||||
|
updatedState.eventSink(
|
||||||
|
MediaViewerEvents.Delete(
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
||||||
redactEventLambda.assertions()
|
redactEventLambda.assertions()
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
.with(
|
.with(
|
||||||
|
|
@ -272,7 +473,71 @@ class MediaViewerPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - view in timeline invokes the navigator`() = runTest {
|
fun `present - on navigate to`() = runTest {
|
||||||
|
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
|
startLambda = { },
|
||||||
|
)
|
||||||
|
val presenter = createMediaViewerPresenter(
|
||||||
|
mediaGalleryDataSource = mediaGalleryDataSource,
|
||||||
|
)
|
||||||
|
val anImage = aMediaItemImage(
|
||||||
|
mediaSourceUrl = aUrl,
|
||||||
|
)
|
||||||
|
val anImage2 = aMediaItemImage(
|
||||||
|
mediaSourceUrl = aUrl,
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
awaitFirstItem()
|
||||||
|
mediaGalleryDataSource.emitGroupedMediaItems(
|
||||||
|
AsyncData.Success(
|
||||||
|
GroupedMediaItems(
|
||||||
|
imageAndVideoItems = persistentListOf(anImage, anImage2),
|
||||||
|
fileItems = persistentListOf(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val updatedState = awaitItem()
|
||||||
|
updatedState.eventSink(
|
||||||
|
MediaViewerEvents.OnNavigateTo(1)
|
||||||
|
)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.currentIndex).isEqualTo(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - load more`() = runTest {
|
||||||
|
val loadMoreLambda = lambdaRecorder<Timeline.PaginationDirection, Unit> { }
|
||||||
|
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
|
startLambda = { },
|
||||||
|
loadMoreLambda = loadMoreLambda,
|
||||||
|
)
|
||||||
|
val presenter = createMediaViewerPresenter(
|
||||||
|
mediaGalleryDataSource = mediaGalleryDataSource,
|
||||||
|
)
|
||||||
|
val anImage = aMediaItemImage(
|
||||||
|
mediaSourceUrl = aUrl,
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
awaitFirstItem()
|
||||||
|
mediaGalleryDataSource.emitGroupedMediaItems(
|
||||||
|
AsyncData.Success(
|
||||||
|
GroupedMediaItems(
|
||||||
|
imageAndVideoItems = persistentListOf(anImage),
|
||||||
|
fileItems = persistentListOf(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val updatedState = awaitItem()
|
||||||
|
updatedState.eventSink(
|
||||||
|
MediaViewerEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)
|
||||||
|
)
|
||||||
|
loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - view in timeline hide the bottom sheet and invokes the navigator`() = runTest {
|
||||||
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
|
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
|
||||||
val navigator = FakeMediaViewerNavigator(
|
val navigator = FakeMediaViewerNavigator(
|
||||||
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
|
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
|
||||||
|
|
@ -285,22 +550,28 @@ class MediaViewerPresenterTest {
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
|
initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData()))
|
||||||
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
|
val withBottomSheetState = awaitItem()
|
||||||
val loadingState = awaitItem()
|
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
|
||||||
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
|
initialState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID))
|
||||||
val successState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
|
||||||
successState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID))
|
|
||||||
onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
|
onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createMediaViewerPresenter(
|
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||||
|
return awaitItem()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createMediaViewerPresenter(
|
||||||
eventId: EventId? = null,
|
eventId: EventId? = null,
|
||||||
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
|
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
|
||||||
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
|
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
|
||||||
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
|
||||||
|
mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||||
|
startLambda = { },
|
||||||
|
),
|
||||||
canShowInfo: Boolean = true,
|
canShowInfo: Boolean = true,
|
||||||
mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(),
|
mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(),
|
||||||
room: MatrixRoom = FakeMatrixRoom(
|
room: MatrixRoom = FakeMatrixRoom(
|
||||||
|
|
@ -309,18 +580,25 @@ class MediaViewerPresenterTest {
|
||||||
): MediaViewerPresenter {
|
): MediaViewerPresenter {
|
||||||
return MediaViewerPresenter(
|
return MediaViewerPresenter(
|
||||||
inputs = MediaViewerEntryPoint.Params(
|
inputs = MediaViewerEntryPoint.Params(
|
||||||
|
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
mediaInfo = TESTED_MEDIA_INFO,
|
mediaInfo = TESTED_MEDIA_INFO,
|
||||||
mediaSource = aMediaSource(),
|
mediaSource = aMediaSource(),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
canShowInfo = canShowInfo,
|
canShowInfo = canShowInfo,
|
||||||
),
|
),
|
||||||
localMediaFactory = localMediaFactory,
|
navigator = mediaViewerNavigator,
|
||||||
mediaLoader = matrixMediaLoader,
|
dataSource = MediaViewerDataSource(
|
||||||
|
galleryMode = MediaGalleryMode.Images,
|
||||||
|
dispatcher = testCoroutineDispatchers().computation,
|
||||||
|
galleryDataSource = mediaGalleryDataSource,
|
||||||
|
mediaLoader = matrixMediaLoader,
|
||||||
|
localMediaFactory = localMediaFactory,
|
||||||
|
systemClock = FakeSystemClock(),
|
||||||
|
),
|
||||||
|
room = room,
|
||||||
localMediaActions = localMediaActions,
|
localMediaActions = localMediaActions,
|
||||||
snackbarDispatcher = snackbarDispatcher,
|
snackbarDispatcher = snackbarDispatcher,
|
||||||
navigator = mediaViewerNavigator,
|
|
||||||
room = room,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,15 @@ import androidx.compose.ui.test.performTouchInput
|
||||||
import androidx.compose.ui.test.swipeDown
|
import androidx.compose.ui.test.swipeDown
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
|
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
|
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
|
||||||
|
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||||
import io.element.android.tests.testutils.EventsRecorder
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
import io.element.android.tests.testutils.clickOn
|
import io.element.android.tests.testutils.clickOn
|
||||||
import io.element.android.tests.testutils.ensureCalledOnce
|
import io.element.android.tests.testutils.ensureCalledOnce
|
||||||
import io.element.android.tests.testutils.pressBack
|
import io.element.android.tests.testutils.pressBack
|
||||||
|
import io.mockk.mockk
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.rules.TestRule
|
import org.junit.rules.TestRule
|
||||||
|
|
@ -36,78 +36,127 @@ import org.junit.runner.RunWith
|
||||||
class MediaViewerViewTest {
|
class MediaViewerViewTest {
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
private val mockMediaUrl: Uri = mockk("localMediaUri")
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `clicking on back invokes expected callback`() {
|
fun `clicking on back invokes expected callback`() {
|
||||||
val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false)
|
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
|
||||||
|
val state = aMediaViewerState(
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
)
|
||||||
ensureCalledOnce { callback ->
|
ensureCalledOnce { callback ->
|
||||||
rule.setMediaViewerView(
|
rule.setMediaViewerView(
|
||||||
aMediaViewerState(
|
state = state,
|
||||||
eventSink = eventsRecorder
|
|
||||||
),
|
|
||||||
onBackClick = callback,
|
onBackClick = callback,
|
||||||
)
|
)
|
||||||
rule.pressBack()
|
rule.pressBack()
|
||||||
}
|
}
|
||||||
|
eventsRecorder.assertList(
|
||||||
|
listOf(
|
||||||
|
MediaViewerEvents.OnNavigateTo(0),
|
||||||
|
MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `clicking on open emit expected Event`() {
|
fun `clicking on open emit expected Event`() {
|
||||||
testMenuAction(CommonStrings.action_open_with, MediaViewerEvents.OpenWith)
|
val data = aMediaViewerPageData(
|
||||||
|
downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)),
|
||||||
|
)
|
||||||
|
testMenuAction(
|
||||||
|
data,
|
||||||
|
CommonStrings.action_open_with,
|
||||||
|
MediaViewerEvents.OpenWith(data),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun testMenuAction(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) {
|
@Test
|
||||||
|
fun `clicking on info emit expected Event`() {
|
||||||
|
val data = aMediaViewerPageData(
|
||||||
|
downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)),
|
||||||
|
)
|
||||||
|
testMenuAction(
|
||||||
|
data,
|
||||||
|
CommonStrings.a11y_view_details,
|
||||||
|
MediaViewerEvents.OpenInfo(data),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun testMenuAction(
|
||||||
|
data: MediaViewerPageData.MediaViewerData,
|
||||||
|
contentDescriptionRes: Int,
|
||||||
|
expectedEvent: MediaViewerEvents,
|
||||||
|
) {
|
||||||
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
|
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
|
||||||
rule.setMediaViewerView(
|
rule.setMediaViewerView(
|
||||||
aMediaViewerState(
|
aMediaViewerState(
|
||||||
downloadedMedia = AsyncData.Success(
|
listData = listOf(data),
|
||||||
LocalMedia(Uri.EMPTY, anImageMediaInfo())
|
|
||||||
),
|
|
||||||
mediaInfo = anImageMediaInfo(),
|
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val contentDescription = rule.activity.getString(contentDescriptionRes)
|
val contentDescription = rule.activity.getString(contentDescriptionRes)
|
||||||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||||
eventsRecorder.assertSingle(expectedEvent)
|
eventsRecorder.assertList(
|
||||||
|
listOf(
|
||||||
|
MediaViewerEvents.OnNavigateTo(0),
|
||||||
|
MediaViewerEvents.LoadMedia(data),
|
||||||
|
expectedEvent,
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `clicking on save emit expected Event`() {
|
fun `clicking on save emit expected Event`() {
|
||||||
testBottomSheetAction(CommonStrings.action_save, MediaViewerEvents.SaveOnDisk)
|
val data = aMediaViewerPageData()
|
||||||
|
testBottomSheetAction(
|
||||||
|
data,
|
||||||
|
CommonStrings.action_save,
|
||||||
|
MediaViewerEvents.SaveOnDisk(data),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `clicking on share emit expected Event`() {
|
fun `clicking on share emit expected Event`() {
|
||||||
testBottomSheetAction(CommonStrings.action_share, MediaViewerEvents.Share)
|
val data = aMediaViewerPageData()
|
||||||
|
testBottomSheetAction(
|
||||||
|
data,
|
||||||
|
CommonStrings.action_share,
|
||||||
|
MediaViewerEvents.Share(data),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun testBottomSheetAction(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) {
|
private fun testBottomSheetAction(
|
||||||
|
data: MediaViewerPageData.MediaViewerData,
|
||||||
|
contentDescriptionRes: Int,
|
||||||
|
expectedEvent: MediaViewerEvents,
|
||||||
|
) {
|
||||||
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
|
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
|
||||||
rule.setMediaViewerView(
|
rule.setMediaViewerView(
|
||||||
aMediaViewerState(
|
aMediaViewerState(
|
||||||
downloadedMedia = AsyncData.Success(
|
listData = listOf(data),
|
||||||
LocalMedia(Uri.EMPTY, anImageMediaInfo())
|
|
||||||
),
|
|
||||||
mediaInfo = anImageMediaInfo(),
|
|
||||||
mediaBottomSheetState = aMediaDetailsBottomSheetState(),
|
mediaBottomSheetState = aMediaDetailsBottomSheetState(),
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
rule.clickOn(contentDescriptionRes)
|
rule.clickOn(contentDescriptionRes)
|
||||||
eventsRecorder.assertSingle(expectedEvent)
|
eventsRecorder.assertList(
|
||||||
|
listOf(
|
||||||
|
MediaViewerEvents.OnNavigateTo(0),
|
||||||
|
MediaViewerEvents.LoadMedia(data),
|
||||||
|
expectedEvent,
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `clicking on image hides the overlay`() {
|
fun `clicking on image hides the overlay`() {
|
||||||
val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false)
|
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
|
||||||
|
val state = aMediaViewerState(
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
)
|
||||||
rule.setMediaViewerView(
|
rule.setMediaViewerView(
|
||||||
aMediaViewerState(
|
state = state,
|
||||||
downloadedMedia = AsyncData.Success(
|
|
||||||
LocalMedia(Uri.EMPTY, anImageMediaInfo())
|
|
||||||
),
|
|
||||||
mediaInfo = anImageMediaInfo(),
|
|
||||||
eventSink = eventsRecorder
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
// Ensure that the action are visible
|
// Ensure that the action are visible
|
||||||
val contentDescription = rule.activity.getString(CommonStrings.action_open_with)
|
val contentDescription = rule.activity.getString(CommonStrings.action_open_with)
|
||||||
|
|
@ -120,54 +169,79 @@ class MediaViewerViewTest {
|
||||||
rule.mainClock.advanceTimeBy(1_000)
|
rule.mainClock.advanceTimeBy(1_000)
|
||||||
rule.onNodeWithContentDescription(contentDescription)
|
rule.onNodeWithContentDescription(contentDescription)
|
||||||
.assertDoesNotExist()
|
.assertDoesNotExist()
|
||||||
|
eventsRecorder.assertList(
|
||||||
|
listOf(
|
||||||
|
MediaViewerEvents.OnNavigateTo(0),
|
||||||
|
MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `clicking swipe on the image invokes the expected callback`() {
|
fun `clicking swipe on the image invokes the expected callback`() {
|
||||||
val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false)
|
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
|
||||||
|
val state = aMediaViewerState(
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
)
|
||||||
ensureCalledOnce { callback ->
|
ensureCalledOnce { callback ->
|
||||||
rule.setMediaViewerView(
|
rule.setMediaViewerView(
|
||||||
aMediaViewerState(
|
state = state,
|
||||||
downloadedMedia = AsyncData.Success(
|
|
||||||
LocalMedia(Uri.EMPTY, anImageMediaInfo())
|
|
||||||
),
|
|
||||||
mediaInfo = anImageMediaInfo(),
|
|
||||||
eventSink = eventsRecorder
|
|
||||||
),
|
|
||||||
onBackClick = callback,
|
onBackClick = callback,
|
||||||
)
|
)
|
||||||
val imageContentDescription = rule.activity.getString(CommonStrings.common_image)
|
val imageContentDescription = rule.activity.getString(CommonStrings.common_image)
|
||||||
rule.onNodeWithContentDescription(imageContentDescription).performTouchInput { swipeDown(startY = centerY) }
|
rule.onNodeWithContentDescription(imageContentDescription).performTouchInput { swipeDown(startY = centerY) }
|
||||||
rule.mainClock.advanceTimeBy(1_000)
|
rule.mainClock.advanceTimeBy(1_000)
|
||||||
}
|
}
|
||||||
|
eventsRecorder.assertList(
|
||||||
|
listOf(
|
||||||
|
MediaViewerEvents.OnNavigateTo(0),
|
||||||
|
MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `error case, click on retry emits the expected Event`() {
|
fun `error case, click on retry emits the expected Event`() {
|
||||||
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
|
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
|
||||||
|
val data = aMediaViewerPageData(
|
||||||
|
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
|
||||||
|
)
|
||||||
rule.setMediaViewerView(
|
rule.setMediaViewerView(
|
||||||
aMediaViewerState(
|
aMediaViewerState(
|
||||||
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
|
listData = listOf(data),
|
||||||
mediaInfo = anImageMediaInfo(),
|
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
rule.clickOn(CommonStrings.action_retry)
|
rule.clickOn(CommonStrings.action_retry)
|
||||||
eventsRecorder.assertSingle(MediaViewerEvents.RetryLoading)
|
eventsRecorder.assertList(
|
||||||
|
listOf(
|
||||||
|
MediaViewerEvents.OnNavigateTo(0),
|
||||||
|
MediaViewerEvents.LoadMedia(data),
|
||||||
|
MediaViewerEvents.LoadMedia(data),
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `error case, click on cancel emits the expected Event`() {
|
fun `error case, click on cancel emits the expected Event`() {
|
||||||
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
|
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
|
||||||
|
val data = aMediaViewerPageData(
|
||||||
|
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
|
||||||
|
)
|
||||||
rule.setMediaViewerView(
|
rule.setMediaViewerView(
|
||||||
aMediaViewerState(
|
aMediaViewerState(
|
||||||
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
|
listData = listOf(data),
|
||||||
mediaInfo = anImageMediaInfo(),
|
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
rule.clickOn(CommonStrings.action_cancel)
|
rule.clickOn(CommonStrings.action_cancel)
|
||||||
eventsRecorder.assertSingle(MediaViewerEvents.ClearLoadingError)
|
eventsRecorder.assertList(
|
||||||
|
listOf(
|
||||||
|
MediaViewerEvents.OnNavigateTo(0),
|
||||||
|
MediaViewerEvents.LoadMedia(data),
|
||||||
|
MediaViewerEvents.ClearLoadingError(data)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ class FakeLocalMediaFactory(
|
||||||
dateSent = null,
|
dateSent = null,
|
||||||
dateSentFull = null,
|
dateSentFull = null,
|
||||||
waveform = null,
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
)
|
)
|
||||||
return aLocalMedia(uri, mediaInfo)
|
return aLocalMedia(uri, mediaInfo)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
<string name="a11y_show_password">"Show password"</string>
|
<string name="a11y_show_password">"Show password"</string>
|
||||||
<string name="a11y_start_call">"Start a call"</string>
|
<string name="a11y_start_call">"Start a call"</string>
|
||||||
<string name="a11y_user_menu">"User menu"</string>
|
<string name="a11y_user_menu">"User menu"</string>
|
||||||
|
<string name="a11y_view_details">"View details"</string>
|
||||||
<string name="a11y_voice_message_record">"Record voice message."</string>
|
<string name="a11y_voice_message_record">"Record voice message."</string>
|
||||||
<string name="a11y_voice_message_stop_recording">"Stop recording"</string>
|
<string name="a11y_voice_message_stop_recording">"Stop recording"</string>
|
||||||
<string name="action_accept">"Accept"</string>
|
<string name="action_accept">"Accept"</string>
|
||||||
|
|
@ -181,6 +182,7 @@ Reason: %1$s."</string>
|
||||||
<string name="common_light">"Light"</string>
|
<string name="common_light">"Light"</string>
|
||||||
<string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string>
|
<string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string>
|
||||||
<string name="common_loading">"Loading…"</string>
|
<string name="common_loading">"Loading…"</string>
|
||||||
|
<string name="common_loading_more">"Loading more…"</string>
|
||||||
<plurals name="common_member_count">
|
<plurals name="common_member_count">
|
||||||
<item quantity="one">"%1$d member"</item>
|
<item quantity="one">"%1$d member"</item>
|
||||||
<item quantity="other">"%1$d members"</item>
|
<item quantity="other">"%1$d members"</item>
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
|
|
||||||
const val A_FAKE_TIMESTAMP = 123L
|
const val A_FAKE_TIMESTAMP = 123L
|
||||||
|
|
||||||
class FakeSystemClock : SystemClock {
|
class FakeSystemClock(
|
||||||
override fun epochMillis(): Long {
|
var epochMillisResult: Long = A_FAKE_TIMESTAMP
|
||||||
return A_FAKE_TIMESTAMP
|
) : SystemClock {
|
||||||
}
|
override fun epochMillis() = epochMillisResult
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ class KonsistClassNameTest {
|
||||||
.withoutName(
|
.withoutName(
|
||||||
"Factory",
|
"Factory",
|
||||||
"TimelineController",
|
"TimelineController",
|
||||||
|
"TimelineMediaGalleryDataSource",
|
||||||
)
|
)
|
||||||
.withoutNameStartingWith(
|
.withoutNameStartingWith(
|
||||||
"Accompanist",
|
"Accompanist",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:7c7d4201ed9aa37995f4ab8ac982404f59e77374f316a057685886f14e698c35
|
oid sha256:8d8842663702441ce586c7e2141c0cdf47032a26b6015e592abb5682e3cd2c60
|
||||||
size 24680
|
size 25152
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b94fd31b7ed71eacfe8f136bfd59405b85d31a0fe557800311794f4ba7006271
|
oid sha256:e57fc21cd01917630a08324f49a3d76d82823ee4a2f90f23624c120126426689
|
||||||
size 22749
|
size 23190
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:333a21a41a7d5f8d47946648a7381dadeccf213a1176696b3512c08ae929d4d6
|
oid sha256:ddb495eaf8113f0be1ba572697083bd5b8ccd4c308780478b6c4691dd0f8d922
|
||||||
size 7819
|
size 8019
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:cba8f49caf65856569266a561206437840a43da2d41ed5f08321ecda99204329
|
oid sha256:22e4744300d62e550a9c545420621fe6aa8db1674a876731be31a12fbc426cbc
|
||||||
size 8236
|
size 7320
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a6d04e0ee068682ebb0a3842ba73407855f2b83b7389d26fa0f3e2ec20d42dc8
|
oid sha256:5076cbf15e1d0ec2c70432c88a5f48eb074490bdf8431cfb51109f91ad4e9576
|
||||||
size 7389
|
size 7495
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:267d4be8b727a0ecb5af1e5f1e69adfd68f50f32f1de78f4f9fde60f635244b5
|
oid sha256:b3599a3a6fb43f98d928ce71c3ef7a8b8102d558c31ea1f735a948e337738f95
|
||||||
size 13045
|
size 13533
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:66437179fb0b851d4d4d647d00cab94cc7422d625f559839c675b378dbf1af38
|
oid sha256:1e4ef6ed6fe4c858ac4c67bf2bc5d428f4d4734cf8b01244a3bc963a815a540a
|
||||||
size 389408
|
size 389328
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2b286342ff4d46637beac1f980294f77b3e2eb6824d56448cdbdce7b41c911ab
|
oid sha256:67ca752577251e9e57e7680ae1bbdef3324c84ec6a1037aa9f7c228bb8206f4c
|
||||||
size 388612
|
size 388634
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8165bcb4b0d52a227aad4e1f3951fc3628ef647947ef2584ed50d8ede8a6a344
|
oid sha256:96941aab9596583187e4a089bd448252be551e3ef6b2fb7550c3bad5c7ba60fb
|
||||||
size 38248
|
size 37905
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c9efb4ed1ee82bba30351bf213f0873637e8194140a3bbea669321bf76bc6483
|
oid sha256:add442c1cabc79cde42e65775b22413c92918bba93d96bb72a9ff214b9ae7fac
|
||||||
size 31449
|
size 31126
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:5d2882d79b9f66726c6d16c8c6fc84cb9f65a5674222fe85794229dc5ba12a6b
|
oid sha256:0c288f75fcccb93e074afd8178219887ce8a541d8e444df18ca041647114d340
|
||||||
size 24679
|
size 24491
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9a60bd02d969c7c6ccc0eb2e8f4f2c5551b8d910e5bb2f96710589040541691d
|
||||||
|
size 7973
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:049993637421009db857dbd8a647d268241cd0f88a12a376804444647f94e885
|
||||||
|
size 5069
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:da172fdf40dc8702bc6dcb89bcc75e93bd279f6bbb9454f5283febe4da25d399
|
oid sha256:248ad0bbfc8c8a56975c2fc6ddfa5275ff4f3ad39b9a76126c8d4bdd0c566e88
|
||||||
size 389440
|
size 389354
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ee478f10b781385a5bd472a9fb1047e869ca2e46e1bab28758315722ff911bbc
|
oid sha256:ecdd1219635a61be5473773464d3796ea6a8f17ac2c384e131265e9557dadf42
|
||||||
size 94992
|
size 95129
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:7ad6e45382dec9bb27593b6e2ed92ed633204479040ccebaf4d362bb2f41fec7
|
oid sha256:c89d437ccf40ac25227d19d1b775ae0992cad7970784e29586438a60f6bf950b
|
||||||
size 396403
|
size 396206
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d10cb9be5b5139f0fdfdfb11cc3d3eca1955297180e5db8142bfea6250f20d73
|
oid sha256:9ac33be436135d5022024108ec403ea3e53995d8e42dcc52bbd3d8041e5e2975
|
||||||
size 25811
|
size 25053
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:69b5d7572ae6e4ff084867fac1bae41a55c75c9a5236cb6ccb4c31b89ef77898
|
oid sha256:4d0288375c9e746d4cbb9b270c1c7f5e97633d76027186bd9568555edeaf8700
|
||||||
size 5442
|
size 5411
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f8d3e3f8733424870b254be90599ed1ff6ba784089600bcb200fbef62c81537c
|
oid sha256:c756b50710b1ed10eea01d8f97d8cca79a3b3440c559e1f593c790b55e5f6556
|
||||||
size 14562
|
size 14194
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d1785d90957316791969f047e66bd779da62d675004914099f2af2b69bebe405
|
oid sha256:2c5a6e30127fe88d1d55da6daebff64981e88bbf7a37c712c99f831849e56172
|
||||||
size 14700
|
size 14374
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1ddcd8e9e20de4171a3d9f8175806a268723e47a14dca431849c2c29edaf5d0b
|
oid sha256:1ddd7d087dac24b5f2861b59c946eb89046b9bcb1a709dca423b8893f55a81f9
|
||||||
size 26267
|
size 26217
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:035ef0079af6e9825a52b86e2eab50667404a66d70dd2756596b60cc1cea376a
|
oid sha256:8a0e84ee0e17fa30222ffb5fe58f353ca847b251a8da7088a58faa467f1f6742
|
||||||
size 26404
|
size 26258
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue