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