mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-09 07:19:54 +00:00
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,
|
private val coroutineScope: CoroutineScope,
|
||||||
coroutineDispatchers: CoroutineDispatchers,
|
coroutineDispatchers: CoroutineDispatchers,
|
||||||
) : ContentScannerService {
|
) : 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) {
|
override fun scan(eventId: EventId, mediaSources: List<MediaSource>, contentValidationState: ContentValidationState) {
|
||||||
for (mediaSource in mediaSources) {
|
for (mediaSource in mediaSources) {
|
||||||
@@ -35,7 +35,7 @@ class DefaultContentScannerService(
|
|||||||
if (currentState != ContentValidationValue.Unknown) continue
|
if (currentState != ContentValidationValue.Unknown) continue
|
||||||
contentValidationState.update(url, ContentValidationValue.Loading)
|
contentValidationState.update(url, ContentValidationValue.Loading)
|
||||||
|
|
||||||
coroutineScope.launch(context) {
|
coroutineScope.launch(dispatcher) {
|
||||||
contentScanner.scan(mediaSource)
|
contentScanner.scan(mediaSource)
|
||||||
.onSuccess { isValid ->
|
.onSuccess { isValid ->
|
||||||
val contentValidationValue = if (isValid) ContentValidationValue.Valid else ContentValidationValue.Invalid
|
val contentValidationValue = if (isValid) ContentValidationValue.Valid else ContentValidationValue.Invalid
|
||||||
|
|||||||
+2
-2
@@ -32,7 +32,7 @@ import java.io.FileNotFoundException
|
|||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class DefaultContentScannerServiceTest {
|
class DefaultContentScannerServiceTest {
|
||||||
@Test
|
@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 scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(true) }
|
||||||
val scanner = FakeContentScanner(scan = scanLambda)
|
val scanner = FakeContentScanner(scan = scanLambda)
|
||||||
val contentValidationState = DefaultContentValidationState()
|
val contentValidationState = DefaultContentValidationState()
|
||||||
@@ -45,7 +45,7 @@ class DefaultContentScannerServiceTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
scanLambda.assertions().isNeverCalled()
|
scanLambda.assertions().isNeverCalled()
|
||||||
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Valid)
|
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Unknown)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+2
-1
@@ -22,8 +22,8 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentVal
|
|||||||
fun TimelineItemAudioView(
|
fun TimelineItemAudioView(
|
||||||
content: TimelineItemAudioContent,
|
content: TimelineItemAudioContent,
|
||||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||||
|
contentValidationValue: ContentValidationValue,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
|
||||||
) {
|
) {
|
||||||
TimelineItemAttachmentView(
|
TimelineItemAttachmentView(
|
||||||
icon = CompoundIcons.Audio(),
|
icon = CompoundIcons.Audio(),
|
||||||
@@ -43,5 +43,6 @@ internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioCon
|
|||||||
TimelineItemAudioView(
|
TimelineItemAudioView(
|
||||||
content,
|
content,
|
||||||
onContentLayoutChange = {},
|
onContentLayoutChange = {},
|
||||||
|
contentValidationValue = ContentValidationValue.Valid,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -27,8 +27,8 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||||||
fun TimelineItemFileView(
|
fun TimelineItemFileView(
|
||||||
content: TimelineItemFileContent,
|
content: TimelineItemFileContent,
|
||||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||||
|
contentValidationValue: ContentValidationValue,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
|
||||||
) {
|
) {
|
||||||
TimelineItemAttachmentView(
|
TimelineItemAttachmentView(
|
||||||
icon = CompoundIcons.Attachment(),
|
icon = CompoundIcons.Attachment(),
|
||||||
@@ -48,6 +48,7 @@ internal fun TimelineItemFileViewPreview(@PreviewParameter(TimelineItemFileConte
|
|||||||
TimelineItemFileView(
|
TimelineItemFileView(
|
||||||
content,
|
content,
|
||||||
onContentLayoutChange = {},
|
onContentLayoutChange = {},
|
||||||
|
contentValidationValue = ContentValidationValue.Valid,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-3
@@ -288,9 +288,15 @@ private fun GalleryItemCell(
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.blurHashBackground(item.blurhash, alpha = 0.9f)
|
.blurHashBackground(item.blurhash, alpha = 0.9f)
|
||||||
.combinedClickable(
|
.then(
|
||||||
onClick = onClick,
|
if (itemContentValidationState.isValid()) {
|
||||||
onLongClick = onLongClick,
|
Modifier.combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
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.layout.wrapContentWidth
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -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.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
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.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.matrix.ui.media.contentvalidation.collectOverallState
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||||
@@ -55,8 +58,8 @@ fun TimelineItemImageView(
|
|||||||
onContentClick: (() -> Unit)?,
|
onContentClick: (() -> Unit)?,
|
||||||
onLongClick: (() -> Unit)?,
|
onLongClick: (() -> Unit)?,
|
||||||
onShowContentClick: () -> Unit,
|
onShowContentClick: () -> Unit,
|
||||||
|
contentValidationState: ContentValidationState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
|
|
||||||
) {
|
) {
|
||||||
val a11yLabel = stringResource(CommonStrings.common_image)
|
val a11yLabel = stringResource(CommonStrings.common_image)
|
||||||
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
|
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
|
||||||
@@ -129,52 +132,56 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon
|
|||||||
onShowContentClick = {},
|
onShowContentClick = {},
|
||||||
onContentClick = {},
|
onContentClick = {},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
|
contentValidationState = NoopContentValidationState(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
|
internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
|
||||||
TimelineItemImageView(
|
TimelineItemImageView(
|
||||||
content = aTimelineItemImageContent(),
|
content = aTimelineItemImageContent(),
|
||||||
hideMediaContent = true,
|
hideMediaContent = true,
|
||||||
onShowContentClick = {},
|
onShowContentClick = {},
|
||||||
onContentClick = {},
|
onContentClick = {},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
)
|
contentValidationState = NoopContentValidationState(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ATimelineItemEventRowPreview() = ElementPreview {
|
internal fun ATimelineItemEventRowPreview() = ElementPreview {
|
||||||
Column {
|
CompositionLocalProvider(LocalEventContentValidationState provides NoopEventContentValidationCache()) {
|
||||||
sequenceOf(false, true).forEach { isMine ->
|
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(
|
ATimelineItemEventRow(
|
||||||
event = aTimelineItemEvent(
|
event = aTimelineItemEvent(
|
||||||
isMine = isMine,
|
isMine = false,
|
||||||
content = aTimelineItemImageContent(
|
content = aTimelineItemImageContent(
|
||||||
filename = "image.jpg",
|
filename = "image.jpg",
|
||||||
caption = "A long caption that may wrap into several lines",
|
caption = "Narrow image with null aspectRatio",
|
||||||
width = 40,
|
width = 80,
|
||||||
height = 20,
|
height = 150,
|
||||||
aspectRatio = 40f / 20f,
|
aspectRatio = null,
|
||||||
),
|
),
|
||||||
groupPosition = TimelineItemGroupPosition.Last,
|
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.designsystem.theme.components.CircularProgressIndicator
|
||||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
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.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.matrix.ui.media.contentvalidation.collectOverallState
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@@ -52,8 +52,8 @@ fun TimelineItemStickerView(
|
|||||||
onContentClick: (() -> Unit)?,
|
onContentClick: (() -> Unit)?,
|
||||||
onLongClick: (() -> Unit)?,
|
onLongClick: (() -> Unit)?,
|
||||||
onShowClick: () -> Unit,
|
onShowClick: () -> Unit,
|
||||||
|
contentValidationState: ContentValidationState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
|
|
||||||
) {
|
) {
|
||||||
val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image)
|
val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image)
|
||||||
|
|
||||||
@@ -130,5 +130,6 @@ internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemSticke
|
|||||||
onContentClick = {},
|
onContentClick = {},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
onShowClick = {},
|
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.MAX_THUMBNAIL_WIDTH
|
||||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
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.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.matrix.ui.media.contentvalidation.collectOverallState
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||||
@@ -63,8 +63,8 @@ fun TimelineItemVideoView(
|
|||||||
onContentClick: (() -> Unit)?,
|
onContentClick: (() -> Unit)?,
|
||||||
onLongClick: (() -> Unit)?,
|
onLongClick: (() -> Unit)?,
|
||||||
onShowContentClick: () -> Unit,
|
onShowContentClick: () -> Unit,
|
||||||
|
contentValidationState: ContentValidationState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
|
|
||||||
) {
|
) {
|
||||||
val isTalkbackActive = isTalkbackActive()
|
val isTalkbackActive = isTalkbackActive()
|
||||||
val a11yLabel = stringResource(CommonStrings.common_video)
|
val a11yLabel = stringResource(CommonStrings.common_video)
|
||||||
@@ -157,6 +157,7 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon
|
|||||||
onShowContentClick = {},
|
onShowContentClick = {},
|
||||||
onContentClick = {},
|
onContentClick = {},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
|
contentValidationState = NoopContentValidationState(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,5 +170,6 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
|
|||||||
onShowContentClick = {},
|
onShowContentClick = {},
|
||||||
onContentClick = {},
|
onContentClick = {},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
|
contentValidationState = NoopContentValidationState(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -64,8 +64,8 @@ fun TimelineItemVoiceView(
|
|||||||
state: VoiceMessageState,
|
state: VoiceMessageState,
|
||||||
content: TimelineItemVoiceContent,
|
content: TimelineItemVoiceContent,
|
||||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||||
|
contentValidationValue: ContentValidationValue,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
|
||||||
) {
|
) {
|
||||||
fun playPause() {
|
fun playPause() {
|
||||||
state.eventSink(VoiceMessageEvent.PlayPause)
|
state.eventSink(VoiceMessageEvent.PlayPause)
|
||||||
@@ -289,6 +289,7 @@ internal fun TimelineItemVoiceViewPreview(
|
|||||||
state = timelineItemVoiceViewParameters.state,
|
state = timelineItemVoiceViewParameters.state,
|
||||||
content = timelineItemVoiceViewParameters.content,
|
content = timelineItemVoiceViewParameters.content,
|
||||||
onContentLayoutChange = {},
|
onContentLayoutChange = {},
|
||||||
|
contentValidationValue = ContentValidationValue.Valid,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +303,7 @@ internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview {
|
|||||||
state = it.state,
|
state = it.state,
|
||||||
content = it.content,
|
content = it.content,
|
||||||
onContentLayoutChange = {},
|
onContentLayoutChange = {},
|
||||||
|
contentValidationValue = ContentValidationValue.Valid,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -116,6 +116,7 @@ class DefaultContentValidationState(
|
|||||||
|
|
||||||
private fun calculateOverallState(states: Map<String, ContentValidationValue>): ContentValidationValue {
|
private fun calculateOverallState(states: Map<String, ContentValidationValue>): ContentValidationValue {
|
||||||
return when {
|
return when {
|
||||||
|
states.values.isEmpty() -> ContentValidationValue.Unknown
|
||||||
states.values.any { it is ContentValidationValue.Invalid } -> ContentValidationValue.Invalid
|
states.values.any { it is ContentValidationValue.Invalid } -> ContentValidationValue.Invalid
|
||||||
states.values.any { it is ContentValidationValue.Loading } -> ContentValidationValue.Loading
|
states.values.any { it is ContentValidationValue.Loading } -> ContentValidationValue.Loading
|
||||||
states.values.any { it is ContentValidationValue.UnrecoverableError } -> states.values.first { it is ContentValidationValue.UnrecoverableError }
|
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.
|
* A noop implementation of the [EventContentValidationCache] that immediately returns a successful validation state. This will be used in FOSS.
|
||||||
*/
|
*/
|
||||||
class NoopEventContentValidationCache : EventContentValidationCache {
|
class NoopEventContentValidationCache(
|
||||||
override operator fun get(eventId: EventId): ContentValidationState = noopValue
|
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.enterprise.api)
|
||||||
implementation(projects.features.viewfolder.api)
|
implementation(projects.features.viewfolder.api)
|
||||||
|
implementation(projects.features.contentscanner.api)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.audio.api)
|
implementation(projects.libraries.audio.api)
|
||||||
@@ -48,6 +49,7 @@ dependencies {
|
|||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.featureflag.api)
|
implementation(projects.libraries.featureflag.api)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.matrixmedia.api)
|
implementation(projects.libraries.matrixmedia.api)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.uiUtils)
|
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.VoiceMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||||
|
import io.element.android.libraries.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.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
|
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
|
||||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||||
@@ -54,6 +56,7 @@ class EventItemFactory(
|
|||||||
private val fileSizeFormatter: FileSizeFormatter,
|
private val fileSizeFormatter: FileSizeFormatter,
|
||||||
private val fileExtensionExtractor: FileExtensionExtractor,
|
private val fileExtensionExtractor: FileExtensionExtractor,
|
||||||
private val dateFormatter: DateFormatter,
|
private val dateFormatter: DateFormatter,
|
||||||
|
private val contentValidationCache: EventContentValidationCache,
|
||||||
) {
|
) {
|
||||||
fun create(
|
fun create(
|
||||||
currentTimelineItem: MatrixTimelineItem.Event,
|
currentTimelineItem: MatrixTimelineItem.Event,
|
||||||
@@ -67,6 +70,8 @@ class EventItemFactory(
|
|||||||
timestamp = currentTimelineItem.event.timestamp,
|
timestamp = currentTimelineItem.event.timestamp,
|
||||||
mode = DateFormatterMode.Full,
|
mode = DateFormatterMode.Full,
|
||||||
)
|
)
|
||||||
|
val validationState = currentTimelineItem.eventId?.let { contentValidationCache[it] }
|
||||||
|
?: NoopContentValidationState()
|
||||||
return when (val content = event.content) {
|
return when (val content = event.content) {
|
||||||
is CallNotifyContent,
|
is CallNotifyContent,
|
||||||
is FailedToParseMessageLikeContent,
|
is FailedToParseMessageLikeContent,
|
||||||
@@ -117,6 +122,7 @@ class EventItemFactory(
|
|||||||
mediaSource = c.source,
|
mediaSource = c.source,
|
||||||
thumbnailSource = c.info?.thumbnailSource,
|
thumbnailSource = c.info?.thumbnailSource,
|
||||||
blurHash = c.info?.blurhash,
|
blurHash = c.info?.blurhash,
|
||||||
|
validationState = validationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is GalleryItemType.Video -> {
|
is GalleryItemType.Video -> {
|
||||||
@@ -138,6 +144,7 @@ class EventItemFactory(
|
|||||||
mediaSource = c.source,
|
mediaSource = c.source,
|
||||||
thumbnailSource = c.info?.thumbnailSource,
|
thumbnailSource = c.info?.thumbnailSource,
|
||||||
blurHash = c.info?.blurhash,
|
blurHash = c.info?.blurhash,
|
||||||
|
validationState = validationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is GalleryItemType.Audio -> {
|
is GalleryItemType.Audio -> {
|
||||||
@@ -156,6 +163,7 @@ class EventItemFactory(
|
|||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
),
|
),
|
||||||
mediaSource = c.source,
|
mediaSource = c.source,
|
||||||
|
validationState = validationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is GalleryItemType.File -> {
|
is GalleryItemType.File -> {
|
||||||
@@ -174,6 +182,7 @@ class EventItemFactory(
|
|||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
),
|
),
|
||||||
mediaSource = c.source,
|
mediaSource = c.source,
|
||||||
|
validationState = validationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is GalleryItemType.Other -> null
|
is GalleryItemType.Other -> null
|
||||||
@@ -200,6 +209,7 @@ class EventItemFactory(
|
|||||||
duration = null,
|
duration = null,
|
||||||
),
|
),
|
||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
|
validationState = validationState,
|
||||||
))
|
))
|
||||||
is FileMessageType -> listOf(MediaItem.File(
|
is FileMessageType -> listOf(MediaItem.File(
|
||||||
id = currentTimelineItem.uniqueId,
|
id = currentTimelineItem.uniqueId,
|
||||||
@@ -221,6 +231,7 @@ class EventItemFactory(
|
|||||||
duration = null,
|
duration = null,
|
||||||
),
|
),
|
||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
|
validationState = validationState,
|
||||||
// TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource
|
// TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource
|
||||||
))
|
))
|
||||||
is ImageMessageType -> listOf(MediaItem.Image(
|
is ImageMessageType -> listOf(MediaItem.Image(
|
||||||
@@ -245,6 +256,7 @@ class EventItemFactory(
|
|||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
thumbnailSource = type.info?.thumbnailSource,
|
thumbnailSource = type.info?.thumbnailSource,
|
||||||
blurHash = type.info?.blurhash,
|
blurHash = type.info?.blurhash,
|
||||||
|
validationState = validationState,
|
||||||
))
|
))
|
||||||
is StickerMessageType -> listOf(MediaItem.Image(
|
is StickerMessageType -> listOf(MediaItem.Image(
|
||||||
id = currentTimelineItem.uniqueId,
|
id = currentTimelineItem.uniqueId,
|
||||||
@@ -268,6 +280,7 @@ class EventItemFactory(
|
|||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
thumbnailSource = type.info?.thumbnailSource,
|
thumbnailSource = type.info?.thumbnailSource,
|
||||||
blurHash = type.info?.blurhash,
|
blurHash = type.info?.blurhash,
|
||||||
|
validationState = validationState,
|
||||||
))
|
))
|
||||||
is VideoMessageType -> listOf(MediaItem.Video(
|
is VideoMessageType -> listOf(MediaItem.Video(
|
||||||
id = currentTimelineItem.uniqueId,
|
id = currentTimelineItem.uniqueId,
|
||||||
@@ -291,6 +304,7 @@ class EventItemFactory(
|
|||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
thumbnailSource = type.info?.thumbnailSource,
|
thumbnailSource = type.info?.thumbnailSource,
|
||||||
blurHash = type.info?.blurhash,
|
blurHash = type.info?.blurhash,
|
||||||
|
validationState = validationState,
|
||||||
))
|
))
|
||||||
is VoiceMessageType -> listOf(MediaItem.Voice(
|
is VoiceMessageType -> listOf(MediaItem.Voice(
|
||||||
id = currentTimelineItem.uniqueId,
|
id = currentTimelineItem.uniqueId,
|
||||||
@@ -312,6 +326,7 @@ class EventItemFactory(
|
|||||||
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
||||||
),
|
),
|
||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
|
validationState = validationState,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
@@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import dev.zacsweers.metro.Assisted
|
import dev.zacsweers.metro.Assisted
|
||||||
import dev.zacsweers.metro.AssistedFactory
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
import dev.zacsweers.metro.AssistedInject
|
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.androidutils.R
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
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.mediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.impl.model.mediaPermissions
|
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.mediaSource
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.model.thumbnailSource
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
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
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
@@ -55,6 +60,7 @@ class MediaGalleryPresenter(
|
|||||||
private val mediaLoader: MatrixMediaLoader,
|
private val mediaLoader: MatrixMediaLoader,
|
||||||
private val localMediaActions: LocalMediaActions,
|
private val localMediaActions: LocalMediaActions,
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
|
private val contentScannerService: ContentScannerService,
|
||||||
) : Presenter<MediaGalleryState> {
|
) : Presenter<MediaGalleryState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
@@ -74,6 +80,17 @@ class MediaGalleryPresenter(
|
|||||||
|
|
||||||
val groupedMediaItems by remember {
|
val groupedMediaItems by remember {
|
||||||
mediaGalleryDataSource.groupedMediaItemsFlow()
|
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)
|
.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 {
|
private fun mediaActionsError(throwable: Throwable): Int {
|
||||||
return if (throwable is ActivityNotFoundException) {
|
return if (throwable is ActivityNotFoundException) {
|
||||||
R.string.error_no_compatible_app_found
|
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.components.media.WaveFormSamples
|
||||||
import io.element.android.libraries.designsystem.preview.ROOM_NAME
|
import io.element.android.libraries.designsystem.preview.ROOM_NAME
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
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.MediaBottomSheetState
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.aMediaBottomSheetStateDetails
|
import io.element.android.libraries.mediaviewer.impl.details.aMediaBottomSheetStateDetails
|
||||||
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
|
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
|
||||||
@@ -47,9 +49,20 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
|
|||||||
aMediaItemImage(id = UniqueId("3")),
|
aMediaItemImage(id = UniqueId("3")),
|
||||||
aMediaItemVideo(id = UniqueId("4")),
|
aMediaItemVideo(id = UniqueId("4")),
|
||||||
aMediaItemImage(id = UniqueId("5")),
|
aMediaItemImage(id = UniqueId("5")),
|
||||||
aMediaItemImage(id = UniqueId("6")),
|
aMediaItemImage(
|
||||||
aMediaItemImage(id = UniqueId("7")),
|
id = UniqueId("6"),
|
||||||
aMediaItemImage(id = UniqueId("8")),
|
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")),
|
aMediaItemImage(id = UniqueId("9")),
|
||||||
aMediaItemLoadingIndicator(),
|
aMediaItemLoadingIndicator(),
|
||||||
).toImmutableList()
|
).toImmutableList()
|
||||||
@@ -65,7 +78,7 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
|
|||||||
aGroupedMediaItems(
|
aGroupedMediaItems(
|
||||||
fileItems = listOf(
|
fileItems = listOf(
|
||||||
aMediaItemDateSeparator(id = UniqueId("0")),
|
aMediaItemDateSeparator(id = UniqueId("0")),
|
||||||
aMediaItemFile(id = UniqueId("1")),
|
aMediaItemFile(id = UniqueId("1"), validationState = NoopContentValidationState(ContentValidationValue.Loading)),
|
||||||
aMediaItemDateSeparator(
|
aMediaItemDateSeparator(
|
||||||
id = UniqueId("2"),
|
id = UniqueId("2"),
|
||||||
formattedDate = "September 2004",
|
formattedDate = "September 2004",
|
||||||
@@ -75,6 +88,15 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
|
|||||||
id = UniqueId("5"),
|
id = UniqueId("5"),
|
||||||
waveform = WaveFormSamples.realisticWaveForm,
|
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(),
|
aMediaItemLoadingIndicator(),
|
||||||
).toImmutableList()
|
).toImmutableList()
|
||||||
)
|
)
|
||||||
|
|||||||
+73
-10
@@ -9,14 +9,18 @@
|
|||||||
package io.element.android.libraries.mediaviewer.impl.gallery
|
package io.element.android.libraries.mediaviewer.impl.gallery
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
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.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.background.OnboardingBackground
|
||||||
import io.element.android.libraries.designsystem.components.BigIcon
|
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.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.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
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.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.LinearProgressIndicator
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.SegmentedButton
|
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.SnackbarHost
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
import io.element.android.libraries.matrix.ui.media.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.R
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet
|
import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet
|
||||||
@@ -315,6 +326,14 @@ private fun MediaGalleryFilesList(
|
|||||||
key = { it.id() },
|
key = { it.id() },
|
||||||
contentType = { it::class.java },
|
contentType = { it::class.java },
|
||||||
) { item ->
|
) { item ->
|
||||||
|
val contentValidationState = remember(item is MediaItem.Event) {
|
||||||
|
if (item is MediaItem.Event) {
|
||||||
|
item.validationState
|
||||||
|
} else {
|
||||||
|
NoopContentValidationState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val contentValidationValue by contentValidationState.collectOverallState()
|
||||||
when (item) {
|
when (item) {
|
||||||
is MediaItem.File -> FileItemView(
|
is MediaItem.File -> FileItemView(
|
||||||
modifier = Modifier.animateItem(),
|
modifier = Modifier.animateItem(),
|
||||||
@@ -323,6 +342,7 @@ private fun MediaGalleryFilesList(
|
|||||||
onLongClick = {
|
onLongClick = {
|
||||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||||
},
|
},
|
||||||
|
contentValidationState = contentValidationValue,
|
||||||
)
|
)
|
||||||
is MediaItem.Audio -> AudioItemView(
|
is MediaItem.Audio -> AudioItemView(
|
||||||
modifier = Modifier.animateItem(),
|
modifier = Modifier.animateItem(),
|
||||||
@@ -331,6 +351,7 @@ private fun MediaGalleryFilesList(
|
|||||||
onLongClick = {
|
onLongClick = {
|
||||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||||
},
|
},
|
||||||
|
contentValidationValue = contentValidationValue,
|
||||||
)
|
)
|
||||||
is MediaItem.Voice -> {
|
is MediaItem.Voice -> {
|
||||||
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(item)
|
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(item)
|
||||||
@@ -341,16 +362,17 @@ private fun MediaGalleryFilesList(
|
|||||||
onLongClick = {
|
onLongClick = {
|
||||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||||
},
|
},
|
||||||
|
contentValidationValue = contentValidationValue,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is MediaItem.Image,
|
||||||
|
is MediaItem.Video -> {
|
||||||
|
// Should not happen
|
||||||
|
}
|
||||||
is MediaItem.DateSeparator -> DateItemView(
|
is MediaItem.DateSeparator -> DateItemView(
|
||||||
modifier = Modifier.animateItem(),
|
modifier = Modifier.animateItem(),
|
||||||
item = item
|
item = item
|
||||||
)
|
)
|
||||||
is MediaItem.Image,
|
|
||||||
is MediaItem.Video -> {
|
|
||||||
// Should not happen
|
|
||||||
}
|
|
||||||
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
|
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
|
||||||
modifier = Modifier.animateItem(),
|
modifier = Modifier.animateItem(),
|
||||||
item = item,
|
item = item,
|
||||||
@@ -387,11 +409,48 @@ private fun MediaGalleryImageGrid(
|
|||||||
key = { it.id() },
|
key = { it.id() },
|
||||||
contentType = { it::class.java },
|
contentType = { it::class.java },
|
||||||
) { item ->
|
) { 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) {
|
when (item) {
|
||||||
is MediaItem.DateSeparator -> DateItemView(
|
is MediaItem.Event if !currentValidationState.isValidated() -> {
|
||||||
modifier = Modifier.animateItem(),
|
Box(modifier = blurHashBackgroundModifier.aspectRatio(1f), contentAlignment = Alignment.Center) {
|
||||||
item = item,
|
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 -> {
|
is MediaItem.Audio -> {
|
||||||
// Should not happen
|
// Should not happen
|
||||||
}
|
}
|
||||||
@@ -402,7 +461,7 @@ private fun MediaGalleryImageGrid(
|
|||||||
// Should not happen
|
// Should not happen
|
||||||
}
|
}
|
||||||
is MediaItem.Image -> ImageItemView(
|
is MediaItem.Image -> ImageItemView(
|
||||||
modifier = Modifier.animateItem(),
|
modifier = blurHashBackgroundModifier.animateItem(),
|
||||||
image = item,
|
image = item,
|
||||||
onClick = { onItemClick(item) },
|
onClick = { onItemClick(item) },
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
@@ -410,13 +469,17 @@ private fun MediaGalleryImageGrid(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
is MediaItem.Video -> VideoItemView(
|
is MediaItem.Video -> VideoItemView(
|
||||||
modifier = Modifier.animateItem(),
|
modifier = blurHashBackgroundModifier.animateItem(),
|
||||||
video = item,
|
video = item,
|
||||||
onClick = { onItemClick(item) },
|
onClick = { onItemClick(item) },
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
is MediaItem.DateSeparator -> DateItemView(
|
||||||
|
modifier = Modifier.animateItem(),
|
||||||
|
item = item,
|
||||||
|
)
|
||||||
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
|
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
|
||||||
modifier = Modifier.animateItem(),
|
modifier = Modifier.animateItem(),
|
||||||
item = item,
|
item = item,
|
||||||
|
|||||||
+30
-96
@@ -8,117 +8,38 @@
|
|||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
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.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.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
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.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.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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
|
||||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioItemView(
|
fun AudioItemView(
|
||||||
audio: MediaItem.Audio,
|
audio: MediaItem.Audio,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
|
contentValidationValue: ContentValidationValue,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
GalleryFileListItem(
|
||||||
modifier = modifier
|
modifier = modifier,
|
||||||
.fillMaxWidth()
|
contentValidationValue = contentValidationValue,
|
||||||
.padding(horizontal = 16.dp),
|
caption = audio.mediaInfo.caption,
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
GalleryFileListItemContent(
|
||||||
FilenameRow(
|
name = audio.mediaInfo.filename,
|
||||||
audio = audio,
|
formattedSize = audio.mediaInfo.formattedFileSize,
|
||||||
|
icon = CompoundIcons.Audio(),
|
||||||
|
isValidating = contentValidationValue.isLoading(),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
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(
|
internal fun AudioItemViewPreview(
|
||||||
@PreviewParameter(MediaItemAudioProvider::class) audio: MediaItem.Audio,
|
@PreviewParameter(MediaItemAudioProvider::class) audio: MediaItem.Audio,
|
||||||
) = ElementPreview {
|
) = ElementPreview {
|
||||||
AudioItemView(
|
val states = remember {
|
||||||
audio = audio,
|
listOf(
|
||||||
onClick = {},
|
ContentValidationValue.Valid,
|
||||||
onLongClick = {},
|
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
|
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.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.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
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.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.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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
|
||||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FileItemView(
|
fun FileItemView(
|
||||||
file: MediaItem.File,
|
file: MediaItem.File,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
|
contentValidationState: ContentValidationValue,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
GalleryFileListItem(
|
||||||
modifier = modifier
|
modifier = modifier,
|
||||||
.fillMaxWidth()
|
contentValidationValue = contentValidationState,
|
||||||
.padding(horizontal = 16.dp),
|
caption = file.mediaInfo.caption
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
GalleryFileListItemContent(
|
||||||
FilenameRow(
|
name = file.mediaInfo.filename,
|
||||||
file = file,
|
formattedSize = file.mediaInfo.formattedFileSize,
|
||||||
|
icon = CompoundIcons.Attachment(),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
isValidating = contentValidationState.isLoading(),
|
||||||
onLongClick = onLongClick,
|
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(
|
internal fun FileItemViewPreview(
|
||||||
@PreviewParameter(MediaItemFileProvider::class) file: MediaItem.File,
|
@PreviewParameter(MediaItemFileProvider::class) file: MediaItem.File,
|
||||||
) = ElementPreview {
|
) = ElementPreview {
|
||||||
FileItemView(
|
val states = remember {
|
||||||
file = file,
|
listOf(
|
||||||
onClick = {},
|
ContentValidationValue.Valid,
|
||||||
onLongClick = {},
|
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",
|
caption = "A caption",
|
||||||
),
|
),
|
||||||
aMediaItemAudio(
|
aMediaItemAudio(
|
||||||
caption = loremIpsum,
|
caption = loremIpsum.take(80),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ class MediaItemFileProvider : PreviewParameterProvider<MediaItem.File> {
|
|||||||
caption = "A caption",
|
caption = "A caption",
|
||||||
),
|
),
|
||||||
aMediaItemFile(
|
aMediaItemFile(
|
||||||
caption = loremIpsum,
|
caption = loremIpsum.take(80),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> {
|
|||||||
caption = "A caption",
|
caption = "A caption",
|
||||||
),
|
),
|
||||||
aMediaItemVoice(
|
aMediaItemVoice(
|
||||||
caption = loremIpsum,
|
caption = loremIpsum.take(80),
|
||||||
),
|
),
|
||||||
aMediaItemVoice(
|
aMediaItemVoice(
|
||||||
waveform = emptyList(),
|
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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.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.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
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.MediaItem
|
||||||
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
|
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
@@ -64,26 +64,20 @@ fun VoiceItemView(
|
|||||||
state: VoiceMessageState,
|
state: VoiceMessageState,
|
||||||
voice: MediaItem.Voice,
|
voice: MediaItem.Voice,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
|
contentValidationValue: ContentValidationValue,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
GalleryFileListItem(
|
||||||
modifier = modifier
|
modifier = modifier,
|
||||||
.fillMaxWidth()
|
contentValidationValue = contentValidationValue,
|
||||||
.padding(horizontal = 16.dp),
|
caption = voice.mediaInfo.caption,
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
VoiceInfoRow(
|
VoiceInfoRow(
|
||||||
state = state,
|
state = state,
|
||||||
voice = voice,
|
voice = voice,
|
||||||
|
isValidating = contentValidationValue.isLoading(),
|
||||||
onLongClick = onLongClick,
|
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(
|
private fun VoiceInfoRow(
|
||||||
state: VoiceMessageState,
|
state: VoiceMessageState,
|
||||||
voice: MediaItem.Voice,
|
voice: MediaItem.Voice,
|
||||||
|
isValidating: Boolean,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
fun playPause() {
|
fun playPause() {
|
||||||
@@ -114,12 +109,21 @@ private fun VoiceInfoRow(
|
|||||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
when (state.buttonType) {
|
if (isValidating) {
|
||||||
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
|
CustomIconButton(onClick = {}, enabled = false) {
|
||||||
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
|
CircularProgressIndicator(
|
||||||
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
|
strokeWidth = 2.dp,
|
||||||
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
|
modifier = Modifier.size(16.dp)
|
||||||
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
|
)
|
||||||
|
}
|
||||||
|
} 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))
|
Spacer(Modifier.width(8.dp))
|
||||||
Column(
|
Column(
|
||||||
@@ -128,7 +132,7 @@ private fun VoiceInfoRow(
|
|||||||
) {
|
) {
|
||||||
PlaybackSpeedButton(
|
PlaybackSpeedButton(
|
||||||
speed = state.playbackSpeed,
|
speed = state.playbackSpeed,
|
||||||
onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) },
|
onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) }.takeIf { !isValidating } ?: {},
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
|
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
|
||||||
@@ -149,7 +153,7 @@ private fun VoiceInfoRow(
|
|||||||
onSeek = {
|
onSeek = {
|
||||||
state.eventSink(VoiceMessageEvent.Seek(it))
|
state.eventSink(VoiceMessageEvent.Seek(it))
|
||||||
},
|
},
|
||||||
seekEnabled = true,
|
seekEnabled = !isValidating,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,11 +281,24 @@ private fun CustomIconButton(
|
|||||||
internal fun VoiceItemViewPreview(
|
internal fun VoiceItemViewPreview(
|
||||||
@PreviewParameter(MediaItemVoiceProvider::class) voice: MediaItem.Voice,
|
@PreviewParameter(MediaItemVoiceProvider::class) voice: MediaItem.Voice,
|
||||||
) = ElementPreview {
|
) = ElementPreview {
|
||||||
VoiceItemView(
|
val states = remember {
|
||||||
state = aVoiceMessageState(),
|
listOf(
|
||||||
voice = voice,
|
ContentValidationValue.Valid,
|
||||||
onLongClick = {},
|
ContentValidationValue.Loading,
|
||||||
)
|
ContentValidationValue.Invalid,
|
||||||
|
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
for (state in states) {
|
||||||
|
VoiceItemView(
|
||||||
|
state = aVoiceMessageState(),
|
||||||
|
voice = voice,
|
||||||
|
onLongClick = {},
|
||||||
|
contentValidationValue = state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@@ -289,9 +306,22 @@ internal fun VoiceItemViewPreview(
|
|||||||
internal fun VoiceItemViewPlayPreview(
|
internal fun VoiceItemViewPlayPreview(
|
||||||
@PreviewParameter(VoiceMessageStateProvider::class) state: VoiceMessageState,
|
@PreviewParameter(VoiceMessageStateProvider::class) state: VoiceMessageState,
|
||||||
) = ElementPreview {
|
) = ElementPreview {
|
||||||
VoiceItemView(
|
val validationState = remember {
|
||||||
state = state,
|
listOf(
|
||||||
voice = aMediaItemVoice(),
|
ContentValidationValue.Valid,
|
||||||
onLongClick = {},
|
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(
|
if (localMedia != null) {
|
||||||
state = mediaPlayerControllerState,
|
MediaPlayerControllerView(
|
||||||
onTogglePlay = {
|
state = mediaPlayerControllerState,
|
||||||
autoHideController++
|
onTogglePlay = {
|
||||||
exoPlayer.togglePlay()
|
autoHideController++
|
||||||
},
|
exoPlayer.togglePlay()
|
||||||
onSeekChange = {
|
},
|
||||||
autoHideController++
|
onSeekChange = {
|
||||||
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
autoHideController++
|
||||||
seekingToMillis = it.toLong(),
|
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||||
)
|
seekingToMillis = it.toLong(),
|
||||||
exoPlayer.seekToEnsurePlaying(it.toLong())
|
)
|
||||||
},
|
exoPlayer.seekToEnsurePlaying(it.toLong())
|
||||||
onToggleMute = {
|
},
|
||||||
autoHideController++
|
onToggleMute = {
|
||||||
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
|
autoHideController++
|
||||||
},
|
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
|
||||||
audioFocus = audioFocus,
|
},
|
||||||
modifier = Modifier
|
audioFocus = audioFocus,
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.fillMaxWidth()
|
||||||
.padding(bottom = bottomPaddingInPixels.toDp()),
|
.align(Alignment.BottomCenter)
|
||||||
)
|
.padding(bottom = bottomPaddingInPixels.toDp()),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(exoPlayer.isPlaying) {
|
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.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
|
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||||
|
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
|
|
||||||
sealed interface MediaItem {
|
sealed interface MediaItem {
|
||||||
@@ -27,7 +29,9 @@ sealed interface MediaItem {
|
|||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
) : MediaItem
|
) : MediaItem
|
||||||
|
|
||||||
sealed interface Event : MediaItem
|
sealed interface Event : MediaItem {
|
||||||
|
val validationState: ContentValidationState
|
||||||
|
}
|
||||||
|
|
||||||
data class Image(
|
data class Image(
|
||||||
val id: UniqueId,
|
val id: UniqueId,
|
||||||
@@ -36,6 +40,7 @@ sealed interface MediaItem {
|
|||||||
val mediaSource: MediaSource,
|
val mediaSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
val blurHash: String?,
|
val blurHash: String?,
|
||||||
|
override val validationState: ContentValidationState,
|
||||||
) : Event {
|
) : Event {
|
||||||
val thumbnailMediaRequestData: MediaRequestData
|
val thumbnailMediaRequestData: MediaRequestData
|
||||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
||||||
@@ -48,6 +53,7 @@ sealed interface MediaItem {
|
|||||||
val mediaSource: MediaSource,
|
val mediaSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
val blurHash: String?,
|
val blurHash: String?,
|
||||||
|
override val validationState: ContentValidationState,
|
||||||
) : Event {
|
) : Event {
|
||||||
val thumbnailMediaRequestData: MediaRequestData
|
val thumbnailMediaRequestData: MediaRequestData
|
||||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
||||||
@@ -58,6 +64,7 @@ sealed interface MediaItem {
|
|||||||
val eventId: EventId?,
|
val eventId: EventId?,
|
||||||
val mediaInfo: MediaInfo,
|
val mediaInfo: MediaInfo,
|
||||||
val mediaSource: MediaSource,
|
val mediaSource: MediaSource,
|
||||||
|
override val validationState: ContentValidationState,
|
||||||
) : Event
|
) : Event
|
||||||
|
|
||||||
data class Voice(
|
data class Voice(
|
||||||
@@ -65,6 +72,7 @@ sealed interface MediaItem {
|
|||||||
val eventId: EventId?,
|
val eventId: EventId?,
|
||||||
val mediaInfo: MediaInfo,
|
val mediaInfo: MediaInfo,
|
||||||
val mediaSource: MediaSource,
|
val mediaSource: MediaSource,
|
||||||
|
override val validationState: ContentValidationState,
|
||||||
) : Event
|
) : Event
|
||||||
|
|
||||||
data class File(
|
data class File(
|
||||||
@@ -72,6 +80,7 @@ sealed interface MediaItem {
|
|||||||
val eventId: EventId?,
|
val eventId: EventId?,
|
||||||
val mediaInfo: MediaInfo,
|
val mediaInfo: MediaInfo,
|
||||||
val mediaSource: MediaSource,
|
val mediaSource: MediaSource,
|
||||||
|
override val validationState: ContentValidationState,
|
||||||
) : Event
|
) : Event
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,3 +143,8 @@ fun MediaItem.Event.blurHash(): String? {
|
|||||||
else -> null
|
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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.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.aPdfMediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
|
import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
|
||||||
@@ -25,6 +27,8 @@ fun aMediaItemImage(
|
|||||||
eventId: EventId? = null,
|
eventId: EventId? = null,
|
||||||
senderId: UserId? = null,
|
senderId: UserId? = null,
|
||||||
mediaSourceUrl: String = "",
|
mediaSourceUrl: String = "",
|
||||||
|
validationState: ContentValidationState = NoopContentValidationState(),
|
||||||
|
blurHash: String? = null,
|
||||||
): MediaItem.Image {
|
): MediaItem.Image {
|
||||||
return MediaItem.Image(
|
return MediaItem.Image(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -34,7 +38,8 @@ fun aMediaItemImage(
|
|||||||
),
|
),
|
||||||
mediaSource = MediaSource(mediaSourceUrl),
|
mediaSource = MediaSource(mediaSourceUrl),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
blurHash = null,
|
blurHash = blurHash,
|
||||||
|
validationState = validationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +47,7 @@ fun aMediaItemVideo(
|
|||||||
id: UniqueId = UniqueId("videoId"),
|
id: UniqueId = UniqueId("videoId"),
|
||||||
mediaSource: MediaSource = MediaSource(""),
|
mediaSource: MediaSource = MediaSource(""),
|
||||||
duration: String? = "1:23",
|
duration: String? = "1:23",
|
||||||
|
validationState: ContentValidationState = NoopContentValidationState(),
|
||||||
): MediaItem.Video {
|
): MediaItem.Video {
|
||||||
return MediaItem.Video(
|
return MediaItem.Video(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -52,6 +58,7 @@ fun aMediaItemVideo(
|
|||||||
mediaSource = mediaSource,
|
mediaSource = mediaSource,
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
blurHash = null,
|
blurHash = null,
|
||||||
|
validationState = validationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +67,7 @@ fun aMediaItemFile(
|
|||||||
eventId: EventId? = null,
|
eventId: EventId? = null,
|
||||||
filename: String = "filename",
|
filename: String = "filename",
|
||||||
caption: String? = null,
|
caption: String? = null,
|
||||||
|
validationState: ContentValidationState = NoopContentValidationState(),
|
||||||
): MediaItem.File {
|
): MediaItem.File {
|
||||||
return MediaItem.File(
|
return MediaItem.File(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -69,6 +77,7 @@ fun aMediaItemFile(
|
|||||||
caption = caption,
|
caption = caption,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
|
validationState = validationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +86,7 @@ fun aMediaItemAudio(
|
|||||||
eventId: EventId? = null,
|
eventId: EventId? = null,
|
||||||
filename: String = "filename",
|
filename: String = "filename",
|
||||||
caption: String? = null,
|
caption: String? = null,
|
||||||
|
validationState: ContentValidationState = NoopContentValidationState(),
|
||||||
): MediaItem.Audio {
|
): MediaItem.Audio {
|
||||||
return MediaItem.Audio(
|
return MediaItem.Audio(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -86,6 +96,7 @@ fun aMediaItemAudio(
|
|||||||
caption = caption,
|
caption = caption,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
|
validationState = validationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +106,7 @@ fun aMediaItemVoice(
|
|||||||
caption: String? = null,
|
caption: String? = null,
|
||||||
duration: String? = "1:23",
|
duration: String? = "1:23",
|
||||||
waveform: List<Float> = WaveFormSamples.realisticWaveForm,
|
waveform: List<Float> = WaveFormSamples.realisticWaveForm,
|
||||||
|
validationState: ContentValidationState = NoopContentValidationState(),
|
||||||
): MediaItem.Voice {
|
): MediaItem.Voice {
|
||||||
return MediaItem.Voice(
|
return MediaItem.Voice(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -106,6 +118,7 @@ fun aMediaItemVoice(
|
|||||||
waveForm = waveform,
|
waveForm = waveform,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
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.GalleryInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.GalleryItemData
|
import io.element.android.libraries.mediaviewer.api.GalleryItemData
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
@@ -37,6 +38,7 @@ class GalleryMediaGalleryDataSource(
|
|||||||
eventId: EventId?,
|
eventId: EventId?,
|
||||||
galleryItems: List<GalleryItemData>,
|
galleryItems: List<GalleryItemData>,
|
||||||
galleryInfo: GalleryInfo,
|
galleryInfo: GalleryInfo,
|
||||||
|
contentValidationState: ContentValidationState,
|
||||||
): GalleryMediaGalleryDataSource {
|
): GalleryMediaGalleryDataSource {
|
||||||
val mixedItems = mutableListOf<MediaItem.Event>()
|
val mixedItems = mutableListOf<MediaItem.Event>()
|
||||||
galleryItems.forEachIndexed { index, galleryItem ->
|
galleryItems.forEachIndexed { index, galleryItem ->
|
||||||
@@ -64,18 +66,21 @@ class GalleryMediaGalleryDataSource(
|
|||||||
mediaSource = galleryItem.mediaSource,
|
mediaSource = galleryItem.mediaSource,
|
||||||
thumbnailSource = galleryItem.thumbnailSource,
|
thumbnailSource = galleryItem.thumbnailSource,
|
||||||
blurHash = galleryItem.blurHash,
|
blurHash = galleryItem.blurHash,
|
||||||
|
validationState = contentValidationState,
|
||||||
)
|
)
|
||||||
GalleryItemData.Type.Audio -> MediaItem.Audio(
|
GalleryItemData.Type.Audio -> MediaItem.Audio(
|
||||||
id = id,
|
id = id,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
mediaInfo = itemMediaInfo,
|
mediaInfo = itemMediaInfo,
|
||||||
mediaSource = galleryItem.mediaSource,
|
mediaSource = galleryItem.mediaSource,
|
||||||
|
validationState = contentValidationState,
|
||||||
)
|
)
|
||||||
GalleryItemData.Type.File -> MediaItem.File(
|
GalleryItemData.Type.File -> MediaItem.File(
|
||||||
id = id,
|
id = id,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
mediaInfo = itemMediaInfo,
|
mediaInfo = itemMediaInfo,
|
||||||
mediaSource = galleryItem.mediaSource,
|
mediaSource = galleryItem.mediaSource,
|
||||||
|
validationState = contentValidationState,
|
||||||
)
|
)
|
||||||
GalleryItemData.Type.Image -> MediaItem.Image(
|
GalleryItemData.Type.Image -> MediaItem.Image(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -84,6 +89,7 @@ class GalleryMediaGalleryDataSource(
|
|||||||
mediaSource = galleryItem.mediaSource,
|
mediaSource = galleryItem.mediaSource,
|
||||||
thumbnailSource = galleryItem.thumbnailSource,
|
thumbnailSource = galleryItem.thumbnailSource,
|
||||||
blurHash = galleryItem.blurHash,
|
blurHash = galleryItem.blurHash,
|
||||||
|
validationState = contentValidationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
mixedItems.add(mediaItem)
|
mixedItems.add(mediaItem)
|
||||||
|
|||||||
+27
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
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.architecture.AsyncData
|
||||||
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
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.MediaFile
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.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.MediaViewerEntryPoint.MediaViewerMode
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||||
@@ -57,6 +61,8 @@ class MediaViewerDataSource(
|
|||||||
private val localMediaFactory: LocalMediaFactory,
|
private val localMediaFactory: LocalMediaFactory,
|
||||||
private val systemClock: SystemClock,
|
private val systemClock: SystemClock,
|
||||||
private val pagerKeysHandler: PagerKeysHandler,
|
private val pagerKeysHandler: PagerKeysHandler,
|
||||||
|
private val contentScannerService: ContentScannerService,
|
||||||
|
private val contentValidationCache: EventContentValidationCache,
|
||||||
) {
|
) {
|
||||||
// List of media files that are currently being loaded
|
// List of media files that are currently being loaded
|
||||||
private val mediaFiles: ConcurrentHashMap<MediaSource, MediaFile> = ConcurrentHashMap()
|
private val mediaFiles: ConcurrentHashMap<MediaSource, MediaFile> = ConcurrentHashMap()
|
||||||
@@ -71,6 +77,9 @@ class MediaViewerDataSource(
|
|||||||
private val localMediaStates: MutableMap<String, MutableState<AsyncData<LocalMedia>>> =
|
private val localMediaStates: MutableMap<String, MutableState<AsyncData<LocalMedia>>> =
|
||||||
mutableMapOf()
|
mutableMapOf()
|
||||||
|
|
||||||
|
private val mediaValidationState: MutableMap<String, ContentValidationState> =
|
||||||
|
mutableMapOf()
|
||||||
|
|
||||||
fun setup(coroutineScope: CoroutineScope) {
|
fun setup(coroutineScope: CoroutineScope) {
|
||||||
galleryDataSource.start(coroutineScope)
|
galleryDataSource.start(coroutineScope)
|
||||||
}
|
}
|
||||||
@@ -162,6 +171,9 @@ class MediaViewerDataSource(
|
|||||||
val localMedia = localMediaStates.getOrPut(sourceUrl) {
|
val localMedia = localMediaStates.getOrPut(sourceUrl) {
|
||||||
mutableStateOf(AsyncData.Uninitialized)
|
mutableStateOf(AsyncData.Uninitialized)
|
||||||
}
|
}
|
||||||
|
val validationState = mediaValidationState.getOrPut(sourceUrl) {
|
||||||
|
mediaItem.eventId()?.let { contentValidationCache[it] } ?: DefaultContentValidationState()
|
||||||
|
}
|
||||||
add(
|
add(
|
||||||
MediaViewerPageData.MediaViewerData(
|
MediaViewerPageData.MediaViewerData(
|
||||||
eventId = mediaItem.eventId(),
|
eventId = mediaItem.eventId(),
|
||||||
@@ -170,6 +182,7 @@ class MediaViewerDataSource(
|
|||||||
thumbnailSource = mediaItem.thumbnailSource(),
|
thumbnailSource = mediaItem.thumbnailSource(),
|
||||||
downloadedMedia = localMedia,
|
downloadedMedia = localMedia,
|
||||||
pagerKey = pagerKeysHandler.getKey(mediaItem),
|
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) {
|
fun cancelLoadingMedia(data: MediaViewerPageData.MediaViewerData) {
|
||||||
if (localMediaStates[data.mediaSource.safeUrl]?.value?.isLoading() == true) {
|
if (localMediaStates[data.mediaSource.safeUrl]?.value?.isLoading() == true) {
|
||||||
Timber.d("cancelLoadingMedia for ${data.eventId}")
|
Timber.d("cancelLoadingMedia for ${data.eventId}")
|
||||||
|
|||||||
+2
@@ -9,6 +9,7 @@
|
|||||||
package io.element.android.libraries.mediaviewer.impl.viewer
|
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
|
||||||
sealed interface MediaViewerEvent {
|
sealed interface MediaViewerEvent {
|
||||||
@@ -30,4 +31,5 @@ sealed interface MediaViewerEvent {
|
|||||||
data class OnNavigateTo(val index: Int) : MediaViewerEvent
|
data class OnNavigateTo(val index: Int) : MediaViewerEvent
|
||||||
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvent
|
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvent
|
||||||
data class CancelLoadingMedia(val data: MediaViewerPageData.MediaViewerData) : 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.annotations.ContributesNode
|
||||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||||
import io.element.android.compound.theme.ForcedDarkElementTheme
|
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.enterprise.api.EnterpriseService
|
||||||
import io.element.android.features.viewfolder.api.TextFileViewer
|
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||||
import io.element.android.libraries.architecture.callback
|
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.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
import io.element.android.libraries.matrix.ui.media.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.MediaViewerEntryPoint
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||||
import io.element.android.libraries.mediaviewer.impl.datasource.FocusedTimelineMediaGalleryDataSourceFactory
|
import io.element.android.libraries.mediaviewer.impl.datasource.FocusedTimelineMediaGalleryDataSourceFactory
|
||||||
@@ -57,6 +60,8 @@ class MediaViewerNode(
|
|||||||
private val audioFocus: AudioFocus,
|
private val audioFocus: AudioFocus,
|
||||||
private val sessionId: SessionId,
|
private val sessionId: SessionId,
|
||||||
private val enterpriseService: EnterpriseService,
|
private val enterpriseService: EnterpriseService,
|
||||||
|
private val contentScannerService: ContentScannerService,
|
||||||
|
private val contentValidationCache: EventContentValidationCache,
|
||||||
) : Node(buildContext, plugins = plugins),
|
) : Node(buildContext, plugins = plugins),
|
||||||
MediaViewerNavigator {
|
MediaViewerNavigator {
|
||||||
private val callback: MediaViewerEntryPoint.Callback = callback()
|
private val callback: MediaViewerEntryPoint.Callback = callback()
|
||||||
@@ -76,12 +81,13 @@ class MediaViewerNode(
|
|||||||
|
|
||||||
private val mediaGallerySource = when (inputs) {
|
private val mediaGallerySource = when (inputs) {
|
||||||
is MediaViewerEntryPoint.Params.Avatar ->
|
is MediaViewerEntryPoint.Params.Avatar ->
|
||||||
SingleMediaGalleryDataSource.createFrom(inputs)
|
SingleMediaGalleryDataSource.createFrom(inputs, contentValidationCache)
|
||||||
is MediaViewerEntryPoint.Params.EventGallery ->
|
is MediaViewerEntryPoint.Params.EventGallery ->
|
||||||
GalleryMediaGalleryDataSource.createFrom(
|
GalleryMediaGalleryDataSource.createFrom(
|
||||||
eventId = inputs.eventId,
|
eventId = inputs.eventId,
|
||||||
galleryItems = inputs.galleryItems,
|
galleryItems = inputs.galleryItems,
|
||||||
galleryInfo = inputs.galleryInfo,
|
galleryInfo = inputs.galleryInfo,
|
||||||
|
contentValidationState = inputs.eventId?.let { contentValidationCache[it] } ?: NoopContentValidationState(),
|
||||||
)
|
)
|
||||||
is MediaViewerEntryPoint.Params.RoomMedia -> {
|
is MediaViewerEntryPoint.Params.RoomMedia -> {
|
||||||
val eventId = inputs.eventId
|
val eventId = inputs.eventId
|
||||||
@@ -103,7 +109,7 @@ class MediaViewerNode(
|
|||||||
} else {
|
} else {
|
||||||
focusedTimelineMediaGalleryDataSourceFactory.createFor(
|
focusedTimelineMediaGalleryDataSourceFactory.createFor(
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
mediaItem = inputs.toMediaItem(),
|
mediaItem = inputs.toMediaItem(contentValidationCache[eventId]),
|
||||||
onlyPinnedEvents = false,
|
onlyPinnedEvents = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -111,7 +117,7 @@ class MediaViewerNode(
|
|||||||
Timeline.Mode.PinnedEvents -> {
|
Timeline.Mode.PinnedEvents -> {
|
||||||
focusedTimelineMediaGalleryDataSourceFactory.createFor(
|
focusedTimelineMediaGalleryDataSourceFactory.createFor(
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
mediaItem = inputs.toMediaItem(),
|
mediaItem = inputs.toMediaItem(contentValidationCache[eventId]),
|
||||||
onlyPinnedEvents = true,
|
onlyPinnedEvents = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -142,6 +148,8 @@ class MediaViewerNode(
|
|||||||
localMediaFactory = localMediaFactory,
|
localMediaFactory = localMediaFactory,
|
||||||
systemClock = systemClock,
|
systemClock = systemClock,
|
||||||
pagerKeysHandler = pagerKeysHandler,
|
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.mediaviewer.impl.model.mediaPermissions
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import io.element.android.libraries.androidutils.R as UtilsR
|
import io.element.android.libraries.androidutils.R as UtilsR
|
||||||
@@ -70,6 +72,7 @@ class MediaViewerPresenter(
|
|||||||
private val eventId = inputs.eventId()
|
private val eventId = inputs.eventId()
|
||||||
private val mediaSource = inputs.mediaSource()
|
private val mediaSource = inputs.mediaSource()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): MediaViewerState {
|
override fun present(): MediaViewerState {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
@@ -93,7 +96,7 @@ class MediaViewerPresenter(
|
|||||||
// Restore index based on the eventId after the initial items have been loaded
|
// Restore index based on the eventId after the initial items have been loaded
|
||||||
currentIndex.intValue = dataSource.findEventIndex(eventId, mediaSource) ?: 0
|
currentIndex.intValue = dataSource.findEventIndex(eventId, mediaSource) ?: 0
|
||||||
}
|
}
|
||||||
value = new
|
value = new.toImmutableList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +194,7 @@ class MediaViewerPresenter(
|
|||||||
is MediaViewerEvent.LoadMore -> coroutineScope.launch {
|
is MediaViewerEvent.LoadMore -> coroutineScope.launch {
|
||||||
dataSource.loadMore(event.direction)
|
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.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.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.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||||
@@ -51,6 +52,7 @@ sealed interface MediaViewerPageData {
|
|||||||
val mediaSource: MediaSource,
|
val mediaSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
val downloadedMedia: State<AsyncData<LocalMedia>>,
|
val downloadedMedia: State<AsyncData<LocalMedia>>,
|
||||||
|
val validationState: ContentValidationState,
|
||||||
override val pagerKey: Long,
|
override val pagerKey: Long,
|
||||||
) : MediaViewerPageData
|
) : 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.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.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.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.aTxtMediaInfo
|
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,
|
downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
|
||||||
mediaInfo: MediaInfo = anImageMediaInfo(),
|
mediaInfo: MediaInfo = anImageMediaInfo(),
|
||||||
mediaSource: MediaSource = MediaSource(""),
|
mediaSource: MediaSource = MediaSource(""),
|
||||||
|
validationState: ContentValidationState = NoopContentValidationState(),
|
||||||
): MediaViewerPageData.MediaViewerData = MediaViewerPageData.MediaViewerData(
|
): MediaViewerPageData.MediaViewerData = MediaViewerPageData.MediaViewerData(
|
||||||
eventId = null,
|
eventId = null,
|
||||||
mediaInfo = mediaInfo,
|
mediaInfo = mediaInfo,
|
||||||
@@ -300,6 +332,7 @@ fun aMediaViewerPageData(
|
|||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
downloadedMedia = mutableStateOf(downloadedMedia),
|
downloadedMedia = mutableStateOf(downloadedMedia),
|
||||||
pagerKey = 0L,
|
pagerKey = 0L,
|
||||||
|
validationState = validationState,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aMediaViewerState(
|
fun aMediaViewerState(
|
||||||
|
|||||||
+84
-33
@@ -12,11 +12,14 @@ package io.element.android.libraries.mediaviewer.impl.viewer
|
|||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.architecture.AsyncData
|
||||||
import io.element.android.libraries.audio.api.AudioFocus
|
import io.element.android.libraries.audio.api.AudioFocus
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
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.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.async.AsyncLoading
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||||
@@ -176,7 +182,7 @@ fun MediaViewerView(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) {
|
) { padding ->
|
||||||
val pagerState = rememberPagerState(state.currentIndex, 0f) {
|
val pagerState = rememberPagerState(state.currentIndex, 0f) {
|
||||||
state.listData.size
|
state.listData.size
|
||||||
}
|
}
|
||||||
@@ -211,17 +217,33 @@ fun MediaViewerView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
is MediaViewerPageData.MediaViewerData -> {
|
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) }
|
var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) }
|
||||||
Box(
|
val loadMediaOnVisibilityChangedModifier = remember(isMediaValid) {
|
||||||
modifier = Modifier
|
if (isMediaValid == true) {
|
||||||
.onVisibilityChanged(minDurationMs = 200L) { isVisible ->
|
Modifier.onVisibilityChanged(minDurationMs = 200L) { isVisible ->
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
state.eventSink(MediaViewerEvent.LoadMedia(dataForPage))
|
state.eventSink(MediaViewerEvent.LoadMedia(dataForPage))
|
||||||
} else {
|
} else {
|
||||||
state.eventSink(MediaViewerEvent.CancelLoadingMedia(dataForPage))
|
state.eventSink(MediaViewerEvent.CancelLoadingMedia(dataForPage))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fillMaxSize()
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = loadMediaOnVisibilityChangedModifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
val isDisplayed = remember(pagerState.settledPage) {
|
val isDisplayed = remember(pagerState.settledPage) {
|
||||||
// This 'item provider' lambda will be called when the data source changes with an outdated `settlePage` value
|
// This 'item provider' lambda will be called when the data source changes with an outdated `settlePage` value
|
||||||
@@ -232,6 +254,7 @@ fun MediaViewerView(
|
|||||||
MediaViewerPage(
|
MediaViewerPage(
|
||||||
isDisplayed = isDisplayed,
|
isDisplayed = isDisplayed,
|
||||||
showOverlay = showOverlay,
|
showOverlay = showOverlay,
|
||||||
|
containerPadding = padding,
|
||||||
bottomPaddingInPixels = (bottomPaddingInPixels - navigationBarPadding).coerceAtLeast(0),
|
bottomPaddingInPixels = (bottomPaddingInPixels - navigationBarPadding).coerceAtLeast(0),
|
||||||
data = dataForPage,
|
data = dataForPage,
|
||||||
textFileViewer = textFileViewer,
|
textFileViewer = textFileViewer,
|
||||||
@@ -333,6 +356,7 @@ private fun MediaViewerPage(
|
|||||||
isDisplayed: Boolean,
|
isDisplayed: Boolean,
|
||||||
showOverlay: Boolean,
|
showOverlay: Boolean,
|
||||||
bottomPaddingInPixels: Int,
|
bottomPaddingInPixels: Int,
|
||||||
|
containerPadding: PaddingValues,
|
||||||
data: MediaViewerPageData.MediaViewerData,
|
data: MediaViewerPageData.MediaViewerData,
|
||||||
textFileViewer: TextFileViewer,
|
textFileViewer: TextFileViewer,
|
||||||
isUserSelected: Boolean,
|
isUserSelected: Boolean,
|
||||||
@@ -365,6 +389,25 @@ private fun MediaViewerPage(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.navigationBarsPadding()
|
.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) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
val zoomableState = rememberZoomableState(
|
val zoomableState = rememberZoomableState(
|
||||||
zoomSpec = ZoomSpec(maxZoomFactor = 4f, overzoomEffect = OverzoomEffect.NoLimits)
|
zoomSpec = ZoomSpec(maxZoomFactor = 4f, overzoomEffect = OverzoomEffect.NoLimits)
|
||||||
@@ -380,29 +423,44 @@ private fun MediaViewerPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalMediaView(
|
val isMediaValid = data.validationState.getCurrentMediaState(data.mediaSource.safeUrl).isValid()
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
isDisplayed = isDisplayed,
|
if (isMediaValid == false) {
|
||||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
Column(
|
||||||
localMediaViewState = localMediaViewState,
|
modifier = Modifier.fillMaxSize(),
|
||||||
localMedia = downloadedMedia.dataOrNull(),
|
verticalArrangement = Arrangement.Center,
|
||||||
mediaInfo = data.mediaInfo,
|
) {
|
||||||
textFileViewer = textFileViewer,
|
IconTitleSubtitleMolecule(
|
||||||
onClick = {
|
iconStyle = BigIcon.Style.AlertSolid,
|
||||||
if (playableState is PlayableState.NotPlayable) {
|
title = "The file is not safe",
|
||||||
currentOnShowOverlayChange(!currentShowOverlay)
|
subTitle = "Preview and download have been disabled.",
|
||||||
}
|
)
|
||||||
},
|
}
|
||||||
onOpenWith = onOpenWith,
|
} else {
|
||||||
isUserSelected = isUserSelected,
|
LocalMediaView(
|
||||||
audioFocus = audioFocus,
|
modifier = Modifier.fillMaxSize(),
|
||||||
forPreview = false,
|
isDisplayed = isDisplayed,
|
||||||
)
|
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||||
if (showThumbnail) {
|
localMediaViewState = localMediaViewState,
|
||||||
ThumbnailView(
|
localMedia = downloadedMedia.dataOrNull(),
|
||||||
mediaInfo = data.mediaInfo,
|
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) {
|
if (showError) {
|
||||||
ErrorView(
|
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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
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.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||||
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
|
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
|
||||||
@@ -27,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
|
|||||||
|
|
||||||
class SingleMediaGalleryDataSource(
|
class SingleMediaGalleryDataSource(
|
||||||
private val data: GroupedMediaItems,
|
private val data: GroupedMediaItems,
|
||||||
|
private val contentValidationCache: EventContentValidationCache,
|
||||||
) : MediaGalleryDataSource {
|
) : MediaGalleryDataSource {
|
||||||
override fun start(coroutineScope: CoroutineScope) = Unit
|
override fun start(coroutineScope: CoroutineScope) = Unit
|
||||||
override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data))
|
override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data))
|
||||||
@@ -38,7 +42,10 @@ class SingleMediaGalleryDataSource(
|
|||||||
override suspend fun deleteItem(eventId: EventId) = Unit
|
override suspend fun deleteItem(eventId: EventId) = Unit
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun createFrom(params: MediaViewerEntryPoint.Params.Avatar) = SingleMediaGalleryDataSource(
|
fun createFrom(
|
||||||
|
params: MediaViewerEntryPoint.Params.Avatar,
|
||||||
|
contentValidationCache: EventContentValidationCache,
|
||||||
|
) = SingleMediaGalleryDataSource(
|
||||||
data = GroupedMediaItems(
|
data = GroupedMediaItems(
|
||||||
// Always use imageAndVideoItems, in Single mode, this is the data that will be used
|
// Always use imageAndVideoItems, in Single mode, this is the data that will be used
|
||||||
imageAndVideoItems = persistentListOf(
|
imageAndVideoItems = persistentListOf(
|
||||||
@@ -64,15 +71,19 @@ class SingleMediaGalleryDataSource(
|
|||||||
mediaSource = params.mediaSource,
|
mediaSource = params.mediaSource,
|
||||||
thumbnailSource = params.thumbnailSource,
|
thumbnailSource = params.thumbnailSource,
|
||||||
blurHash = params.blurHash,
|
blurHash = params.blurHash,
|
||||||
|
validationState = DefaultContentValidationState(),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
fileItems = persistentListOf(),
|
fileItems = persistentListOf(),
|
||||||
)
|
),
|
||||||
|
contentValidationCache = contentValidationCache,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem(
|
||||||
|
contentValidationState: ContentValidationState,
|
||||||
|
) = when {
|
||||||
mediaInfo.mimeType.isMimeTypeImage() -> {
|
mediaInfo.mimeType.isMimeTypeImage() -> {
|
||||||
MediaItem.Image(
|
MediaItem.Image(
|
||||||
id = UniqueId("dummy"),
|
id = UniqueId("dummy"),
|
||||||
@@ -80,7 +91,8 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
|||||||
mediaInfo = mediaInfo,
|
mediaInfo = mediaInfo,
|
||||||
mediaSource = mediaSource,
|
mediaSource = mediaSource,
|
||||||
thumbnailSource = thumbnailSource,
|
thumbnailSource = thumbnailSource,
|
||||||
blurHash = blurHash,
|
blurHash = null,
|
||||||
|
validationState = contentValidationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
mediaInfo.mimeType.isMimeTypeVideo() -> {
|
mediaInfo.mimeType.isMimeTypeVideo() -> {
|
||||||
@@ -90,7 +102,8 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
|||||||
mediaInfo = mediaInfo,
|
mediaInfo = mediaInfo,
|
||||||
mediaSource = mediaSource,
|
mediaSource = mediaSource,
|
||||||
thumbnailSource = thumbnailSource,
|
thumbnailSource = thumbnailSource,
|
||||||
blurHash = blurHash,
|
blurHash = null,
|
||||||
|
validationState = contentValidationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
mediaInfo.mimeType.isMimeTypeAudio() -> {
|
mediaInfo.mimeType.isMimeTypeAudio() -> {
|
||||||
@@ -100,6 +113,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
|||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
mediaInfo = mediaInfo,
|
mediaInfo = mediaInfo,
|
||||||
mediaSource = mediaSource,
|
mediaSource = mediaSource,
|
||||||
|
validationState = contentValidationState,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
MediaItem.Voice(
|
MediaItem.Voice(
|
||||||
@@ -107,6 +121,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
|||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
mediaInfo = mediaInfo,
|
mediaInfo = mediaInfo,
|
||||||
mediaSource = mediaSource,
|
mediaSource = mediaSource,
|
||||||
|
validationState = contentValidationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,6 +131,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
|||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
mediaInfo = mediaInfo,
|
mediaInfo = mediaInfo,
|
||||||
mediaSource = mediaSource,
|
mediaSource = mediaSource,
|
||||||
|
validationState = contentValidationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -12,11 +12,13 @@ import android.net.Uri
|
|||||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.google.common.truth.Truth.assertThat
|
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.features.enterprise.test.FakeEnterpriseService
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
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.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.mediaplayer.test.FakeAudioFocus
|
||||||
import io.element.android.libraries.mediaviewer.api.AvatarInfo
|
import io.element.android.libraries.mediaviewer.api.AvatarInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||||
@@ -71,6 +73,8 @@ class DefaultMediaViewerEntryPointTest {
|
|||||||
audioFocus = FakeAudioFocus(),
|
audioFocus = FakeAudioFocus(),
|
||||||
sessionId = A_SESSION_ID,
|
sessionId = A_SESSION_ID,
|
||||||
enterpriseService = FakeEnterpriseService(),
|
enterpriseService = FakeEnterpriseService(),
|
||||||
|
contentValidationCache = NoopEventContentValidationCache(),
|
||||||
|
contentScannerService = ContentScannerService { _, _, _ -> },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val callback = object : MediaViewerEntryPoint.Callback {
|
val callback = object : MediaViewerEntryPoint.Callback {
|
||||||
@@ -118,6 +122,8 @@ class DefaultMediaViewerEntryPointTest {
|
|||||||
audioFocus = FakeAudioFocus(),
|
audioFocus = FakeAudioFocus(),
|
||||||
sessionId = A_SESSION_ID,
|
sessionId = A_SESSION_ID,
|
||||||
enterpriseService = FakeEnterpriseService(),
|
enterpriseService = FakeEnterpriseService(),
|
||||||
|
contentValidationCache = NoopEventContentValidationCache(),
|
||||||
|
contentScannerService = ContentScannerService { _, _, _ -> },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val callback = object : MediaViewerEntryPoint.Callback {
|
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.aStickerContent
|
||||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
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.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.api.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||||
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
||||||
@@ -174,6 +176,7 @@ class EventItemFactoryTest {
|
|||||||
duration = null,
|
duration = null,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
|
validationState = noopValidationState,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -227,6 +230,7 @@ class EventItemFactoryTest {
|
|||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
blurHash = null,
|
blurHash = null,
|
||||||
|
validationState = noopValidationState,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -274,6 +278,7 @@ class EventItemFactoryTest {
|
|||||||
duration = null,
|
duration = null,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
|
validationState = noopValidationState,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -328,6 +333,7 @@ class EventItemFactoryTest {
|
|||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
blurHash = null,
|
blurHash = null,
|
||||||
|
validationState = noopValidationState,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -379,6 +385,7 @@ class EventItemFactoryTest {
|
|||||||
duration = "7:36",
|
duration = "7:36",
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
|
validationState = noopValidationState,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -440,6 +447,7 @@ class EventItemFactoryTest {
|
|||||||
mediaSource = MediaSource("image_url"),
|
mediaSource = MediaSource("image_url"),
|
||||||
thumbnailSource = MediaSource("thumbnail_url"),
|
thumbnailSource = MediaSource("thumbnail_url"),
|
||||||
blurHash = null,
|
blurHash = null,
|
||||||
|
validationState = noopValidationState,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -502,6 +510,7 @@ class EventItemFactoryTest {
|
|||||||
mediaSource = MediaSource("video_url"),
|
mediaSource = MediaSource("video_url"),
|
||||||
thumbnailSource = MediaSource("thumbnail_url"),
|
thumbnailSource = MediaSource("thumbnail_url"),
|
||||||
blurHash = null,
|
blurHash = null,
|
||||||
|
validationState = noopValidationState,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -557,6 +566,7 @@ class EventItemFactoryTest {
|
|||||||
duration = null,
|
duration = null,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource("audio_url"),
|
mediaSource = MediaSource("audio_url"),
|
||||||
|
validationState = noopValidationState,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -613,6 +623,7 @@ class EventItemFactoryTest {
|
|||||||
duration = null,
|
duration = null,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource("file_url"),
|
mediaSource = MediaSource("file_url"),
|
||||||
|
validationState = noopValidationState,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -749,13 +760,16 @@ class EventItemFactoryTest {
|
|||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
blurHash = null,
|
blurHash = null,
|
||||||
|
validationState = noopValidationState,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val noopValidationState = NoopContentValidationState()
|
||||||
private fun createEventItemFactory() = EventItemFactory(
|
private fun createEventItemFactory() = EventItemFactory(
|
||||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||||
dateFormatter = FakeDateFormatter(),
|
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.aMessageContent
|
||||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
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.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.api.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
|
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
|
||||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||||
@@ -250,6 +252,7 @@ class TimelineMediaGalleryDataSourceTest {
|
|||||||
mediaSource = MediaSource("url"),
|
mediaSource = MediaSource("url"),
|
||||||
thumbnailSource = MediaSource("url_thumbnail"),
|
thumbnailSource = MediaSource("url_thumbnail"),
|
||||||
blurHash = A_BLUR_HASH,
|
blurHash = A_BLUR_HASH,
|
||||||
|
validationState = noopValidationState,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
fileItems = persistentListOf()
|
fileItems = persistentListOf()
|
||||||
@@ -260,6 +263,8 @@ class TimelineMediaGalleryDataSourceTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val noopValidationState = NoopContentValidationState()
|
||||||
|
|
||||||
internal fun TestScope.createTimelineMediaGalleryDataSource(
|
internal fun TestScope.createTimelineMediaGalleryDataSource(
|
||||||
room: JoinedRoom = FakeJoinedRoom(
|
room: JoinedRoom = FakeJoinedRoom(
|
||||||
liveTimeline = FakeTimeline(),
|
liveTimeline = FakeTimeline(),
|
||||||
@@ -282,5 +287,6 @@ fun TestScope.createTimelineMediaItemsFactory() = TimelineMediaItemsFactory(
|
|||||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||||
dateFormatter = FakeDateFormatter(),
|
dateFormatter = FakeDateFormatter(),
|
||||||
),
|
contentValidationCache = NoopEventContentValidationCache(noopValidationState),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+1
@@ -534,6 +534,7 @@ class MediaGalleryPresenterTest {
|
|||||||
mediaLoader = matrixMediaLoader,
|
mediaLoader = matrixMediaLoader,
|
||||||
localMediaActions = localMediaActions,
|
localMediaActions = localMediaActions,
|
||||||
snackbarDispatcher = snackbarDispatcher,
|
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.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_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.GalleryInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.GalleryItemData
|
import io.element.android.libraries.mediaviewer.api.GalleryItemData
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
@@ -95,6 +96,7 @@ class GalleryMediaGalleryDataSourceTest {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
galleryInfo = aGalleryInfo(),
|
galleryInfo = aGalleryInfo(),
|
||||||
|
contentValidationState = noopValidationState,
|
||||||
)
|
)
|
||||||
val data = (result.getLastData() as AsyncData.Success).data
|
val data = (result.getLastData() as AsyncData.Success).data
|
||||||
assertThat(data.fileItems).isEmpty()
|
assertThat(data.fileItems).isEmpty()
|
||||||
@@ -106,6 +108,7 @@ class GalleryMediaGalleryDataSourceTest {
|
|||||||
mediaSource = MediaSource("image_url"),
|
mediaSource = MediaSource("image_url"),
|
||||||
thumbnailSource = MediaSource("thumbnail_url"),
|
thumbnailSource = MediaSource("thumbnail_url"),
|
||||||
blurHash = null,
|
blurHash = null,
|
||||||
|
validationState = noopValidationState,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -125,6 +128,7 @@ class GalleryMediaGalleryDataSourceTest {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
galleryInfo = aGalleryInfo(),
|
galleryInfo = aGalleryInfo(),
|
||||||
|
contentValidationState = noopValidationState,
|
||||||
)
|
)
|
||||||
val data = (result.getLastData() as AsyncData.Success).data
|
val data = (result.getLastData() as AsyncData.Success).data
|
||||||
assertThat(data.fileItems).isEmpty()
|
assertThat(data.fileItems).isEmpty()
|
||||||
@@ -136,6 +140,7 @@ class GalleryMediaGalleryDataSourceTest {
|
|||||||
mediaSource = MediaSource("video_url"),
|
mediaSource = MediaSource("video_url"),
|
||||||
thumbnailSource = MediaSource("thumbnail_url"),
|
thumbnailSource = MediaSource("thumbnail_url"),
|
||||||
blurHash = null,
|
blurHash = null,
|
||||||
|
validationState = noopValidationState,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -155,6 +160,7 @@ class GalleryMediaGalleryDataSourceTest {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
galleryInfo = aGalleryInfo(),
|
galleryInfo = aGalleryInfo(),
|
||||||
|
contentValidationState = noopValidationState,
|
||||||
)
|
)
|
||||||
val data = (result.getLastData() as AsyncData.Success).data
|
val data = (result.getLastData() as AsyncData.Success).data
|
||||||
assertThat(data.fileItems).isEmpty()
|
assertThat(data.fileItems).isEmpty()
|
||||||
@@ -164,6 +170,7 @@ class GalleryMediaGalleryDataSourceTest {
|
|||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
mediaInfo = expectedMediaInfo("audio.mp3", MimeTypes.Mp3),
|
mediaInfo = expectedMediaInfo("audio.mp3", MimeTypes.Mp3),
|
||||||
mediaSource = MediaSource("audio_url"),
|
mediaSource = MediaSource("audio_url"),
|
||||||
|
validationState = noopValidationState,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -183,6 +190,7 @@ class GalleryMediaGalleryDataSourceTest {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
galleryInfo = aGalleryInfo(),
|
galleryInfo = aGalleryInfo(),
|
||||||
|
contentValidationState = noopValidationState,
|
||||||
)
|
)
|
||||||
val data = (result.getLastData() as AsyncData.Success).data
|
val data = (result.getLastData() as AsyncData.Success).data
|
||||||
assertThat(data.fileItems).isEmpty()
|
assertThat(data.fileItems).isEmpty()
|
||||||
@@ -192,6 +200,7 @@ class GalleryMediaGalleryDataSourceTest {
|
|||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
mediaInfo = expectedMediaInfo("document.pdf", MimeTypes.Pdf),
|
mediaInfo = expectedMediaInfo("document.pdf", MimeTypes.Pdf),
|
||||||
mediaSource = MediaSource("file_url"),
|
mediaSource = MediaSource("file_url"),
|
||||||
|
validationState = noopValidationState,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -211,6 +220,7 @@ class GalleryMediaGalleryDataSourceTest {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
galleryInfo = aGalleryInfo(),
|
galleryInfo = aGalleryInfo(),
|
||||||
|
contentValidationState = noopValidationState,
|
||||||
)
|
)
|
||||||
val data = (result.getLastData() as AsyncData.Success).data
|
val data = (result.getLastData() as AsyncData.Success).data
|
||||||
val item = data.imageAndVideoItems.single() as MediaItem.Image
|
val item = data.imageAndVideoItems.single() as MediaItem.Image
|
||||||
@@ -249,6 +259,7 @@ class GalleryMediaGalleryDataSourceTest {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
galleryInfo = aGalleryInfo(),
|
galleryInfo = aGalleryInfo(),
|
||||||
|
contentValidationState = noopValidationState,
|
||||||
)
|
)
|
||||||
val data = (result.getLastData() as AsyncData.Success).data
|
val data = (result.getLastData() as AsyncData.Success).data
|
||||||
assertThat(data.fileItems).isEmpty()
|
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"))
|
assertThat((data.imageAndVideoItems[2] as MediaItem.Video).id).isEqualTo(UniqueId("${AN_EVENT_ID.value}_2"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val noopValidationState = NoopContentValidationState()
|
||||||
|
|
||||||
private fun aGalleryInfo() = GalleryInfo(
|
private fun aGalleryInfo() = GalleryInfo(
|
||||||
caption = "A caption",
|
caption = "A caption",
|
||||||
formattedCaption = null,
|
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_EVENT_ID_2
|
||||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
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.MediaViewerEntryPoint.MediaViewerMode
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||||
import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource
|
import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource
|
||||||
@@ -290,5 +291,7 @@ class MediaViewerDataSourceTest {
|
|||||||
localMediaFactory = localMediaFactory,
|
localMediaFactory = localMediaFactory,
|
||||||
systemClock = FakeSystemClock(),
|
systemClock = FakeSystemClock(),
|
||||||
pagerKeysHandler = PagerKeysHandler(),
|
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.FakeJoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
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.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.AvatarInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||||
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
|
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
|
||||||
@@ -969,6 +970,8 @@ internal fun TestScope.createMediaViewerPresenter(
|
|||||||
localMediaFactory = localMediaFactory,
|
localMediaFactory = localMediaFactory,
|
||||||
systemClock = FakeSystemClock(),
|
systemClock = FakeSystemClock(),
|
||||||
pagerKeysHandler = PagerKeysHandler(),
|
pagerKeysHandler = PagerKeysHandler(),
|
||||||
|
contentScannerService = { _, _, _ -> },
|
||||||
|
contentValidationCache = NoopEventContentValidationCache(),
|
||||||
),
|
),
|
||||||
room = room,
|
room = room,
|
||||||
localMediaActions = localMediaActions,
|
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.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.media.aMediaSource
|
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.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||||
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
||||||
@@ -36,22 +37,24 @@ class SingleMediaGalleryDataSourceTest {
|
|||||||
@get:Rule
|
@get:Rule
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
private val contentValidationCache = NoopEventContentValidationCache()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `function start is no op`() = runTest {
|
fun `function start is no op`() = runTest {
|
||||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
|
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems(), contentValidationCache)
|
||||||
sut.start(backgroundScope)
|
sut.start(backgroundScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `function loadMore is no op`() = runTest {
|
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.BACKWARDS)
|
||||||
sut.loadMore(Timeline.PaginationDirection.FORWARDS)
|
sut.loadMore(Timeline.PaginationDirection.FORWARDS)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `function deleteItem is no op`() = runTest {
|
fun `function deleteItem is no op`() = runTest {
|
||||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
|
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems(), contentValidationCache)
|
||||||
sut.deleteItem(AN_EVENT_ID)
|
sut.deleteItem(AN_EVENT_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +64,7 @@ class SingleMediaGalleryDataSourceTest {
|
|||||||
imageAndVideoItems = listOf(aMediaItemImage()),
|
imageAndVideoItems = listOf(aMediaItemImage()),
|
||||||
fileItems = listOf(aMediaItemFile()),
|
fileItems = listOf(aMediaItemFile()),
|
||||||
)
|
)
|
||||||
val sut = SingleMediaGalleryDataSource(data)
|
val sut = SingleMediaGalleryDataSource(data, contentValidationCache)
|
||||||
assertThat(sut.getLastData()).isEqualTo(AsyncData.Success(data))
|
assertThat(sut.getLastData()).isEqualTo(AsyncData.Success(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +74,7 @@ class SingleMediaGalleryDataSourceTest {
|
|||||||
imageAndVideoItems = listOf(aMediaItemImage()),
|
imageAndVideoItems = listOf(aMediaItemImage()),
|
||||||
fileItems = listOf(aMediaItemFile()),
|
fileItems = listOf(aMediaItemFile()),
|
||||||
)
|
)
|
||||||
val sut = SingleMediaGalleryDataSource(data)
|
val sut = SingleMediaGalleryDataSource(data, contentValidationCache)
|
||||||
sut.groupedMediaItemsFlow().test {
|
sut.groupedMediaItemsFlow().test {
|
||||||
assertThat(awaitItem()).isEqualTo(AsyncData.Success(data))
|
assertThat(awaitItem()).isEqualTo(AsyncData.Success(data))
|
||||||
awaitComplete()
|
awaitComplete()
|
||||||
@@ -90,6 +93,7 @@ class SingleMediaGalleryDataSourceTest {
|
|||||||
mediaSource = params.mediaSource,
|
mediaSource = params.mediaSource,
|
||||||
thumbnailSource = params.thumbnailSource,
|
thumbnailSource = params.thumbnailSource,
|
||||||
blurHash = null,
|
blurHash = null,
|
||||||
|
validationState = contentValidationCache[AN_EVENT_ID],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -107,6 +111,7 @@ class SingleMediaGalleryDataSourceTest {
|
|||||||
mediaSource = params.mediaSource,
|
mediaSource = params.mediaSource,
|
||||||
thumbnailSource = params.thumbnailSource,
|
thumbnailSource = params.thumbnailSource,
|
||||||
blurHash = null,
|
blurHash = null,
|
||||||
|
validationState = contentValidationCache[AN_EVENT_ID],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -122,6 +127,7 @@ class SingleMediaGalleryDataSourceTest {
|
|||||||
eventId = params.eventId,
|
eventId = params.eventId,
|
||||||
mediaInfo = params.mediaInfo,
|
mediaInfo = params.mediaInfo,
|
||||||
mediaSource = params.mediaSource,
|
mediaSource = params.mediaSource,
|
||||||
|
validationState = contentValidationCache[AN_EVENT_ID],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -140,6 +146,7 @@ class SingleMediaGalleryDataSourceTest {
|
|||||||
eventId = params.eventId,
|
eventId = params.eventId,
|
||||||
mediaInfo = params.mediaInfo,
|
mediaInfo = params.mediaInfo,
|
||||||
mediaSource = params.mediaSource,
|
mediaSource = params.mediaSource,
|
||||||
|
validationState = contentValidationCache[AN_EVENT_ID],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -155,6 +162,7 @@ class SingleMediaGalleryDataSourceTest {
|
|||||||
eventId = params.eventId,
|
eventId = params.eventId,
|
||||||
mediaInfo = params.mediaInfo,
|
mediaInfo = params.mediaInfo,
|
||||||
mediaSource = params.mediaSource,
|
mediaSource = params.mediaSource,
|
||||||
|
validationState = contentValidationCache[AN_EVENT_ID],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -165,7 +173,7 @@ class SingleMediaGalleryDataSourceTest {
|
|||||||
expectedResult: (MediaViewerEntryPoint.Params.RoomMedia) -> MediaItem,
|
expectedResult: (MediaViewerEntryPoint.Params.RoomMedia) -> MediaItem,
|
||||||
) {
|
) {
|
||||||
val params = aMediaViewerEntryPointParams(mediaInfo)
|
val params = aMediaViewerEntryPointParams(mediaInfo)
|
||||||
val result = params.toMediaItem()
|
val result = params.toMediaItem(contentValidationCache[AN_EVENT_ID],)
|
||||||
assertThat(result).isEqualTo(expectedResult(params))
|
assertThat(result).isEqualTo(expectedResult(params))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
Reference in New Issue
Block a user