Add content scanner support to media gallery and viewer (Element Pro) (#7233)
* Add content scanner support to media gallery and viewer * Improve UI and previews * Remove default content validation state in media content views * Disable click for invalid media items in the gallery events * Update screenshots --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
committed by
GitHub
parent
19724aaa78
commit
2705efad15
+2
-2
@@ -26,7 +26,7 @@ class DefaultContentScannerService(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
coroutineDispatchers: CoroutineDispatchers,
|
||||
) : ContentScannerService {
|
||||
private val context = coroutineDispatchers.io.limitedParallelism(4)
|
||||
private val dispatcher = coroutineDispatchers.io.limitedParallelism(4)
|
||||
|
||||
override fun scan(eventId: EventId, mediaSources: List<MediaSource>, contentValidationState: ContentValidationState) {
|
||||
for (mediaSource in mediaSources) {
|
||||
@@ -35,7 +35,7 @@ class DefaultContentScannerService(
|
||||
if (currentState != ContentValidationValue.Unknown) continue
|
||||
contentValidationState.update(url, ContentValidationValue.Loading)
|
||||
|
||||
coroutineScope.launch(context) {
|
||||
coroutineScope.launch(dispatcher) {
|
||||
contentScanner.scan(mediaSource)
|
||||
.onSuccess { isValid ->
|
||||
val contentValidationValue = if (isValid) ContentValidationValue.Valid else ContentValidationValue.Invalid
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@ import java.io.FileNotFoundException
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DefaultContentScannerServiceTest {
|
||||
@Test
|
||||
fun `scan with no media sources returns valid`() = runTest {
|
||||
fun `scan with no media sources returns unknown`() = runTest {
|
||||
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(true) }
|
||||
val scanner = FakeContentScanner(scan = scanLambda)
|
||||
val contentValidationState = DefaultContentValidationState()
|
||||
@@ -45,7 +45,7 @@ class DefaultContentScannerServiceTest {
|
||||
)
|
||||
|
||||
scanLambda.assertions().isNeverCalled()
|
||||
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Valid)
|
||||
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Unknown)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
-1
@@ -22,8 +22,8 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentVal
|
||||
fun TimelineItemAudioView(
|
||||
content: TimelineItemAudioContent,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
contentValidationValue: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
||||
) {
|
||||
TimelineItemAttachmentView(
|
||||
icon = CompoundIcons.Audio(),
|
||||
@@ -43,5 +43,6 @@ internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioCon
|
||||
TimelineItemAudioView(
|
||||
content,
|
||||
onContentLayoutChange = {},
|
||||
contentValidationValue = ContentValidationValue.Valid,
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -27,8 +27,8 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
fun TimelineItemFileView(
|
||||
content: TimelineItemFileContent,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
contentValidationValue: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
||||
) {
|
||||
TimelineItemAttachmentView(
|
||||
icon = CompoundIcons.Attachment(),
|
||||
@@ -48,6 +48,7 @@ internal fun TimelineItemFileViewPreview(@PreviewParameter(TimelineItemFileConte
|
||||
TimelineItemFileView(
|
||||
content,
|
||||
onContentLayoutChange = {},
|
||||
contentValidationValue = ContentValidationValue.Valid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-3
@@ -288,9 +288,15 @@ private fun GalleryItemCell(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.blurHashBackground(item.blurhash, alpha = 0.9f)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
.then(
|
||||
if (itemContentValidationState.isValid()) {
|
||||
Modifier.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
|
||||
+36
-29
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -43,7 +44,9 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||
@@ -55,8 +58,8 @@ fun TimelineItemImageView(
|
||||
onContentClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onShowContentClick: () -> Unit,
|
||||
contentValidationState: ContentValidationState,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
|
||||
) {
|
||||
val a11yLabel = stringResource(CommonStrings.common_image)
|
||||
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
|
||||
@@ -129,52 +132,56 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
contentValidationState = NoopContentValidationState(),
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
|
||||
TimelineItemImageView(
|
||||
content = aTimelineItemImageContent(),
|
||||
hideMediaContent = true,
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
TimelineItemImageView(
|
||||
content = aTimelineItemImageContent(),
|
||||
hideMediaContent = true,
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
contentValidationState = NoopContentValidationState(),
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ATimelineItemEventRowPreview() = ElementPreview {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
CompositionLocalProvider(LocalEventContentValidationState provides NoopEventContentValidationCache()) {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
content = aTimelineItemImageContent(
|
||||
filename = "image.jpg",
|
||||
caption = "A long caption that may wrap into several lines",
|
||||
width = 40,
|
||||
height = 20,
|
||||
aspectRatio = 40f / 20f,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
isMine = false,
|
||||
content = aTimelineItemImageContent(
|
||||
filename = "image.jpg",
|
||||
caption = "A long caption that may wrap into several lines",
|
||||
width = 40,
|
||||
height = 20,
|
||||
aspectRatio = 40f / 20f,
|
||||
caption = "Narrow image with null aspectRatio",
|
||||
width = 80,
|
||||
height = 150,
|
||||
aspectRatio = null,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = false,
|
||||
content = aTimelineItemImageContent(
|
||||
filename = "image.jpg",
|
||||
caption = "Narrow image with null aspectRatio",
|
||||
width = 80,
|
||||
height = 150,
|
||||
aspectRatio = null,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -39,7 +39,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@@ -52,8 +52,8 @@ fun TimelineItemStickerView(
|
||||
onContentClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onShowClick: () -> Unit,
|
||||
contentValidationState: ContentValidationState,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
|
||||
) {
|
||||
val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image)
|
||||
|
||||
@@ -130,5 +130,6 @@ internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemSticke
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
onShowClick = {},
|
||||
contentValidationState = NoopContentValidationState(),
|
||||
)
|
||||
}
|
||||
|
||||
+4
-2
@@ -51,7 +51,7 @@ import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
|
||||
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||
@@ -63,8 +63,8 @@ fun TimelineItemVideoView(
|
||||
onContentClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onShowContentClick: () -> Unit,
|
||||
contentValidationState: ContentValidationState,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
|
||||
) {
|
||||
val isTalkbackActive = isTalkbackActive()
|
||||
val a11yLabel = stringResource(CommonStrings.common_video)
|
||||
@@ -157,6 +157,7 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
contentValidationState = NoopContentValidationState(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,5 +170,6 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
contentValidationState = NoopContentValidationState(),
|
||||
)
|
||||
}
|
||||
|
||||
+3
-1
@@ -64,8 +64,8 @@ fun TimelineItemVoiceView(
|
||||
state: VoiceMessageState,
|
||||
content: TimelineItemVoiceContent,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
contentValidationValue: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
||||
) {
|
||||
fun playPause() {
|
||||
state.eventSink(VoiceMessageEvent.PlayPause)
|
||||
@@ -289,6 +289,7 @@ internal fun TimelineItemVoiceViewPreview(
|
||||
state = timelineItemVoiceViewParameters.state,
|
||||
content = timelineItemVoiceViewParameters.content,
|
||||
onContentLayoutChange = {},
|
||||
contentValidationValue = ContentValidationValue.Valid,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -302,6 +303,7 @@ internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview {
|
||||
state = it.state,
|
||||
content = it.content,
|
||||
onContentLayoutChange = {},
|
||||
contentValidationValue = ContentValidationValue.Valid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -116,6 +116,7 @@ class DefaultContentValidationState(
|
||||
|
||||
private fun calculateOverallState(states: Map<String, ContentValidationValue>): ContentValidationValue {
|
||||
return when {
|
||||
states.values.isEmpty() -> ContentValidationValue.Unknown
|
||||
states.values.any { it is ContentValidationValue.Invalid } -> ContentValidationValue.Invalid
|
||||
states.values.any { it is ContentValidationValue.Loading } -> ContentValidationValue.Loading
|
||||
states.values.any { it is ContentValidationValue.UnrecoverableError } -> states.values.first { it is ContentValidationValue.UnrecoverableError }
|
||||
|
||||
+4
-2
@@ -26,8 +26,10 @@ val LocalEventContentValidationState = staticCompositionLocalOf<EventContentVali
|
||||
/**
|
||||
* A noop implementation of the [EventContentValidationCache] that immediately returns a successful validation state. This will be used in FOSS.
|
||||
*/
|
||||
class NoopEventContentValidationCache : EventContentValidationCache {
|
||||
override operator fun get(eventId: EventId): ContentValidationState = noopValue
|
||||
class NoopEventContentValidationCache(
|
||||
private val provided: ContentValidationState = noopValue,
|
||||
) : EventContentValidationCache {
|
||||
override operator fun get(eventId: EventId): ContentValidationState = provided
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,7 @@ dependencies {
|
||||
|
||||
implementation(projects.features.enterprise.api)
|
||||
implementation(projects.features.viewfolder.api)
|
||||
implementation(projects.features.contentscanner.api)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.audio.api)
|
||||
@@ -48,6 +49,7 @@ dependencies {
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.matrixmedia.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.uiUtils)
|
||||
|
||||
+15
@@ -44,6 +44,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
@@ -54,6 +56,7 @@ class EventItemFactory(
|
||||
private val fileSizeFormatter: FileSizeFormatter,
|
||||
private val fileExtensionExtractor: FileExtensionExtractor,
|
||||
private val dateFormatter: DateFormatter,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
) {
|
||||
fun create(
|
||||
currentTimelineItem: MatrixTimelineItem.Event,
|
||||
@@ -67,6 +70,8 @@ class EventItemFactory(
|
||||
timestamp = currentTimelineItem.event.timestamp,
|
||||
mode = DateFormatterMode.Full,
|
||||
)
|
||||
val validationState = currentTimelineItem.eventId?.let { contentValidationCache[it] }
|
||||
?: NoopContentValidationState()
|
||||
return when (val content = event.content) {
|
||||
is CallNotifyContent,
|
||||
is FailedToParseMessageLikeContent,
|
||||
@@ -117,6 +122,7 @@ class EventItemFactory(
|
||||
mediaSource = c.source,
|
||||
thumbnailSource = c.info?.thumbnailSource,
|
||||
blurHash = c.info?.blurhash,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Video -> {
|
||||
@@ -138,6 +144,7 @@ class EventItemFactory(
|
||||
mediaSource = c.source,
|
||||
thumbnailSource = c.info?.thumbnailSource,
|
||||
blurHash = c.info?.blurhash,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Audio -> {
|
||||
@@ -156,6 +163,7 @@ class EventItemFactory(
|
||||
dateSentFull = dateSentFull,
|
||||
),
|
||||
mediaSource = c.source,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.File -> {
|
||||
@@ -174,6 +182,7 @@ class EventItemFactory(
|
||||
dateSentFull = dateSentFull,
|
||||
),
|
||||
mediaSource = c.source,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Other -> null
|
||||
@@ -200,6 +209,7 @@ class EventItemFactory(
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = type.source,
|
||||
validationState = validationState,
|
||||
))
|
||||
is FileMessageType -> listOf(MediaItem.File(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -221,6 +231,7 @@ class EventItemFactory(
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = type.source,
|
||||
validationState = validationState,
|
||||
// TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource
|
||||
))
|
||||
is ImageMessageType -> listOf(MediaItem.Image(
|
||||
@@ -245,6 +256,7 @@ class EventItemFactory(
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
blurHash = type.info?.blurhash,
|
||||
validationState = validationState,
|
||||
))
|
||||
is StickerMessageType -> listOf(MediaItem.Image(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -268,6 +280,7 @@ class EventItemFactory(
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
blurHash = type.info?.blurhash,
|
||||
validationState = validationState,
|
||||
))
|
||||
is VideoMessageType -> listOf(MediaItem.Video(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -291,6 +304,7 @@ class EventItemFactory(
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
blurHash = type.info?.blurhash,
|
||||
validationState = validationState,
|
||||
))
|
||||
is VoiceMessageType -> listOf(MediaItem.Voice(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -312,6 +326,7 @@ class EventItemFactory(
|
||||
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
||||
),
|
||||
mediaSource = type.source,
|
||||
validationState = validationState,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.libraries.androidutils.R
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
@@ -43,7 +44,11 @@ import io.element.android.libraries.mediaviewer.impl.model.eventId
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaInfo
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaPermissions
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaSource
|
||||
import io.element.android.libraries.mediaviewer.impl.model.thumbnailSource
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AssistedInject
|
||||
@@ -55,6 +60,7 @@ class MediaGalleryPresenter(
|
||||
private val mediaLoader: MatrixMediaLoader,
|
||||
private val localMediaActions: LocalMediaActions,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
) : Presenter<MediaGalleryState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -74,6 +80,17 @@ class MediaGalleryPresenter(
|
||||
|
||||
val groupedMediaItems by remember {
|
||||
mediaGalleryDataSource.groupedMediaItemsFlow()
|
||||
.onEach { groupedItems ->
|
||||
if (groupedItems is AsyncData.Success) {
|
||||
val items = groupedItems.data.getItems(mode)
|
||||
items.forEach { item ->
|
||||
if (item is MediaItem.Event) {
|
||||
val eventId = item.eventId() ?: return@forEach
|
||||
coroutineScope.validateMedia(eventId, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.collectAsState(AsyncData.Uninitialized)
|
||||
|
||||
@@ -220,6 +237,21 @@ class MediaGalleryPresenter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.validateMedia(eventId: EventId, mediaItem: MediaItem.Event) {
|
||||
launch {
|
||||
val thumbnailSource = mediaItem.thumbnailSource()
|
||||
val mediaSource = mediaItem.mediaSource()
|
||||
val contentValidationState = mediaItem.validationState
|
||||
val currentState = contentValidationState.overallStateFlow.first()
|
||||
|
||||
if (currentState.isLoading() || currentState.isValid()) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
contentScannerService.scan(eventId, listOfNotNull(thumbnailSource, mediaSource).distinct(), contentValidationState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mediaActionsError(throwable: Throwable): Int {
|
||||
return if (throwable is ActivityNotFoundException) {
|
||||
R.string.error_no_compatible_app_found
|
||||
|
||||
+26
-4
@@ -13,6 +13,8 @@ import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.components.media.WaveFormSamples
|
||||
import io.element.android.libraries.designsystem.preview.ROOM_NAME
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||
import io.element.android.libraries.mediaviewer.impl.details.aMediaBottomSheetStateDetails
|
||||
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
|
||||
@@ -47,9 +49,20 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
|
||||
aMediaItemImage(id = UniqueId("3")),
|
||||
aMediaItemVideo(id = UniqueId("4")),
|
||||
aMediaItemImage(id = UniqueId("5")),
|
||||
aMediaItemImage(id = UniqueId("6")),
|
||||
aMediaItemImage(id = UniqueId("7")),
|
||||
aMediaItemImage(id = UniqueId("8")),
|
||||
aMediaItemImage(
|
||||
id = UniqueId("6"),
|
||||
validationState = NoopContentValidationState(ContentValidationValue.Loading),
|
||||
blurHash = "LKO2?U%2Tw=w]~RBVZRi};RPxuwH"
|
||||
),
|
||||
aMediaItemImage(id = UniqueId("7"), validationState = NoopContentValidationState(ContentValidationValue.Invalid)),
|
||||
aMediaItemImage(
|
||||
id = UniqueId("8"),
|
||||
validationState = NoopContentValidationState(
|
||||
ContentValidationValue.UnrecoverableError(
|
||||
IllegalStateException("Failed to validate media item")
|
||||
)
|
||||
)
|
||||
),
|
||||
aMediaItemImage(id = UniqueId("9")),
|
||||
aMediaItemLoadingIndicator(),
|
||||
).toImmutableList()
|
||||
@@ -65,7 +78,7 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
|
||||
aGroupedMediaItems(
|
||||
fileItems = listOf(
|
||||
aMediaItemDateSeparator(id = UniqueId("0")),
|
||||
aMediaItemFile(id = UniqueId("1")),
|
||||
aMediaItemFile(id = UniqueId("1"), validationState = NoopContentValidationState(ContentValidationValue.Loading)),
|
||||
aMediaItemDateSeparator(
|
||||
id = UniqueId("2"),
|
||||
formattedDate = "September 2004",
|
||||
@@ -75,6 +88,15 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
|
||||
id = UniqueId("5"),
|
||||
waveform = WaveFormSamples.realisticWaveForm,
|
||||
),
|
||||
aMediaItemAudio(id = UniqueId("6"), validationState = NoopContentValidationState(ContentValidationValue.Invalid)),
|
||||
aMediaItemAudio(
|
||||
id = UniqueId("7"),
|
||||
validationState = NoopContentValidationState(
|
||||
ContentValidationValue.UnrecoverableError(
|
||||
IllegalStateException("Failed to validate media item")
|
||||
)
|
||||
)
|
||||
),
|
||||
aMediaItemLoadingIndicator(),
|
||||
).toImmutableList()
|
||||
)
|
||||
|
||||
+73
-10
@@ -9,14 +9,18 @@
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
@@ -31,6 +35,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -49,11 +55,13 @@ import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubti
|
||||
import io.element.android.libraries.designsystem.background.OnboardingBackground
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.SegmentedButton
|
||||
@@ -62,6 +70,9 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.mediaviewer.impl.R
|
||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||
import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet
|
||||
@@ -315,6 +326,14 @@ private fun MediaGalleryFilesList(
|
||||
key = { it.id() },
|
||||
contentType = { it::class.java },
|
||||
) { item ->
|
||||
val contentValidationState = remember(item is MediaItem.Event) {
|
||||
if (item is MediaItem.Event) {
|
||||
item.validationState
|
||||
} else {
|
||||
NoopContentValidationState()
|
||||
}
|
||||
}
|
||||
val contentValidationValue by contentValidationState.collectOverallState()
|
||||
when (item) {
|
||||
is MediaItem.File -> FileItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
@@ -323,6 +342,7 @@ private fun MediaGalleryFilesList(
|
||||
onLongClick = {
|
||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||
},
|
||||
contentValidationState = contentValidationValue,
|
||||
)
|
||||
is MediaItem.Audio -> AudioItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
@@ -331,6 +351,7 @@ private fun MediaGalleryFilesList(
|
||||
onLongClick = {
|
||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||
},
|
||||
contentValidationValue = contentValidationValue,
|
||||
)
|
||||
is MediaItem.Voice -> {
|
||||
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(item)
|
||||
@@ -341,16 +362,17 @@ private fun MediaGalleryFilesList(
|
||||
onLongClick = {
|
||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||
},
|
||||
contentValidationValue = contentValidationValue,
|
||||
)
|
||||
}
|
||||
is MediaItem.Image,
|
||||
is MediaItem.Video -> {
|
||||
// Should not happen
|
||||
}
|
||||
is MediaItem.DateSeparator -> DateItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item
|
||||
)
|
||||
is MediaItem.Image,
|
||||
is MediaItem.Video -> {
|
||||
// Should not happen
|
||||
}
|
||||
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item,
|
||||
@@ -387,11 +409,48 @@ private fun MediaGalleryImageGrid(
|
||||
key = { it.id() },
|
||||
contentType = { it::class.java },
|
||||
) { item ->
|
||||
val blurHash = when (item) {
|
||||
is MediaItem.Image -> item.blurHash
|
||||
is MediaItem.Video -> item.blurHash
|
||||
else -> null
|
||||
}
|
||||
|
||||
val blurHashBackgroundModifier = if (blurHash != null) {
|
||||
Modifier.blurHashBackground(blurHash)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val currentValidationState by when (item) {
|
||||
is MediaItem.Event -> item.validationState.collectOverallState()
|
||||
else -> remember { mutableStateOf(ContentValidationValue.Unknown) }
|
||||
}
|
||||
|
||||
when (item) {
|
||||
is MediaItem.DateSeparator -> DateItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item,
|
||||
)
|
||||
is MediaItem.Event if !currentValidationState.isValidated() -> {
|
||||
Box(modifier = blurHashBackgroundModifier.aspectRatio(1f), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
is MediaItem.Event if currentValidationState.isInvalid() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable { onItemClick(item) }
|
||||
.background(ElementTheme.colors.bgCriticalSubtle)
|
||||
.aspectRatio(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
imageVector = CompoundIcons.Error(),
|
||||
tint = ElementTheme.colors.iconCriticalPrimary,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
is MediaItem.Audio -> {
|
||||
// Should not happen
|
||||
}
|
||||
@@ -402,7 +461,7 @@ private fun MediaGalleryImageGrid(
|
||||
// Should not happen
|
||||
}
|
||||
is MediaItem.Image -> ImageItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
modifier = blurHashBackgroundModifier.animateItem(),
|
||||
image = item,
|
||||
onClick = { onItemClick(item) },
|
||||
onLongClick = {
|
||||
@@ -410,13 +469,17 @@ private fun MediaGalleryImageGrid(
|
||||
},
|
||||
)
|
||||
is MediaItem.Video -> VideoItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
modifier = blurHashBackgroundModifier.animateItem(),
|
||||
video = item,
|
||||
onClick = { onItemClick(item) },
|
||||
onLongClick = {
|
||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||
},
|
||||
)
|
||||
is MediaItem.DateSeparator -> DateItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item,
|
||||
)
|
||||
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item,
|
||||
|
||||
+30
-96
@@ -8,117 +8,38 @@
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.core.extensions.withBrackets
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun AudioItemView(
|
||||
audio: MediaItem.Audio,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
contentValidationValue: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
GalleryFileListItem(
|
||||
modifier = modifier,
|
||||
contentValidationValue = contentValidationValue,
|
||||
caption = audio.mediaInfo.caption,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
FilenameRow(
|
||||
audio = audio,
|
||||
GalleryFileListItemContent(
|
||||
name = audio.mediaInfo.filename,
|
||||
formattedSize = audio.mediaInfo.formattedFileSize,
|
||||
icon = CompoundIcons.Audio(),
|
||||
isValidating = contentValidationValue.isLoading(),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
val caption = audio.mediaInfo.caption
|
||||
if (caption != null) {
|
||||
CaptionView(caption)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilenameRow(
|
||||
audio: MediaItem.Audio,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||
)
|
||||
.onKeyboardContextMenuAction(onLongClick)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = ElementTheme.colors.bgActionSecondaryRest,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.size(32.dp)
|
||||
.padding(6.dp),
|
||||
imageVector = CompoundIcons.Audio(),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = audio.mediaInfo.filename,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val formattedSize = audio.mediaInfo.formattedFileSize
|
||||
if (formattedSize.isNotEmpty()) {
|
||||
Text(
|
||||
text = formattedSize.withBrackets(),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +48,22 @@ private fun FilenameRow(
|
||||
internal fun AudioItemViewPreview(
|
||||
@PreviewParameter(MediaItemAudioProvider::class) audio: MediaItem.Audio,
|
||||
) = ElementPreview {
|
||||
AudioItemView(
|
||||
audio = audio,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
val states = remember {
|
||||
listOf(
|
||||
ContentValidationValue.Valid,
|
||||
ContentValidationValue.Loading,
|
||||
ContentValidationValue.Invalid,
|
||||
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
|
||||
)
|
||||
}
|
||||
Column {
|
||||
for (state in states) {
|
||||
AudioItemView(
|
||||
contentValidationValue = state,
|
||||
audio = audio,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-96
@@ -8,117 +8,38 @@
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.core.extensions.withBrackets
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun FileItemView(
|
||||
file: MediaItem.File,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
contentValidationState: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
GalleryFileListItem(
|
||||
modifier = modifier,
|
||||
contentValidationValue = contentValidationState,
|
||||
caption = file.mediaInfo.caption
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
FilenameRow(
|
||||
file = file,
|
||||
GalleryFileListItemContent(
|
||||
name = file.mediaInfo.filename,
|
||||
formattedSize = file.mediaInfo.formattedFileSize,
|
||||
icon = CompoundIcons.Attachment(),
|
||||
onClick = onClick,
|
||||
isValidating = contentValidationState.isLoading(),
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
val caption = file.mediaInfo.caption
|
||||
if (caption != null) {
|
||||
CaptionView(caption)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilenameRow(
|
||||
file: MediaItem.File,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||
)
|
||||
.onKeyboardContextMenuAction(onLongClick)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = ElementTheme.colors.bgActionSecondaryRest,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.size(32.dp)
|
||||
.padding(6.dp),
|
||||
imageVector = CompoundIcons.Attachment(),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = file.mediaInfo.filename,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val formattedSize = file.mediaInfo.formattedFileSize
|
||||
if (formattedSize.isNotEmpty()) {
|
||||
Text(
|
||||
text = formattedSize.withBrackets(),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +48,22 @@ private fun FilenameRow(
|
||||
internal fun FileItemViewPreview(
|
||||
@PreviewParameter(MediaItemFileProvider::class) file: MediaItem.File,
|
||||
) = ElementPreview {
|
||||
FileItemView(
|
||||
file = file,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
val states = remember {
|
||||
listOf(
|
||||
ContentValidationValue.Valid,
|
||||
ContentValidationValue.Loading,
|
||||
ContentValidationValue.Invalid,
|
||||
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
|
||||
)
|
||||
}
|
||||
Column {
|
||||
for (state in states) {
|
||||
FileItemView(
|
||||
file = file,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
contentValidationState = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.core.extensions.withBrackets
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun GalleryFileListItem(
|
||||
contentValidationValue: ContentValidationValue,
|
||||
caption: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
when (contentValidationValue) {
|
||||
ContentValidationValue.Invalid -> InvalidContentView(
|
||||
modifier = invalidContentModifier(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
is ContentValidationValue.UnrecoverableError -> NotFoundContentView(
|
||||
modifier = invalidContentModifier(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
else -> {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
if (caption != null) {
|
||||
CaptionView(caption)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun invalidContentModifier(): Modifier {
|
||||
return Modifier
|
||||
.border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = RoundedCornerShape(12.dp))
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(ElementTheme.colors.bgCriticalSubtle)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GalleryFileListItemContent(
|
||||
name: String,
|
||||
formattedSize: String,
|
||||
icon: ImageVector,
|
||||
isValidating: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = onClick.takeIf { !isValidating } ?: {},
|
||||
onLongClick = onLongClick.takeIf { !isValidating } ?: {},
|
||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||
)
|
||||
.onKeyboardContextMenuAction(onLongClick)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val iconModifier = Modifier
|
||||
.background(
|
||||
color = ElementTheme.colors.bgActionSecondaryRest,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.size(32.dp)
|
||||
.padding(6.dp)
|
||||
|
||||
if (isValidating) {
|
||||
CircularProgressIndicator(
|
||||
modifier = iconModifier,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
modifier = iconModifier,
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = name,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (formattedSize.isNotEmpty()) {
|
||||
Text(
|
||||
text = formattedSize.withBrackets(),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -22,7 +22,7 @@ class MediaItemAudioProvider : PreviewParameterProvider<MediaItem.Audio> {
|
||||
caption = "A caption",
|
||||
),
|
||||
aMediaItemAudio(
|
||||
caption = loremIpsum,
|
||||
caption = loremIpsum.take(80),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ class MediaItemFileProvider : PreviewParameterProvider<MediaItem.File> {
|
||||
caption = "A caption",
|
||||
),
|
||||
aMediaItemFile(
|
||||
caption = loremIpsum,
|
||||
caption = loremIpsum.take(80),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> {
|
||||
caption = "A caption",
|
||||
),
|
||||
aMediaItemVoice(
|
||||
caption = loremIpsum,
|
||||
caption = loremIpsum.take(80),
|
||||
),
|
||||
aMediaItemVoice(
|
||||
waveform = emptyList(),
|
||||
|
||||
+61
-31
@@ -45,10 +45,10 @@ import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenu
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
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.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -64,26 +64,20 @@ fun VoiceItemView(
|
||||
state: VoiceMessageState,
|
||||
voice: MediaItem.Voice,
|
||||
onLongClick: () -> Unit,
|
||||
contentValidationValue: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
GalleryFileListItem(
|
||||
modifier = modifier,
|
||||
contentValidationValue = contentValidationValue,
|
||||
caption = voice.mediaInfo.caption,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
VoiceInfoRow(
|
||||
state = state,
|
||||
voice = voice,
|
||||
isValidating = contentValidationValue.isLoading(),
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
val caption = voice.mediaInfo.caption
|
||||
if (caption != null) {
|
||||
CaptionView(caption)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +85,7 @@ fun VoiceItemView(
|
||||
private fun VoiceInfoRow(
|
||||
state: VoiceMessageState,
|
||||
voice: MediaItem.Voice,
|
||||
isValidating: Boolean,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
fun playPause() {
|
||||
@@ -114,12 +109,21 @@ private fun VoiceInfoRow(
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (state.buttonType) {
|
||||
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
|
||||
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
|
||||
if (isValidating) {
|
||||
CustomIconButton(onClick = {}, enabled = false) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
when (state.buttonType) {
|
||||
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
|
||||
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(
|
||||
@@ -128,7 +132,7 @@ private fun VoiceInfoRow(
|
||||
) {
|
||||
PlaybackSpeedButton(
|
||||
speed = state.playbackSpeed,
|
||||
onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) },
|
||||
onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) }.takeIf { !isValidating } ?: {},
|
||||
)
|
||||
Text(
|
||||
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
|
||||
@@ -149,7 +153,7 @@ private fun VoiceInfoRow(
|
||||
onSeek = {
|
||||
state.eventSink(VoiceMessageEvent.Seek(it))
|
||||
},
|
||||
seekEnabled = true,
|
||||
seekEnabled = !isValidating,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -277,11 +281,24 @@ private fun CustomIconButton(
|
||||
internal fun VoiceItemViewPreview(
|
||||
@PreviewParameter(MediaItemVoiceProvider::class) voice: MediaItem.Voice,
|
||||
) = ElementPreview {
|
||||
VoiceItemView(
|
||||
state = aVoiceMessageState(),
|
||||
voice = voice,
|
||||
onLongClick = {},
|
||||
)
|
||||
val states = remember {
|
||||
listOf(
|
||||
ContentValidationValue.Valid,
|
||||
ContentValidationValue.Loading,
|
||||
ContentValidationValue.Invalid,
|
||||
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
|
||||
)
|
||||
}
|
||||
Column {
|
||||
for (state in states) {
|
||||
VoiceItemView(
|
||||
state = aVoiceMessageState(),
|
||||
voice = voice,
|
||||
onLongClick = {},
|
||||
contentValidationValue = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@@ -289,9 +306,22 @@ internal fun VoiceItemViewPreview(
|
||||
internal fun VoiceItemViewPlayPreview(
|
||||
@PreviewParameter(VoiceMessageStateProvider::class) state: VoiceMessageState,
|
||||
) = ElementPreview {
|
||||
VoiceItemView(
|
||||
state = state,
|
||||
voice = aMediaItemVoice(),
|
||||
onLongClick = {},
|
||||
)
|
||||
val validationState = remember {
|
||||
listOf(
|
||||
ContentValidationValue.Valid,
|
||||
ContentValidationValue.Loading,
|
||||
ContentValidationValue.Invalid,
|
||||
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
|
||||
)
|
||||
}
|
||||
Column {
|
||||
for (validationState in validationState) {
|
||||
VoiceItemView(
|
||||
state = state,
|
||||
voice = aMediaItemVoice(),
|
||||
onLongClick = {},
|
||||
contentValidationValue = validationState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-23
@@ -254,29 +254,31 @@ private fun ExoPlayerMediaVideoView(
|
||||
},
|
||||
)
|
||||
}
|
||||
MediaPlayerControllerView(
|
||||
state = mediaPlayerControllerState,
|
||||
onTogglePlay = {
|
||||
autoHideController++
|
||||
exoPlayer.togglePlay()
|
||||
},
|
||||
onSeekChange = {
|
||||
autoHideController++
|
||||
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||
seekingToMillis = it.toLong(),
|
||||
)
|
||||
exoPlayer.seekToEnsurePlaying(it.toLong())
|
||||
},
|
||||
onToggleMute = {
|
||||
autoHideController++
|
||||
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
|
||||
},
|
||||
audioFocus = audioFocus,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = bottomPaddingInPixels.toDp()),
|
||||
)
|
||||
if (localMedia != null) {
|
||||
MediaPlayerControllerView(
|
||||
state = mediaPlayerControllerState,
|
||||
onTogglePlay = {
|
||||
autoHideController++
|
||||
exoPlayer.togglePlay()
|
||||
},
|
||||
onSeekChange = {
|
||||
autoHideController++
|
||||
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||
seekingToMillis = it.toLong(),
|
||||
)
|
||||
exoPlayer.seekToEnsurePlaying(it.toLong())
|
||||
},
|
||||
onToggleMute = {
|
||||
autoHideController++
|
||||
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
|
||||
},
|
||||
audioFocus = audioFocus,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = bottomPaddingInPixels.toDp()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(exoPlayer.isPlaying) {
|
||||
|
||||
+15
-1
@@ -13,6 +13,8 @@ import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
|
||||
sealed interface MediaItem {
|
||||
@@ -27,7 +29,9 @@ sealed interface MediaItem {
|
||||
val timestamp: Long,
|
||||
) : MediaItem
|
||||
|
||||
sealed interface Event : MediaItem
|
||||
sealed interface Event : MediaItem {
|
||||
val validationState: ContentValidationState
|
||||
}
|
||||
|
||||
data class Image(
|
||||
val id: UniqueId,
|
||||
@@ -36,6 +40,7 @@ sealed interface MediaItem {
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
override val validationState: ContentValidationState,
|
||||
) : Event {
|
||||
val thumbnailMediaRequestData: MediaRequestData
|
||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
||||
@@ -48,6 +53,7 @@ sealed interface MediaItem {
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
override val validationState: ContentValidationState,
|
||||
) : Event {
|
||||
val thumbnailMediaRequestData: MediaRequestData
|
||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
||||
@@ -58,6 +64,7 @@ sealed interface MediaItem {
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
override val validationState: ContentValidationState,
|
||||
) : Event
|
||||
|
||||
data class Voice(
|
||||
@@ -65,6 +72,7 @@ sealed interface MediaItem {
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
override val validationState: ContentValidationState,
|
||||
) : Event
|
||||
|
||||
data class File(
|
||||
@@ -72,6 +80,7 @@ sealed interface MediaItem {
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
override val validationState: ContentValidationState,
|
||||
) : Event
|
||||
}
|
||||
|
||||
@@ -134,3 +143,8 @@ fun MediaItem.Event.blurHash(): String? {
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaItem.isMediaValid(): Boolean? = when (this) {
|
||||
is MediaItem.Event -> validationState.getCurrentOverallState() == ContentValidationValue.Valid
|
||||
else -> null
|
||||
}
|
||||
|
||||
+14
-1
@@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
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.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
|
||||
@@ -25,6 +27,8 @@ fun aMediaItemImage(
|
||||
eventId: EventId? = null,
|
||||
senderId: UserId? = null,
|
||||
mediaSourceUrl: String = "",
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
blurHash: String? = null,
|
||||
): MediaItem.Image {
|
||||
return MediaItem.Image(
|
||||
id = id,
|
||||
@@ -34,7 +38,8 @@ fun aMediaItemImage(
|
||||
),
|
||||
mediaSource = MediaSource(mediaSourceUrl),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
blurHash = blurHash,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,6 +47,7 @@ fun aMediaItemVideo(
|
||||
id: UniqueId = UniqueId("videoId"),
|
||||
mediaSource: MediaSource = MediaSource(""),
|
||||
duration: String? = "1:23",
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
): MediaItem.Video {
|
||||
return MediaItem.Video(
|
||||
id = id,
|
||||
@@ -52,6 +58,7 @@ fun aMediaItemVideo(
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,6 +67,7 @@ fun aMediaItemFile(
|
||||
eventId: EventId? = null,
|
||||
filename: String = "filename",
|
||||
caption: String? = null,
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
): MediaItem.File {
|
||||
return MediaItem.File(
|
||||
id = id,
|
||||
@@ -69,6 +77,7 @@ fun aMediaItemFile(
|
||||
caption = caption,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,6 +86,7 @@ fun aMediaItemAudio(
|
||||
eventId: EventId? = null,
|
||||
filename: String = "filename",
|
||||
caption: String? = null,
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
): MediaItem.Audio {
|
||||
return MediaItem.Audio(
|
||||
id = id,
|
||||
@@ -86,6 +96,7 @@ fun aMediaItemAudio(
|
||||
caption = caption,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -95,6 +106,7 @@ fun aMediaItemVoice(
|
||||
caption: String? = null,
|
||||
duration: String? = "1:23",
|
||||
waveform: List<Float> = WaveFormSamples.realisticWaveForm,
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
): MediaItem.Voice {
|
||||
return MediaItem.Voice(
|
||||
id = id,
|
||||
@@ -106,6 +118,7 @@ fun aMediaItemVoice(
|
||||
waveForm = waveform,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -11,6 +11,7 @@ import io.element.android.libraries.architecture.AsyncData
|
||||
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.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryInfo
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryItemData
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
@@ -37,6 +38,7 @@ class GalleryMediaGalleryDataSource(
|
||||
eventId: EventId?,
|
||||
galleryItems: List<GalleryItemData>,
|
||||
galleryInfo: GalleryInfo,
|
||||
contentValidationState: ContentValidationState,
|
||||
): GalleryMediaGalleryDataSource {
|
||||
val mixedItems = mutableListOf<MediaItem.Event>()
|
||||
galleryItems.forEachIndexed { index, galleryItem ->
|
||||
@@ -64,18 +66,21 @@ class GalleryMediaGalleryDataSource(
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
thumbnailSource = galleryItem.thumbnailSource,
|
||||
blurHash = galleryItem.blurHash,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
GalleryItemData.Type.Audio -> MediaItem.Audio(
|
||||
id = id,
|
||||
eventId = eventId,
|
||||
mediaInfo = itemMediaInfo,
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
GalleryItemData.Type.File -> MediaItem.File(
|
||||
id = id,
|
||||
eventId = eventId,
|
||||
mediaInfo = itemMediaInfo,
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
GalleryItemData.Type.Image -> MediaItem.Image(
|
||||
id = id,
|
||||
@@ -84,6 +89,7 @@ class GalleryMediaGalleryDataSource(
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
thumbnailSource = galleryItem.thumbnailSource,
|
||||
blurHash = galleryItem.blurHash,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
mixedItems.add(mediaItem)
|
||||
|
||||
+27
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -24,6 +25,9 @@ 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.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint.MediaViewerMode
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||
@@ -57,6 +61,8 @@ class MediaViewerDataSource(
|
||||
private val localMediaFactory: LocalMediaFactory,
|
||||
private val systemClock: SystemClock,
|
||||
private val pagerKeysHandler: PagerKeysHandler,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
) {
|
||||
// List of media files that are currently being loaded
|
||||
private val mediaFiles: ConcurrentHashMap<MediaSource, MediaFile> = ConcurrentHashMap()
|
||||
@@ -71,6 +77,9 @@ class MediaViewerDataSource(
|
||||
private val localMediaStates: MutableMap<String, MutableState<AsyncData<LocalMedia>>> =
|
||||
mutableMapOf()
|
||||
|
||||
private val mediaValidationState: MutableMap<String, ContentValidationState> =
|
||||
mutableMapOf()
|
||||
|
||||
fun setup(coroutineScope: CoroutineScope) {
|
||||
galleryDataSource.start(coroutineScope)
|
||||
}
|
||||
@@ -162,6 +171,9 @@ class MediaViewerDataSource(
|
||||
val localMedia = localMediaStates.getOrPut(sourceUrl) {
|
||||
mutableStateOf(AsyncData.Uninitialized)
|
||||
}
|
||||
val validationState = mediaValidationState.getOrPut(sourceUrl) {
|
||||
mediaItem.eventId()?.let { contentValidationCache[it] } ?: DefaultContentValidationState()
|
||||
}
|
||||
add(
|
||||
MediaViewerPageData.MediaViewerData(
|
||||
eventId = mediaItem.eventId(),
|
||||
@@ -170,6 +182,7 @@ class MediaViewerDataSource(
|
||||
thumbnailSource = mediaItem.thumbnailSource(),
|
||||
downloadedMedia = localMedia,
|
||||
pagerKey = pagerKeysHandler.getKey(mediaItem),
|
||||
validationState = validationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -229,6 +242,20 @@ class MediaViewerDataSource(
|
||||
}
|
||||
}
|
||||
|
||||
fun validateMedia(
|
||||
eventId: EventId,
|
||||
mediaSource: MediaSource,
|
||||
) {
|
||||
val validationState = mediaValidationState[mediaSource.safeUrl] ?: return
|
||||
val currentState = validationState.getCurrentMediaState(mediaSource.safeUrl)
|
||||
|
||||
if (currentState.isLoading() || currentState.isValid()) {
|
||||
return
|
||||
}
|
||||
|
||||
contentScannerService.scan(eventId, listOf(mediaSource), validationState)
|
||||
}
|
||||
|
||||
fun cancelLoadingMedia(data: MediaViewerPageData.MediaViewerData) {
|
||||
if (localMediaStates[data.mediaSource.safeUrl]?.value?.isLoading() == true) {
|
||||
Timber.d("cancelLoadingMedia for ${data.eventId}")
|
||||
|
||||
+2
@@ -9,6 +9,7 @@
|
||||
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||
|
||||
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
|
||||
|
||||
sealed interface MediaViewerEvent {
|
||||
@@ -30,4 +31,5 @@ sealed interface MediaViewerEvent {
|
||||
data class OnNavigateTo(val index: Int) : MediaViewerEvent
|
||||
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvent
|
||||
data class CancelLoadingMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvent
|
||||
data class ValidateMedia(val eventId: EventId, val mediaSource: MediaSource) : MediaViewerEvent
|
||||
}
|
||||
|
||||
+11
-3
@@ -22,6 +22,7 @@ import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.compound.theme.ForcedDarkElementTheme
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||
import io.element.android.libraries.architecture.callback
|
||||
@@ -33,6 +34,8 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||
import io.element.android.libraries.mediaviewer.impl.datasource.FocusedTimelineMediaGalleryDataSourceFactory
|
||||
@@ -57,6 +60,8 @@ class MediaViewerNode(
|
||||
private val audioFocus: AudioFocus,
|
||||
private val sessionId: SessionId,
|
||||
private val enterpriseService: EnterpriseService,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
) : Node(buildContext, plugins = plugins),
|
||||
MediaViewerNavigator {
|
||||
private val callback: MediaViewerEntryPoint.Callback = callback()
|
||||
@@ -76,12 +81,13 @@ class MediaViewerNode(
|
||||
|
||||
private val mediaGallerySource = when (inputs) {
|
||||
is MediaViewerEntryPoint.Params.Avatar ->
|
||||
SingleMediaGalleryDataSource.createFrom(inputs)
|
||||
SingleMediaGalleryDataSource.createFrom(inputs, contentValidationCache)
|
||||
is MediaViewerEntryPoint.Params.EventGallery ->
|
||||
GalleryMediaGalleryDataSource.createFrom(
|
||||
eventId = inputs.eventId,
|
||||
galleryItems = inputs.galleryItems,
|
||||
galleryInfo = inputs.galleryInfo,
|
||||
contentValidationState = inputs.eventId?.let { contentValidationCache[it] } ?: NoopContentValidationState(),
|
||||
)
|
||||
is MediaViewerEntryPoint.Params.RoomMedia -> {
|
||||
val eventId = inputs.eventId
|
||||
@@ -103,7 +109,7 @@ class MediaViewerNode(
|
||||
} else {
|
||||
focusedTimelineMediaGalleryDataSourceFactory.createFor(
|
||||
eventId = eventId,
|
||||
mediaItem = inputs.toMediaItem(),
|
||||
mediaItem = inputs.toMediaItem(contentValidationCache[eventId]),
|
||||
onlyPinnedEvents = false,
|
||||
)
|
||||
}
|
||||
@@ -111,7 +117,7 @@ class MediaViewerNode(
|
||||
Timeline.Mode.PinnedEvents -> {
|
||||
focusedTimelineMediaGalleryDataSourceFactory.createFor(
|
||||
eventId = eventId,
|
||||
mediaItem = inputs.toMediaItem(),
|
||||
mediaItem = inputs.toMediaItem(contentValidationCache[eventId]),
|
||||
onlyPinnedEvents = true,
|
||||
)
|
||||
}
|
||||
@@ -142,6 +148,8 @@ class MediaViewerNode(
|
||||
localMediaFactory = localMediaFactory,
|
||||
systemClock = systemClock,
|
||||
pagerKeysHandler = pagerKeysHandler,
|
||||
contentScannerService = contentScannerService,
|
||||
contentValidationCache = contentValidationCache,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+5
-1
@@ -42,7 +42,9 @@ import io.element.android.libraries.mediaviewer.impl.model.MediaPermissions
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaPermissions
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import io.element.android.libraries.androidutils.R as UtilsR
|
||||
@@ -70,6 +72,7 @@ class MediaViewerPresenter(
|
||||
private val eventId = inputs.eventId()
|
||||
private val mediaSource = inputs.mediaSource()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Composable
|
||||
override fun present(): MediaViewerState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -93,7 +96,7 @@ class MediaViewerPresenter(
|
||||
// Restore index based on the eventId after the initial items have been loaded
|
||||
currentIndex.intValue = dataSource.findEventIndex(eventId, mediaSource) ?: 0
|
||||
}
|
||||
value = new
|
||||
value = new.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +194,7 @@ class MediaViewerPresenter(
|
||||
is MediaViewerEvent.LoadMore -> coroutineScope.launch {
|
||||
dataSource.loadMore(event.direction)
|
||||
}
|
||||
is MediaViewerEvent.ValidateMedia -> dataSource.validateMedia(event.eventId, event.mediaSource)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -15,6 +15,7 @@ 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.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
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
|
||||
@@ -51,6 +52,7 @@ sealed interface MediaViewerPageData {
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val downloadedMedia: State<AsyncData<LocalMedia>>,
|
||||
val validationState: ContentValidationState,
|
||||
override val pagerKey: Long,
|
||||
) : MediaViewerPageData
|
||||
}
|
||||
|
||||
+33
@@ -17,6 +17,9 @@ import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
|
||||
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.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.aTxtMediaInfo
|
||||
@@ -275,6 +278,34 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
|
||||
)
|
||||
)
|
||||
},
|
||||
anImageMediaInfo(
|
||||
senderName = "Frank",
|
||||
dateSent = "26 NOV, 2024",
|
||||
).let {
|
||||
aMediaViewerState(
|
||||
listOf(
|
||||
aMediaViewerPageData(
|
||||
downloadedMedia = AsyncData.Uninitialized,
|
||||
validationState = NoopContentValidationState(ContentValidationValue.Loading),
|
||||
mediaInfo = it,
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
anImageMediaInfo(
|
||||
senderName = "Frank",
|
||||
dateSent = "26 NOV, 2024",
|
||||
).let {
|
||||
aMediaViewerState(
|
||||
listOf(
|
||||
aMediaViewerPageData(
|
||||
downloadedMedia = AsyncData.Uninitialized,
|
||||
validationState = NoopContentValidationState(ContentValidationValue.Invalid),
|
||||
mediaInfo = it,
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -293,6 +324,7 @@ fun aMediaViewerPageData(
|
||||
downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
|
||||
mediaInfo: MediaInfo = anImageMediaInfo(),
|
||||
mediaSource: MediaSource = MediaSource(""),
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
): MediaViewerPageData.MediaViewerData = MediaViewerPageData.MediaViewerData(
|
||||
eventId = null,
|
||||
mediaInfo = mediaInfo,
|
||||
@@ -300,6 +332,7 @@ fun aMediaViewerPageData(
|
||||
thumbnailSource = null,
|
||||
downloadedMedia = mutableStateOf(downloadedMedia),
|
||||
pagerKey = 0L,
|
||||
validationState = validationState,
|
||||
)
|
||||
|
||||
fun aMediaViewerState(
|
||||
|
||||
+84
-33
@@ -12,11 +12,14 @@ package io.element.android.libraries.mediaviewer.impl.viewer
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -73,7 +76,10 @@ import io.element.android.libraries.androidutils.text.safeLinkify
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
|
||||
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
|
||||
@@ -176,7 +182,7 @@ fun MediaViewerView(
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) {
|
||||
) { padding ->
|
||||
val pagerState = rememberPagerState(state.currentIndex, 0f) {
|
||||
state.listData.size
|
||||
}
|
||||
@@ -211,17 +217,33 @@ fun MediaViewerView(
|
||||
)
|
||||
}
|
||||
is MediaViewerPageData.MediaViewerData -> {
|
||||
val eventId = dataForPage.eventId
|
||||
|
||||
val isMediaValid = dataForPage.validationState.getCurrentMediaState(dataForPage.mediaSource.safeUrl).isValid()
|
||||
|
||||
// Check if the media to display is valid or dangerous
|
||||
LaunchedEffect(eventId) {
|
||||
if (eventId != null) {
|
||||
state.eventSink(MediaViewerEvent.ValidateMedia(eventId, dataForPage.mediaSource))
|
||||
}
|
||||
}
|
||||
|
||||
var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.onVisibilityChanged(minDurationMs = 200L) { isVisible ->
|
||||
val loadMediaOnVisibilityChangedModifier = remember(isMediaValid) {
|
||||
if (isMediaValid == true) {
|
||||
Modifier.onVisibilityChanged(minDurationMs = 200L) { isVisible ->
|
||||
if (isVisible) {
|
||||
state.eventSink(MediaViewerEvent.LoadMedia(dataForPage))
|
||||
} else {
|
||||
state.eventSink(MediaViewerEvent.CancelLoadingMedia(dataForPage))
|
||||
}
|
||||
}
|
||||
.fillMaxSize()
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = loadMediaOnVisibilityChangedModifier.fillMaxSize()
|
||||
) {
|
||||
val isDisplayed = remember(pagerState.settledPage) {
|
||||
// This 'item provider' lambda will be called when the data source changes with an outdated `settlePage` value
|
||||
@@ -232,6 +254,7 @@ fun MediaViewerView(
|
||||
MediaViewerPage(
|
||||
isDisplayed = isDisplayed,
|
||||
showOverlay = showOverlay,
|
||||
containerPadding = padding,
|
||||
bottomPaddingInPixels = (bottomPaddingInPixels - navigationBarPadding).coerceAtLeast(0),
|
||||
data = dataForPage,
|
||||
textFileViewer = textFileViewer,
|
||||
@@ -333,6 +356,7 @@ private fun MediaViewerPage(
|
||||
isDisplayed: Boolean,
|
||||
showOverlay: Boolean,
|
||||
bottomPaddingInPixels: Int,
|
||||
containerPadding: PaddingValues,
|
||||
data: MediaViewerPageData.MediaViewerData,
|
||||
textFileViewer: TextFileViewer,
|
||||
isUserSelected: Boolean,
|
||||
@@ -365,6 +389,25 @@ private fun MediaViewerPage(
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = data.validationState.getCurrentMediaState(data.mediaSource.safeUrl).isLoading(),
|
||||
modifier = Modifier.padding(top = containerPadding.calculateTopPadding()).align(Alignment.TopCenter),
|
||||
enter = fadeIn(spring(stiffness = 500F)),
|
||||
exit = fadeOut(spring(stiffness = 500F)),
|
||||
) {
|
||||
AsyncIndicator.Loading(
|
||||
text = stringResource(CommonStrings.content_scanner_scanning),
|
||||
)
|
||||
}
|
||||
|
||||
if (showProgress) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val zoomableState = rememberZoomableState(
|
||||
zoomSpec = ZoomSpec(maxZoomFactor = 4f, overzoomEffect = OverzoomEffect.NoLimits)
|
||||
@@ -380,29 +423,44 @@ private fun MediaViewerPage(
|
||||
}
|
||||
}
|
||||
|
||||
LocalMediaView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
isDisplayed = isDisplayed,
|
||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||
localMediaViewState = localMediaViewState,
|
||||
localMedia = downloadedMedia.dataOrNull(),
|
||||
mediaInfo = data.mediaInfo,
|
||||
textFileViewer = textFileViewer,
|
||||
onClick = {
|
||||
if (playableState is PlayableState.NotPlayable) {
|
||||
currentOnShowOverlayChange(!currentShowOverlay)
|
||||
}
|
||||
},
|
||||
onOpenWith = onOpenWith,
|
||||
isUserSelected = isUserSelected,
|
||||
audioFocus = audioFocus,
|
||||
forPreview = false,
|
||||
)
|
||||
if (showThumbnail) {
|
||||
ThumbnailView(
|
||||
val isMediaValid = data.validationState.getCurrentMediaState(data.mediaSource.safeUrl).isValid()
|
||||
|
||||
if (isMediaValid == false) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
IconTitleSubtitleMolecule(
|
||||
iconStyle = BigIcon.Style.AlertSolid,
|
||||
title = "The file is not safe",
|
||||
subTitle = "Preview and download have been disabled.",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LocalMediaView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
isDisplayed = isDisplayed,
|
||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||
localMediaViewState = localMediaViewState,
|
||||
localMedia = downloadedMedia.dataOrNull(),
|
||||
mediaInfo = data.mediaInfo,
|
||||
thumbnailSource = data.thumbnailSource,
|
||||
textFileViewer = textFileViewer,
|
||||
onClick = {
|
||||
if (playableState is PlayableState.NotPlayable) {
|
||||
currentOnShowOverlayChange(!currentShowOverlay)
|
||||
}
|
||||
},
|
||||
onOpenWith = onOpenWith,
|
||||
isUserSelected = isUserSelected,
|
||||
audioFocus = audioFocus,
|
||||
forPreview = false,
|
||||
)
|
||||
if (showThumbnail) {
|
||||
ThumbnailView(
|
||||
mediaInfo = data.mediaInfo,
|
||||
thumbnailSource = data.thumbnailSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showError) {
|
||||
ErrorView(
|
||||
@@ -412,13 +470,6 @@ private fun MediaViewerPage(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showProgress) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-5
@@ -16,6 +16,9 @@ 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.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
|
||||
@@ -27,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
class SingleMediaGalleryDataSource(
|
||||
private val data: GroupedMediaItems,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
) : MediaGalleryDataSource {
|
||||
override fun start(coroutineScope: CoroutineScope) = Unit
|
||||
override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data))
|
||||
@@ -38,7 +42,10 @@ class SingleMediaGalleryDataSource(
|
||||
override suspend fun deleteItem(eventId: EventId) = Unit
|
||||
|
||||
companion object {
|
||||
fun createFrom(params: MediaViewerEntryPoint.Params.Avatar) = SingleMediaGalleryDataSource(
|
||||
fun createFrom(
|
||||
params: MediaViewerEntryPoint.Params.Avatar,
|
||||
contentValidationCache: EventContentValidationCache,
|
||||
) = SingleMediaGalleryDataSource(
|
||||
data = GroupedMediaItems(
|
||||
// Always use imageAndVideoItems, in Single mode, this is the data that will be used
|
||||
imageAndVideoItems = persistentListOf(
|
||||
@@ -64,15 +71,19 @@ class SingleMediaGalleryDataSource(
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
blurHash = params.blurHash,
|
||||
validationState = DefaultContentValidationState(),
|
||||
)
|
||||
),
|
||||
fileItems = persistentListOf(),
|
||||
)
|
||||
),
|
||||
contentValidationCache = contentValidationCache,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem(
|
||||
contentValidationState: ContentValidationState,
|
||||
) = when {
|
||||
mediaInfo.mimeType.isMimeTypeImage() -> {
|
||||
MediaItem.Image(
|
||||
id = UniqueId("dummy"),
|
||||
@@ -80,7 +91,8 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
blurHash = blurHash,
|
||||
blurHash = null,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
mediaInfo.mimeType.isMimeTypeVideo() -> {
|
||||
@@ -90,7 +102,8 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
blurHash = blurHash,
|
||||
blurHash = null,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
mediaInfo.mimeType.isMimeTypeAudio() -> {
|
||||
@@ -100,6 +113,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
eventId = eventId,
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
} else {
|
||||
MediaItem.Voice(
|
||||
@@ -107,6 +121,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
eventId = eventId,
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -116,6 +131,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
eventId = eventId,
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -12,11 +12,13 @@ import android.net.Uri
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaplayer.test.FakeAudioFocus
|
||||
import io.element.android.libraries.mediaviewer.api.AvatarInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
@@ -71,6 +73,8 @@ class DefaultMediaViewerEntryPointTest {
|
||||
audioFocus = FakeAudioFocus(),
|
||||
sessionId = A_SESSION_ID,
|
||||
enterpriseService = FakeEnterpriseService(),
|
||||
contentValidationCache = NoopEventContentValidationCache(),
|
||||
contentScannerService = ContentScannerService { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
val callback = object : MediaViewerEntryPoint.Callback {
|
||||
@@ -118,6 +122,8 @@ class DefaultMediaViewerEntryPointTest {
|
||||
audioFocus = FakeAudioFocus(),
|
||||
sessionId = A_SESSION_ID,
|
||||
enterpriseService = FakeEnterpriseService(),
|
||||
contentValidationCache = NoopEventContentValidationCache(),
|
||||
contentScannerService = ContentScannerService { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
val callback = object : MediaViewerEntryPoint.Callback {
|
||||
|
||||
+14
@@ -52,6 +52,8 @@ import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageCo
|
||||
import io.element.android.libraries.matrix.test.timeline.aStickerContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
||||
@@ -174,6 +176,7 @@ class EventItemFactoryTest {
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -227,6 +230,7 @@ class EventItemFactoryTest {
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -274,6 +278,7 @@ class EventItemFactoryTest {
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -328,6 +333,7 @@ class EventItemFactoryTest {
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -379,6 +385,7 @@ class EventItemFactoryTest {
|
||||
duration = "7:36",
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -440,6 +447,7 @@ class EventItemFactoryTest {
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -502,6 +510,7 @@ class EventItemFactoryTest {
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -557,6 +566,7 @@ class EventItemFactoryTest {
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource("audio_url"),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -613,6 +623,7 @@ class EventItemFactoryTest {
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource("file_url"),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -749,13 +760,16 @@ class EventItemFactoryTest {
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val noopValidationState = NoopContentValidationState()
|
||||
private fun createEventItemFactory() = EventItemFactory(
|
||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
contentValidationCache = NoopEventContentValidationCache(noopValidationState),
|
||||
)
|
||||
|
||||
+7
-1
@@ -34,6 +34,8 @@ 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.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
@@ -250,6 +252,7 @@ class TimelineMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("url"),
|
||||
thumbnailSource = MediaSource("url_thumbnail"),
|
||||
blurHash = A_BLUR_HASH,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
),
|
||||
fileItems = persistentListOf()
|
||||
@@ -260,6 +263,8 @@ class TimelineMediaGalleryDataSourceTest {
|
||||
}
|
||||
}
|
||||
|
||||
private val noopValidationState = NoopContentValidationState()
|
||||
|
||||
internal fun TestScope.createTimelineMediaGalleryDataSource(
|
||||
room: JoinedRoom = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline(),
|
||||
@@ -282,5 +287,6 @@ fun TestScope.createTimelineMediaItemsFactory() = TimelineMediaItemsFactory(
|
||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
),
|
||||
contentValidationCache = NoopEventContentValidationCache(noopValidationState),
|
||||
)
|
||||
)
|
||||
|
||||
+1
@@ -534,6 +534,7 @@ class MediaGalleryPresenterTest {
|
||||
mediaLoader = matrixMediaLoader,
|
||||
localMediaActions = localMediaActions,
|
||||
snackbarDispatcher = snackbarDispatcher,
|
||||
contentScannerService = { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
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_USER_ID
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryInfo
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryItemData
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
@@ -95,6 +96,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
@@ -106,6 +108,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -125,6 +128,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
@@ -136,6 +140,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -155,6 +160,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
@@ -164,6 +170,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = expectedMediaInfo("audio.mp3", MimeTypes.Mp3),
|
||||
mediaSource = MediaSource("audio_url"),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -183,6 +190,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
@@ -192,6 +200,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = expectedMediaInfo("document.pdf", MimeTypes.Pdf),
|
||||
mediaSource = MediaSource("file_url"),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -211,6 +220,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
val item = data.imageAndVideoItems.single() as MediaItem.Image
|
||||
@@ -249,6 +259,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
),
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
@@ -258,6 +269,8 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
assertThat((data.imageAndVideoItems[2] as MediaItem.Video).id).isEqualTo(UniqueId("${AN_EVENT_ID.value}_2"))
|
||||
}
|
||||
|
||||
private val noopValidationState = NoopContentValidationState()
|
||||
|
||||
private fun aGalleryInfo() = GalleryInfo(
|
||||
caption = "A caption",
|
||||
formattedCaption = null,
|
||||
|
||||
+3
@@ -18,6 +18,7 @@ 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.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint.MediaViewerMode
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||
import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource
|
||||
@@ -290,5 +291,7 @@ class MediaViewerDataSourceTest {
|
||||
localMediaFactory = localMediaFactory,
|
||||
systemClock = FakeSystemClock(),
|
||||
pagerKeysHandler = PagerKeysHandler(),
|
||||
contentScannerService = { _, _, _ -> },
|
||||
contentValidationCache = NoopEventContentValidationCache(),
|
||||
)
|
||||
}
|
||||
|
||||
+3
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.AvatarInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
|
||||
@@ -969,6 +970,8 @@ internal fun TestScope.createMediaViewerPresenter(
|
||||
localMediaFactory = localMediaFactory,
|
||||
systemClock = FakeSystemClock(),
|
||||
pagerKeysHandler = PagerKeysHandler(),
|
||||
contentScannerService = { _, _, _ -> },
|
||||
contentValidationCache = NoopEventContentValidationCache(),
|
||||
),
|
||||
room = room,
|
||||
localMediaActions = localMediaActions,
|
||||
|
||||
+14
-6
@@ -16,6 +16,7 @@ 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.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
||||
@@ -36,22 +37,24 @@ class SingleMediaGalleryDataSourceTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private val contentValidationCache = NoopEventContentValidationCache()
|
||||
|
||||
@Test
|
||||
fun `function start is no op`() = runTest {
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems(), contentValidationCache)
|
||||
sut.start(backgroundScope)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `function loadMore is no op`() = runTest {
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems(), contentValidationCache)
|
||||
sut.loadMore(Timeline.PaginationDirection.BACKWARDS)
|
||||
sut.loadMore(Timeline.PaginationDirection.FORWARDS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `function deleteItem is no op`() = runTest {
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems(), contentValidationCache)
|
||||
sut.deleteItem(AN_EVENT_ID)
|
||||
}
|
||||
|
||||
@@ -61,7 +64,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
imageAndVideoItems = listOf(aMediaItemImage()),
|
||||
fileItems = listOf(aMediaItemFile()),
|
||||
)
|
||||
val sut = SingleMediaGalleryDataSource(data)
|
||||
val sut = SingleMediaGalleryDataSource(data, contentValidationCache)
|
||||
assertThat(sut.getLastData()).isEqualTo(AsyncData.Success(data))
|
||||
}
|
||||
|
||||
@@ -71,7 +74,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
imageAndVideoItems = listOf(aMediaItemImage()),
|
||||
fileItems = listOf(aMediaItemFile()),
|
||||
)
|
||||
val sut = SingleMediaGalleryDataSource(data)
|
||||
val sut = SingleMediaGalleryDataSource(data, contentValidationCache)
|
||||
sut.groupedMediaItemsFlow().test {
|
||||
assertThat(awaitItem()).isEqualTo(AsyncData.Success(data))
|
||||
awaitComplete()
|
||||
@@ -90,6 +93,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
blurHash = null,
|
||||
validationState = contentValidationCache[AN_EVENT_ID],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -107,6 +111,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
blurHash = null,
|
||||
validationState = contentValidationCache[AN_EVENT_ID],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -122,6 +127,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
eventId = params.eventId,
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
validationState = contentValidationCache[AN_EVENT_ID],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -140,6 +146,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
eventId = params.eventId,
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
validationState = contentValidationCache[AN_EVENT_ID],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -155,6 +162,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
eventId = params.eventId,
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
validationState = contentValidationCache[AN_EVENT_ID],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -165,7 +173,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
expectedResult: (MediaViewerEntryPoint.Params.RoomMedia) -> MediaItem,
|
||||
) {
|
||||
val params = aMediaViewerEntryPointParams(mediaInfo)
|
||||
val result = params.toMediaItem()
|
||||
val result = params.toMediaItem(contentValidationCache[AN_EVENT_ID],)
|
||||
assertThat(result).isEqualTo(expectedResult(params))
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:de447e1f21d51bd3e21a0fe4de6817c375a408beb8225a6ad5deb85025801e4f
|
||||
size 8293
|
||||
oid sha256:fbcb3345c0486059b8a446e0a9b904345b899e398d07f3363449e577d2c0e9fb
|
||||
size 30369
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f6c7e6ecb02d33e3849ebd2624dfd1e994d77b9f7f43e7ffe9afcf33e8436d44
|
||||
size 13041
|
||||
oid sha256:d149cbbffd2698eba03f4d89de3af98fb1ad7bc37849598b95dd3f02e66965a8
|
||||
size 43742
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:75f5d30bc99d5b09a69b77d544aaafaf665b1c377405f7ccb04092ac0da0a114
|
||||
size 35334
|
||||
oid sha256:f1abb765bc08c172adefb0ebd4c78d65b5b52791f54c15d72257b7b5a7192f0e
|
||||
size 79970
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7b3105694519d42956dc164a04b7642dfff4a303d7b96cfd2967ea3ad1bc19cd
|
||||
size 8071
|
||||
oid sha256:63449813a8ca880d291113834561224e2547546580cb10a1c4dd7e35a142c638
|
||||
size 29089
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8fd9f5dfc87d0a6e65e9c10c67f4359abde2ce85265770f8a47e3329f2f64a2d
|
||||
size 12443
|
||||
oid sha256:a5efe93f94bd3f0c68adaa21043cbbf02ef1e6aebbbdd137e8e2a8e99f2f6193
|
||||
size 41850
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:97f49b6969c5a852624558077f9fc9b0add39a12e76e744a385c85785d4489ec
|
||||
size 34405
|
||||
oid sha256:7c488147fde94e3c74660141125279b12435b9f037359256d70555afcd239a18
|
||||
size 77333
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:31283735c33057ede4d2ba20024b00ae4a4f45510cfbb47c21f980f7151c3858
|
||||
size 8931
|
||||
oid sha256:70aa1b155531b531ae7f7e7ac1a5123fd065a3c9080c6319435f63f8c315c4e4
|
||||
size 31421
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0a2decb05c73655390dd2cfb3d88d209b17ded46d5d6c40cc8672e5781b48348
|
||||
size 13334
|
||||
oid sha256:a5e462fa9302567a3ac90a4493e71ccb08ba8b924af686b251791c24c8b8553a
|
||||
size 44108
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:21803c66fce41f53cd7796321fb9f3fc774e0c4fc99cd3683a3a8b22e00e2615
|
||||
size 35966
|
||||
oid sha256:5ecc879f8b304ffd4b95f54d1aeda7208238352af1570be23bc9d8de4fe84677
|
||||
size 80846
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:11300a4c85f2e8daa06ddb4a3f8d6f72432dc581dca279366b1229450d3b973c
|
||||
size 8634
|
||||
oid sha256:447a27ce5bcd769a8efda39c39ef3494eaf648cbd0fa4939b6089e58b3f31ea3
|
||||
size 30107
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1c8a1759a613527cfe5912f6e811121e29611ed05b8a8cecb95391ffb1bc5be2
|
||||
size 12710
|
||||
oid sha256:3946799c8c3faba3bfef028f6adba46919bda464b73d83d3dcee95383fec491b
|
||||
size 42288
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c53798a077d020700ae58aa93aa507dd7ce88984c48aaabf11ba863e8b1773ed
|
||||
size 34944
|
||||
oid sha256:99a5e5fa3f4e014fd1a2b127646e88d848802d24f3150c45b1fb8e4b405d45d1
|
||||
size 78217
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2ef97e6e11a66c49ed5ba1863567f24097fe8121ea34f7de5fc5cdda3f0c7a0b
|
||||
size 9279
|
||||
oid sha256:42a0dbb630480cda58746522b69f6368c9cc7295f98a865a3caa4535a8f024f3
|
||||
size 32081
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:49bec9adfef9cf1637c722f5c96e0fec677c21a4ea3f89fd4555a9c894090be0
|
||||
size 9833
|
||||
oid sha256:d325f7de3a1c8517e7a924de415ab8da043ae3b44eec5e46c5150b1f58f01a50
|
||||
size 32799
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a010f16ec1221a43ba2bd5266da05f3de48d354b98dfc087974ff605d32d095d
|
||||
size 9576
|
||||
oid sha256:ff893a90f760818bd9c4f5befabf67c90d2ecc30ce26e1f613420ec32285c588
|
||||
size 32630
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c91316c96e75ba29e3d1bae38cb8b0abf255d7316ff3a616fdbddafc550c744f
|
||||
size 9715
|
||||
oid sha256:458bca1df07c79811b4deb874bb71bb822ae21cf85eb76ecf92c69f8ea10e84d
|
||||
size 32949
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0f8945f66443988d9b36084868241079e22e61605f21cad055d8e21e8e063c0c
|
||||
size 9819
|
||||
oid sha256:c107c8d928bb50527a24032697dee9427dd1dde5987c0c536c52163ce3b8e2a6
|
||||
size 33164
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2cc4cb7aca28fba19e6e5d9398ca7d26c9c361298137552789024f84a78a7f70
|
||||
size 8713
|
||||
oid sha256:0cd6406db1a8309e2f524d4de88ceed6062ac0902622d16fa73d6d8ca64f780e
|
||||
size 30306
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:48a1a38461c9d2f0bb66878b977673531808eafc0aba53f17beff779a575707e
|
||||
size 9337
|
||||
oid sha256:8b63323a128da29de0ebee9104ce6473136e5e3e8a11394c61116c683c3d51a7
|
||||
size 31109
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:98990600b7b05b8fe64404ef1f463bf5be99f48b90b6d6aab64f799d5b03f737
|
||||
size 9095
|
||||
oid sha256:8008dfe4f3a135203766b8107751960f159a1bb3350e1d43ede376075db8d61d
|
||||
size 30978
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c662180e91e10974f7709364a07fa54194df0061425565e08644e731f5922dfa
|
||||
size 9196
|
||||
oid sha256:f642dd641bf49c51d09fcbbc6a644cf280efaf2e88d3c8f4f85ee51b37eeac2a
|
||||
size 31190
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5c08ae126aab643824bd431aa4449a855e382e65840ebd980d4849e19ed17152
|
||||
size 9310
|
||||
oid sha256:e383090f2f0f06d902b220e05434045ebdd5be781d8f076e21bdc434a83c5bde
|
||||
size 31393
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9a3c0e76d07f250735fd75061fb076732180b254d8e8bc145887e0d307272de0
|
||||
size 9340
|
||||
oid sha256:cf406dbc78412e6c4e457dfbcbabf05248f2578e938016c4a5a6b5cccf1b0c29
|
||||
size 32157
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:08357309dca3a8a3cd8a38f0928522eeefd3a0cdd91990fcc590929e187b162a
|
||||
size 11676
|
||||
oid sha256:2506e19dfcd975a69fa533973ead95ea7923dbc67545553bad0b09ca87b3f6e9
|
||||
size 40834
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d03f232373aa8cb3f0f6fceeecbd4b1ef86f8703ac6b887683cbfa5c5e5af4e4
|
||||
size 36831
|
||||
oid sha256:1a6bc629fad6fdd5e1285cc9ac4484683b111498f989c5fe43f0b037b5619b36
|
||||
size 82000
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:886dff267c22e2ba300b81f13e3071756483c5938e9d1cfb357f633357f5ff06
|
||||
size 7634
|
||||
oid sha256:745ea9fca0277511ef197608025135387a985082367e52d52b780ba94498b7aa
|
||||
size 28992
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5c11e7b79420af53a8b934b3ad6dbe9c68d8ea5635322389663b55f62160eade
|
||||
size 8775
|
||||
oid sha256:e20e92f594ead4e2d5a0beb2f290077506322ec11eb75f8d16c1374ddb217456
|
||||
size 30375
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:22f76d8fbeaca8d0b986b4ac17a28c16e5f757e2022466200215fca333f69ede
|
||||
size 10929
|
||||
oid sha256:dd0e274dc81bf5bfa6498922cb3a75721bb98af8fbeaf558d67b127248755543
|
||||
size 38543
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:73cc7f1b0f8b9bff2b2e6c9a88b833baaeb9f39923c2113a006399c6a877634a
|
||||
size 35118
|
||||
oid sha256:e54d39cc93385c85ae9c1f8b9f5dd459811c28878f788d0e05562bfb62dfff6d
|
||||
size 78927
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d4d538dd90a36eefd2f9c8f8f66847cce6bc17f60e503513bf1890e6b5403d12
|
||||
size 7244
|
||||
oid sha256:4485fe9b2310bde15485f78d7dd556df6453e2567f859ab3268e36d5ec901f4f
|
||||
size 27453
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5832d2a257dcf613420352d10c42e058ca9df0afa323f2a6bd91ab6596240e5a
|
||||
size 528252
|
||||
oid sha256:b5bb8fe5d28d0df28c25c633d5e37aa6245d69e754e3ec543d855fd4a64517fb
|
||||
size 448654
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:691c6ffd759b01f216128c1f7d59e7cae76e1f1a83c95a8b89e6f2f55dce3b9c
|
||||
size 33009
|
||||
oid sha256:a7f75b9cad16c26fdca283a79b871be4b4c41d8c15b1d4ea96c82dd37015761e
|
||||
size 48065
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a5fbccba6548b6101f50f3842310e591507321817b048ec9a7310203dcd868d9
|
||||
size 524645
|
||||
oid sha256:ece2446eaa8417ce67a32589fa2901a8e2c73b6475b46b1bfb895ee3aacb6937
|
||||
size 445037
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e218343a909a15b66c6c00f46c6bbdd783ad1365aa380ed576a9e66c6dc3c09f
|
||||
size 31646
|
||||
oid sha256:a4d6f6ad2d759fd2383eed4d051880851cd3b46a03812e6ce43a372dd4542be8
|
||||
size 46007
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f10ca2d461e4078e46455e007eca2d1e9c9a20dbb6bc24c681fa5164e5f50efd
|
||||
size 13531
|
||||
oid sha256:d5c4ad66332bc73642009c013cf99b4b67dfe22d34dfe9db5d30e18dbafa2f10
|
||||
size 8905
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:da386980ce6a45e102727715b5d50f219e39dcb40a513abdfaab9fa725b8ea41
|
||||
size 13260
|
||||
oid sha256:921ac0d2d3ced6fc6c21c3fa82c49a783bf1c1ecababbac8d07364c5ee5a2573
|
||||
size 8727
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:348a194ad17a0a0dffda47c7387edde99436d543e9bff1726608946e92558830
|
||||
size 185553
|
||||
oid sha256:be416c0da077b81dbbe3c61c750bcaa3398150ac3444f1f9a365709d2ef647d0
|
||||
size 185717
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b23ef4fb29f51e308b74681017fcee257308edbd1e218e4daf5736501c70e0de
|
||||
size 699637
|
||||
oid sha256:06735917160f8f210909922ccf765b806c64586853fad685634a006fe59af974
|
||||
size 700189
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:47779722cc50bb7181d300e5738d10c32824c87122faef5939c4774e1b6530d8
|
||||
size 23583
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3c23fdfa983bc6032756dfb80149f8f39643215eab97ddb8d9c843401262bfc6
|
||||
size 20022
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:515d5a47d530117ff21cb922d1cb2cb81b22d02c083c51bbff69964f92260bbe
|
||||
size 197095
|
||||
oid sha256:d3403da236e7e9e8a28001adcb0f5c188d79bdf83146b69e8094b37e02627b28
|
||||
size 197245
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2871bd26b6c21d31ca251761eb5a203468406b4d18c13662fa815c53f015b5e1
|
||||
size 210297
|
||||
oid sha256:3454f97fd3811411ea5cd941b3d1e37a0f5ab98b9ef7475fa2c903c2b6b089d1
|
||||
size 210375
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fc42702e3ee2b646b4763fef985568f03d3d1ad971fd426328a1d9820045cbb8
|
||||
size 24235
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e2d3fd5b70d90864b581d71b064a684f1dfbaa368464c32d259dc418a4ab77f5
|
||||
size 20821
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cc65f284ec05a8ed7ea496c49c52afae585f09d26986d8582c00385459823393
|
||||
size 136782
|
||||
oid sha256:9f3f46de75cee217874e2c57b390998136a19d32613ea33a2e303541a4fd165b
|
||||
size 136770
|
||||
|
||||
Reference in New Issue
Block a user