Merge branch 'develop' into feature/valere/timeline_active_call_rendering

This commit is contained in:
Valere
2026-07-17 15:33:33 +02:00
106 changed files with 961 additions and 536 deletions
+4
View File
@@ -28,6 +28,10 @@ changelog:
labels: labels:
- PR-Dependencies - PR-Dependencies
- title: 💼 Element Pro
labels:
- PR-Element-Pro
- title: Others - title: Others
labels: labels:
- PR-misc - PR-misc
+1 -1
View File
@@ -16,4 +16,4 @@ appId: ${MAESTRO_APP_ID}
- extendedWaitUntil: - extendedWaitUntil:
visible: "Device verified" visible: "Device verified"
timeout: 30000 timeout: 30000
- tapOn: "Continue" - tapOn: "Done"
@@ -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
@@ -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
@@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
@@ -40,7 +41,7 @@ fun AccountProviderOtherView(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() } .clickable(role = Role.Button) { onClick() }
) { ) {
HorizontalDivider() HorizontalDivider()
Row( Row(
@@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@@ -45,7 +46,7 @@ fun AccountProviderView(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() } .clickable(role = Role.Button) { onClick() }
) { ) {
HorizontalDivider() HorizontalDivider()
Column( Column(
@@ -36,6 +36,7 @@ import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalAutofillManager import androidx.compose.ui.platform.LocalAutofillManager
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentType import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@@ -211,10 +212,15 @@ private fun LoginForm(
singleLine = true, singleLine = true,
trailingIcon = if (loginFieldState.isNotEmpty()) { trailingIcon = if (loginFieldState.isNotEmpty()) {
{ {
Box(Modifier.clickable { Box(
loginFieldState = "" Modifier.clickable(
eventSink(LoginPasswordEvents.SetLogin("")) onClickLabel = stringResource(CommonStrings.action_clear),
}) { role = Role.Button,
) {
loginFieldState = ""
eventSink(LoginPasswordEvents.SetLogin(""))
}
) {
Icon( Icon(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_clear), contentDescription = stringResource(CommonStrings.action_clear),
@@ -27,6 +27,7 @@ import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -336,7 +337,7 @@ private fun OnBoardingButtons(
} else { } else {
Text( Text(
modifier = Modifier modifier = Modifier
.clickable { .clickable(role = Role.Button) {
state.eventSink(OnBoardingEvents.OnVersionClick) state.eventSink(OnBoardingEvents.OnVersionClick)
} }
.padding(16.dp), .padding(16.dp),
@@ -17,7 +17,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.qrcode.QrCodeLoginManager import io.element.android.features.login.impl.qrcode.QrCodeLoginManager
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@@ -39,7 +38,6 @@ class QrCodeScanPresenter(
private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory, private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory,
private val qrCodeLoginManager: QrCodeLoginManager, private val qrCodeLoginManager: QrCodeLoginManager,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
) : Presenter<QrCodeScanState> { ) : Presenter<QrCodeScanState> {
private var isScanning by mutableStateOf(true) private var isScanning by mutableStateOf(true)
@@ -97,13 +95,6 @@ class QrCodeScanPresenter(
val data = qrCodeLoginDataFactory.parseQrCodeData(code).onFailure { val data = qrCodeLoginDataFactory.parseQrCodeData(code).onFailure {
Timber.e(it, "Error parsing QR code data") Timber.e(it, "Error parsing QR code data")
}.getOrThrow() }.getOrThrow()
val serverName = data.serverName()
if (serverName != null) {
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
title = serverName,
accountProviderUrl = serverName,
)
}
data data
}.runCatchingUpdatingState(codeScannedAction) }.runCatchingUpdatingState(codeScannedAction)
}.invokeOnCompletion { }.invokeOnCompletion {
@@ -34,6 +34,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -125,10 +126,15 @@ fun SearchAccountProviderView(
singleLine = true, singleLine = true,
trailingIcon = if (userInputState.isNotEmpty()) { trailingIcon = if (userInputState.isNotEmpty()) {
{ {
Box(Modifier.clickable { Box(
userInputState = "" Modifier.clickable(
eventSink(SearchAccountProviderEvents.UserInput("")) onClickLabel = stringResource(CommonStrings.action_clear),
}) { role = Role.Button,
) {
userInputState = ""
eventSink(SearchAccountProviderEvents.UserInput(""))
}
) {
Icon( Icon(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_clear) contentDescription = stringResource(CommonStrings.action_clear)
@@ -9,18 +9,12 @@
package io.element.android.features.login.impl.screens.qrcode.scan package io.element.android.features.login.impl.screens.qrcode.scan
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
import io.element.android.features.login.impl.qrcode.FakeQrCodeLoginManager import io.element.android.features.login.impl.qrcode.FakeQrCodeLoginManager
import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginDataFactory import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginDataFactory
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -53,9 +47,6 @@ class QrCodeScanPresenterTest {
) )
val presenter = createQrCodeScanPresenter( val presenter = createQrCodeScanPresenter(
qrCodeLoginDataFactory = qrCodeLoginDataFactory, qrCodeLoginDataFactory = qrCodeLoginDataFactory,
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
)
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
@@ -66,44 +57,6 @@ class QrCodeScanPresenterTest {
} }
} }
@Test
fun `present - scanned QR code successfully, but homeserver not allowed`() = runTest {
val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(
parseQrCodeLoginDataResult = {
Result.success(
FakeMatrixQrCodeLoginData(
serverNameResult = { "example.com" }
)
)
}
)
val presenter = createQrCodeScanPresenter(
qrCodeLoginDataFactory = qrCodeLoginDataFactory,
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { false },
defaultHomeserverListResult = { listOf("element.io") },
)
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(QrCodeScanEvents.QrCodeScanned(byteArrayOf()))
assertThat(awaitItem().isScanning).isFalse()
assertThat(awaitItem().authenticationAction.isLoading()).isTrue()
awaitItem().also { state ->
assertThat(
(state.authenticationAction
.errorOrNull() as AccountProviderAccessException.UnauthorizedAccountProviderException).unauthorisedAccountProviderTitle
)
.isEqualTo("example.com")
assertThat(
(state.authenticationAction
.errorOrNull() as AccountProviderAccessException.UnauthorizedAccountProviderException).authorisedAccountProviderTitles
)
.containsExactly("element.io")
}
}
}
@Test @Test
fun `present - scanned QR code failed and can be retried`() = runTest { fun `present - scanned QR code failed and can be retried`() = runTest {
val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory( val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(
@@ -153,15 +106,9 @@ class QrCodeScanPresenterTest {
qrCodeLoginDataFactory: FakeMatrixQrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(), qrCodeLoginDataFactory: FakeMatrixQrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(),
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(), qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
) = QrCodeScanPresenter( ) = QrCodeScanPresenter(
qrCodeLoginDataFactory = qrCodeLoginDataFactory, qrCodeLoginDataFactory = qrCodeLoginDataFactory,
qrCodeLoginManager = qrCodeLoginManager, qrCodeLoginManager = qrCodeLoginManager,
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService,
wellknownRetriever = wellknownRetriever,
),
) )
} }
@@ -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,
) )
} }
@@ -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,
) )
} }
} }
@@ -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,
) { ) {
@@ -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,
),
)
} }
} }
@@ -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(),
) )
} }
@@ -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(),
) )
} }
@@ -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,
) )
} }
} }
@@ -66,7 +66,8 @@ fun OutgoingVerificationView(
fun cancelOrResetFlow() { fun cancelOrResetFlow() {
when (step) { when (step) {
is Step.Canceled -> state.eventSink(OutgoingVerificationViewEvents.Reset) is Step.Canceled -> state.eventSink(OutgoingVerificationViewEvents.Reset)
Step.Initial, Step.Completed -> onBack() Step.Initial -> onBack()
Step.Completed -> onFinish()
Step.Ready, is Step.AwaitingOtherDeviceResponse -> state.eventSink(OutgoingVerificationViewEvents.Cancel) Step.Ready, is Step.AwaitingOtherDeviceResponse -> state.eventSink(OutgoingVerificationViewEvents.Cancel)
is Step.Verifying -> { is Step.Verifying -> {
if (!step.state.isLoading()) { if (!step.state.isLoading()) {
@@ -96,7 +97,9 @@ fun OutgoingVerificationView(
TopAppBar( TopAppBar(
title = {}, title = {},
navigationIcon = { navigationIcon = {
BackButton(onClick = ::cancelOrResetFlow) if (step !is Step.Completed) {
BackButton(onClick = ::cancelOrResetFlow)
}
}, },
colors = topAppBarColors(containerColor = Color.Transparent), colors = topAppBarColors(containerColor = Color.Transparent),
) )
@@ -108,7 +111,7 @@ fun OutgoingVerificationView(
OutgoingVerificationBottomMenu( OutgoingVerificationBottomMenu(
state = state, state = state,
onCancelClick = ::cancelOrResetFlow, onCancelClick = ::cancelOrResetFlow,
onContinueClick = onFinish, onDoneClick = onFinish,
) )
}, },
isScrollable = true, isScrollable = true,
@@ -244,7 +247,7 @@ private fun ContentInitial(
private fun OutgoingVerificationBottomMenu( private fun OutgoingVerificationBottomMenu(
state: OutgoingVerificationState, state: OutgoingVerificationState,
onCancelClick: () -> Unit, onCancelClick: () -> Unit,
onContinueClick: () -> Unit, onDoneClick: () -> Unit,
) { ) {
val eventSink = state.eventSink val eventSink = state.eventSink
when (val step = state.step) { when (val step = state.step) {
@@ -313,8 +316,8 @@ private fun OutgoingVerificationBottomMenu(
VerificationBottomMenu { VerificationBottomMenu {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_continue), text = stringResource(CommonStrings.action_done),
onClick = onContinueClick, onClick = onDoneClick,
) )
InvisibleButton() InvisibleButton()
} }
@@ -99,10 +99,10 @@ class OutgoingVerificationViewTest : RobolectricTest() {
} }
@Test @Test
fun `back key pressed - on Completed exits the flow`() = runAndroidComposeUiTest { fun `back key pressed - on Completed finishes the flow`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
setOutgoingVerificationView( setOutgoingVerificationView(
onBack = callback, onFinished = callback,
state = anOutgoingVerificationState( state = anOutgoingVerificationState(
step = OutgoingVerificationState.Step.Completed, step = OutgoingVerificationState.Step.Completed,
), ),
@@ -112,7 +112,7 @@ class OutgoingVerificationViewTest : RobolectricTest() {
} }
@Test @Test
fun `when flow is completed and the user clicks on the continue button, the expected callback is invoked`() = runAndroidComposeUiTest { fun `when flow is completed and the user clicks on the done button, the expected callback is invoked`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<OutgoingVerificationViewEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<OutgoingVerificationViewEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
setOutgoingVerificationView( setOutgoingVerificationView(
@@ -122,7 +122,7 @@ class OutgoingVerificationViewTest : RobolectricTest() {
), ),
onFinished = callback, onFinished = callback,
) )
clickOn(CommonStrings.action_continue) clickOn(CommonStrings.action_done)
} }
} }
+3 -3
View File
@@ -7,7 +7,7 @@ android_gradle_plugin = "9.2.1"
# When updating this, please also update the version in the file ./idea/kotlinc.xml # When updating this, please also update the version in the file ./idea/kotlinc.xml
kotlin = "2.4.0" kotlin = "2.4.0"
kotlinpoet = "2.3.0" kotlinpoet = "2.3.0"
ksp = "2.3.9" ksp = "2.3.10"
firebaseAppDistribution = "5.3.0" firebaseAppDistribution = "5.3.0"
# AndroidX # AndroidX
@@ -88,7 +88,7 @@ google_firebase_bom = "com.google.firebase:firebase-bom:34.15.0"
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" } firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" } autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
google_tink = "com.google.crypto.tink:tink-android:1.22.0" google_tink = "com.google.crypto.tink:tink-android:1.23.0"
jna = "net.java.dev.jna:jna:5.19.1" jna = "net.java.dev.jna:jna:5.19.1"
# AndroidX # AndroidX
@@ -208,7 +208,7 @@ matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose",
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqlcipher = "net.zetetic:sqlcipher-android:4.16.0" sqlcipher = "net.zetetic:sqlcipher-android:4.17.0"
sqlite = "androidx.sqlite:sqlite-ktx:2.7.0" sqlite = "androidx.sqlite:sqlite-ktx:2.7.0"
unifiedpush = "org.unifiedpush.android:connector:3.3.3" unifiedpush = "org.unifiedpush.android:connector:3.3.3"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
@@ -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 }
@@ -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)
@@ -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,
)) ))
} }
} }
@@ -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
@@ -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()
) )
@@ -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,
@@ -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 = {},
)
}
}
} }
@@ -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,
)
}
}
} }
@@ -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,
)
}
}
}
@@ -22,7 +22,7 @@ class MediaItemAudioProvider : PreviewParameterProvider<MediaItem.Audio> {
caption = "A caption", caption = "A caption",
), ),
aMediaItemAudio( aMediaItemAudio(
caption = loremIpsum, caption = loremIpsum.take(80),
), ),
) )
} }
@@ -22,7 +22,7 @@ class MediaItemFileProvider : PreviewParameterProvider<MediaItem.File> {
caption = "A caption", caption = "A caption",
), ),
aMediaItemFile( aMediaItemFile(
caption = loremIpsum, caption = loremIpsum.take(80),
), ),
) )
} }
@@ -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(),
@@ -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,
)
}
}
} }
@@ -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) {
@@ -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,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,
) )
} }
@@ -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)
@@ -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}")
@@ -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
} }
@@ -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,
) )
) )
@@ -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)
} }
} }
@@ -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
} }
@@ -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(
@@ -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)
)
}
} }
} }
} }
@@ -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,
) )
} }
} }
@@ -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 {
@@ -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),
) )
@@ -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),
)
) )
@@ -534,6 +534,7 @@ class MediaGalleryPresenterTest {
mediaLoader = matrixMediaLoader, mediaLoader = matrixMediaLoader,
localMediaActions = localMediaActions, localMediaActions = localMediaActions,
snackbarDispatcher = snackbarDispatcher, snackbarDispatcher = snackbarDispatcher,
contentScannerService = { _, _, _ -> },
) )
} }
} }
@@ -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,
@@ -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(),
) )
} }
@@ -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,
@@ -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))
} }
@@ -23,14 +23,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme 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.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.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
internal fun FormattingOption( internal fun FormattingOption(
@@ -52,6 +55,8 @@ internal fun FormattingOption(
FormattingOptionState.Default -> ElementTheme.colors.iconSecondary FormattingOptionState.Default -> ElementTheme.colors.iconSecondary
FormattingOptionState.Disabled -> ElementTheme.colors.iconDisabled FormattingOptionState.Disabled -> ElementTheme.colors.iconDisabled
} }
val activeStateDescription = stringResource(CommonStrings.a11y_active)
val inactiveStateDescription = stringResource(CommonStrings.a11y_inactive)
Box( Box(
modifier = modifier modifier = modifier
.clickable( .clickable(
@@ -77,6 +82,13 @@ internal fun FormattingOption(
) )
.clearAndSetSemantics { .clearAndSetSemantics {
this.contentDescription = contentDescription this.contentDescription = contentDescription
if (toggleable) {
this.stateDescription = if (state == FormattingOptionState.Selected) {
activeStateDescription
} else {
inactiveStateDescription
}
}
} }
) { ) {
Box( Box(
@@ -10,4 +10,6 @@
<string name="a11y_jump_to_unread_messages">"Jump to first unread message"</string> <string name="a11y_jump_to_unread_messages">"Jump to first unread message"</string>
<string name="screen_local_network_opt_in_title">Allow access to local network</string> <string name="screen_local_network_opt_in_title">Allow access to local network</string>
<string name="screen_local_network_opt_in_subtitle">Your homeserver is on your local network. To connect, Element needs permission to reach other devices on this network.</string> <string name="screen_local_network_opt_in_subtitle">Your homeserver is on your local network. To connect, Element needs permission to reach other devices on this network.</string>
<string name="a11y_active">Active</string>
<string name="a11y_inactive">Inactive</string>
</resources> </resources>

Some files were not shown because too many files have changed in this diff Show More