mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 22:36:34 +00:00
Merge branch 'develop' into feature/valere/timeline_active_call_rendering
This commit is contained in:
@@ -28,6 +28,10 @@ changelog:
|
||||
labels:
|
||||
- PR-Dependencies
|
||||
|
||||
- title: 💼 Element Pro
|
||||
labels:
|
||||
- PR-Element-Pro
|
||||
|
||||
- title: Others
|
||||
labels:
|
||||
- PR-misc
|
||||
|
||||
@@ -16,4 +16,4 @@ appId: ${MAESTRO_APP_ID}
|
||||
- extendedWaitUntil:
|
||||
visible: "Device verified"
|
||||
timeout: 30000
|
||||
- tapOn: "Continue"
|
||||
- tapOn: "Done"
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ class DefaultContentScannerService(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
coroutineDispatchers: CoroutineDispatchers,
|
||||
) : ContentScannerService {
|
||||
private val context = coroutineDispatchers.io.limitedParallelism(4)
|
||||
private val dispatcher = coroutineDispatchers.io.limitedParallelism(4)
|
||||
|
||||
override fun scan(eventId: EventId, mediaSources: List<MediaSource>, contentValidationState: ContentValidationState) {
|
||||
for (mediaSource in mediaSources) {
|
||||
@@ -35,7 +35,7 @@ class DefaultContentScannerService(
|
||||
if (currentState != ContentValidationValue.Unknown) continue
|
||||
contentValidationState.update(url, ContentValidationValue.Loading)
|
||||
|
||||
coroutineScope.launch(context) {
|
||||
coroutineScope.launch(dispatcher) {
|
||||
contentScanner.scan(mediaSource)
|
||||
.onSuccess { isValid ->
|
||||
val contentValidationValue = if (isValid) ContentValidationValue.Valid else ContentValidationValue.Invalid
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@ import java.io.FileNotFoundException
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DefaultContentScannerServiceTest {
|
||||
@Test
|
||||
fun `scan with no media sources returns valid`() = runTest {
|
||||
fun `scan with no media sources returns unknown`() = runTest {
|
||||
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(true) }
|
||||
val scanner = FakeContentScanner(scan = scanLambda)
|
||||
val contentValidationState = DefaultContentValidationState()
|
||||
@@ -45,7 +45,7 @@ class DefaultContentScannerServiceTest {
|
||||
)
|
||||
|
||||
scanLambda.assertions().isNeverCalled()
|
||||
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Valid)
|
||||
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Unknown)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
-1
@@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
@@ -40,7 +41,7 @@ fun AccountProviderOtherView(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.clickable(role = Role.Button) { onClick() }
|
||||
) {
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
|
||||
+2
-1
@@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
@@ -45,7 +46,7 @@ fun AccountProviderView(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.clickable(role = Role.Button) { onClick() }
|
||||
) {
|
||||
HorizontalDivider()
|
||||
Column(
|
||||
|
||||
+10
-4
@@ -36,6 +36,7 @@ import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalAutofillManager
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentType
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
@@ -211,10 +212,15 @@ private fun LoginForm(
|
||||
singleLine = true,
|
||||
trailingIcon = if (loginFieldState.isNotEmpty()) {
|
||||
{
|
||||
Box(Modifier.clickable {
|
||||
loginFieldState = ""
|
||||
eventSink(LoginPasswordEvents.SetLogin(""))
|
||||
}) {
|
||||
Box(
|
||||
Modifier.clickable(
|
||||
onClickLabel = stringResource(CommonStrings.action_clear),
|
||||
role = Role.Button,
|
||||
) {
|
||||
loginFieldState = ""
|
||||
eventSink(LoginPasswordEvents.SetLogin(""))
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.action_clear),
|
||||
|
||||
+2
-1
@@ -27,6 +27,7 @@ import androidx.compose.ui.BiasAlignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -336,7 +337,7 @@ private fun OnBoardingButtons(
|
||||
} else {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
.clickable(role = Role.Button) {
|
||||
state.eventSink(OnBoardingEvents.OnVersionClick)
|
||||
}
|
||||
.padding(16.dp),
|
||||
|
||||
-9
@@ -17,7 +17,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
@@ -39,7 +38,6 @@ class QrCodeScanPresenter(
|
||||
private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory,
|
||||
private val qrCodeLoginManager: QrCodeLoginManager,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
|
||||
) : Presenter<QrCodeScanState> {
|
||||
private var isScanning by mutableStateOf(true)
|
||||
|
||||
@@ -97,13 +95,6 @@ class QrCodeScanPresenter(
|
||||
val data = qrCodeLoginDataFactory.parseQrCodeData(code).onFailure {
|
||||
Timber.e(it, "Error parsing QR code data")
|
||||
}.getOrThrow()
|
||||
val serverName = data.serverName()
|
||||
if (serverName != null) {
|
||||
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
|
||||
title = serverName,
|
||||
accountProviderUrl = serverName,
|
||||
)
|
||||
}
|
||||
data
|
||||
}.runCatchingUpdatingState(codeScannedAction)
|
||||
}.invokeOnCompletion {
|
||||
|
||||
+10
-4
@@ -34,6 +34,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
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.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
@@ -125,10 +126,15 @@ fun SearchAccountProviderView(
|
||||
singleLine = true,
|
||||
trailingIcon = if (userInputState.isNotEmpty()) {
|
||||
{
|
||||
Box(Modifier.clickable {
|
||||
userInputState = ""
|
||||
eventSink(SearchAccountProviderEvents.UserInput(""))
|
||||
}) {
|
||||
Box(
|
||||
Modifier.clickable(
|
||||
onClickLabel = stringResource(CommonStrings.action_clear),
|
||||
role = Role.Button,
|
||||
) {
|
||||
userInputState = ""
|
||||
eventSink(SearchAccountProviderEvents.UserInput(""))
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.action_clear)
|
||||
|
||||
-53
@@ -9,18 +9,12 @@
|
||||
package io.element.android.features.login.impl.screens.qrcode.scan
|
||||
|
||||
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.wellknown.test.FakeWellknownRetriever
|
||||
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.QrLoginException
|
||||
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.wellknown.api.WellknownRetriever
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
@@ -53,9 +47,6 @@ class QrCodeScanPresenterTest {
|
||||
)
|
||||
val presenter = createQrCodeScanPresenter(
|
||||
qrCodeLoginDataFactory = qrCodeLoginDataFactory,
|
||||
enterpriseService = FakeEnterpriseService(
|
||||
isAllowedToConnectToHomeserverResult = { true },
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
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
|
||||
fun `present - scanned QR code failed and can be retried`() = runTest {
|
||||
val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(
|
||||
@@ -153,15 +106,9 @@ class QrCodeScanPresenterTest {
|
||||
qrCodeLoginDataFactory: FakeMatrixQrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(),
|
||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(),
|
||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
|
||||
) = QrCodeScanPresenter(
|
||||
qrCodeLoginDataFactory = qrCodeLoginDataFactory,
|
||||
qrCodeLoginManager = qrCodeLoginManager,
|
||||
coroutineDispatchers = coroutineDispatchers,
|
||||
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
|
||||
enterpriseService = enterpriseService,
|
||||
wellknownRetriever = wellknownRetriever,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -22,8 +22,8 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentVal
|
||||
fun TimelineItemAudioView(
|
||||
content: TimelineItemAudioContent,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
contentValidationValue: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
||||
) {
|
||||
TimelineItemAttachmentView(
|
||||
icon = CompoundIcons.Audio(),
|
||||
@@ -43,5 +43,6 @@ internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioCon
|
||||
TimelineItemAudioView(
|
||||
content,
|
||||
onContentLayoutChange = {},
|
||||
contentValidationValue = ContentValidationValue.Valid,
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -27,8 +27,8 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
fun TimelineItemFileView(
|
||||
content: TimelineItemFileContent,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
contentValidationValue: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
||||
) {
|
||||
TimelineItemAttachmentView(
|
||||
icon = CompoundIcons.Attachment(),
|
||||
@@ -48,6 +48,7 @@ internal fun TimelineItemFileViewPreview(@PreviewParameter(TimelineItemFileConte
|
||||
TimelineItemFileView(
|
||||
content,
|
||||
onContentLayoutChange = {},
|
||||
contentValidationValue = ContentValidationValue.Valid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-3
@@ -288,9 +288,15 @@ private fun GalleryItemCell(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.blurHashBackground(item.blurhash, alpha = 0.9f)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
.then(
|
||||
if (itemContentValidationState.isValid()) {
|
||||
Modifier.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
|
||||
+36
-29
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -43,7 +44,9 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||
@@ -55,8 +58,8 @@ fun TimelineItemImageView(
|
||||
onContentClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onShowContentClick: () -> Unit,
|
||||
contentValidationState: ContentValidationState,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
|
||||
) {
|
||||
val a11yLabel = stringResource(CommonStrings.common_image)
|
||||
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
|
||||
@@ -129,52 +132,56 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
contentValidationState = NoopContentValidationState(),
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
|
||||
TimelineItemImageView(
|
||||
content = aTimelineItemImageContent(),
|
||||
hideMediaContent = true,
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
TimelineItemImageView(
|
||||
content = aTimelineItemImageContent(),
|
||||
hideMediaContent = true,
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
contentValidationState = NoopContentValidationState(),
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ATimelineItemEventRowPreview() = ElementPreview {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
CompositionLocalProvider(LocalEventContentValidationState provides NoopEventContentValidationCache()) {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
content = aTimelineItemImageContent(
|
||||
filename = "image.jpg",
|
||||
caption = "A long caption that may wrap into several lines",
|
||||
width = 40,
|
||||
height = 20,
|
||||
aspectRatio = 40f / 20f,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
isMine = false,
|
||||
content = aTimelineItemImageContent(
|
||||
filename = "image.jpg",
|
||||
caption = "A long caption that may wrap into several lines",
|
||||
width = 40,
|
||||
height = 20,
|
||||
aspectRatio = 40f / 20f,
|
||||
caption = "Narrow image with null aspectRatio",
|
||||
width = 80,
|
||||
height = 150,
|
||||
aspectRatio = null,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = false,
|
||||
content = aTimelineItemImageContent(
|
||||
filename = "image.jpg",
|
||||
caption = "Narrow image with null aspectRatio",
|
||||
width = 80,
|
||||
height = 150,
|
||||
aspectRatio = null,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -39,7 +39,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@@ -52,8 +52,8 @@ fun TimelineItemStickerView(
|
||||
onContentClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onShowClick: () -> Unit,
|
||||
contentValidationState: ContentValidationState,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
|
||||
) {
|
||||
val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image)
|
||||
|
||||
@@ -130,5 +130,6 @@ internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemSticke
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
onShowClick = {},
|
||||
contentValidationState = NoopContentValidationState(),
|
||||
)
|
||||
}
|
||||
|
||||
+4
-2
@@ -51,7 +51,7 @@ import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
|
||||
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||
@@ -63,8 +63,8 @@ fun TimelineItemVideoView(
|
||||
onContentClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onShowContentClick: () -> Unit,
|
||||
contentValidationState: ContentValidationState,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
|
||||
) {
|
||||
val isTalkbackActive = isTalkbackActive()
|
||||
val a11yLabel = stringResource(CommonStrings.common_video)
|
||||
@@ -157,6 +157,7 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
contentValidationState = NoopContentValidationState(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,5 +170,6 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
contentValidationState = NoopContentValidationState(),
|
||||
)
|
||||
}
|
||||
|
||||
+3
-1
@@ -64,8 +64,8 @@ fun TimelineItemVoiceView(
|
||||
state: VoiceMessageState,
|
||||
content: TimelineItemVoiceContent,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
contentValidationValue: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
||||
) {
|
||||
fun playPause() {
|
||||
state.eventSink(VoiceMessageEvent.PlayPause)
|
||||
@@ -289,6 +289,7 @@ internal fun TimelineItemVoiceViewPreview(
|
||||
state = timelineItemVoiceViewParameters.state,
|
||||
content = timelineItemVoiceViewParameters.content,
|
||||
onContentLayoutChange = {},
|
||||
contentValidationValue = ContentValidationValue.Valid,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -302,6 +303,7 @@ internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview {
|
||||
state = it.state,
|
||||
content = it.content,
|
||||
onContentLayoutChange = {},
|
||||
contentValidationValue = ContentValidationValue.Valid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-6
@@ -66,7 +66,8 @@ fun OutgoingVerificationView(
|
||||
fun cancelOrResetFlow() {
|
||||
when (step) {
|
||||
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)
|
||||
is Step.Verifying -> {
|
||||
if (!step.state.isLoading()) {
|
||||
@@ -96,7 +97,9 @@ fun OutgoingVerificationView(
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
BackButton(onClick = ::cancelOrResetFlow)
|
||||
if (step !is Step.Completed) {
|
||||
BackButton(onClick = ::cancelOrResetFlow)
|
||||
}
|
||||
},
|
||||
colors = topAppBarColors(containerColor = Color.Transparent),
|
||||
)
|
||||
@@ -108,7 +111,7 @@ fun OutgoingVerificationView(
|
||||
OutgoingVerificationBottomMenu(
|
||||
state = state,
|
||||
onCancelClick = ::cancelOrResetFlow,
|
||||
onContinueClick = onFinish,
|
||||
onDoneClick = onFinish,
|
||||
)
|
||||
},
|
||||
isScrollable = true,
|
||||
@@ -244,7 +247,7 @@ private fun ContentInitial(
|
||||
private fun OutgoingVerificationBottomMenu(
|
||||
state: OutgoingVerificationState,
|
||||
onCancelClick: () -> Unit,
|
||||
onContinueClick: () -> Unit,
|
||||
onDoneClick: () -> Unit,
|
||||
) {
|
||||
val eventSink = state.eventSink
|
||||
when (val step = state.step) {
|
||||
@@ -313,8 +316,8 @@ private fun OutgoingVerificationBottomMenu(
|
||||
VerificationBottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_continue),
|
||||
onClick = onContinueClick,
|
||||
text = stringResource(CommonStrings.action_done),
|
||||
onClick = onDoneClick,
|
||||
)
|
||||
InvisibleButton()
|
||||
}
|
||||
|
||||
+4
-4
@@ -99,10 +99,10 @@ class OutgoingVerificationViewTest : RobolectricTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back key pressed - on Completed exits the flow`() = runAndroidComposeUiTest {
|
||||
fun `back key pressed - on Completed finishes the flow`() = runAndroidComposeUiTest {
|
||||
ensureCalledOnce { callback ->
|
||||
setOutgoingVerificationView(
|
||||
onBack = callback,
|
||||
onFinished = callback,
|
||||
state = anOutgoingVerificationState(
|
||||
step = OutgoingVerificationState.Step.Completed,
|
||||
),
|
||||
@@ -112,7 +112,7 @@ class OutgoingVerificationViewTest : RobolectricTest() {
|
||||
}
|
||||
|
||||
@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)
|
||||
ensureCalledOnce { callback ->
|
||||
setOutgoingVerificationView(
|
||||
@@ -122,7 +122,7 @@ class OutgoingVerificationViewTest : RobolectricTest() {
|
||||
),
|
||||
onFinished = callback,
|
||||
)
|
||||
clickOn(CommonStrings.action_continue)
|
||||
clickOn(CommonStrings.action_done)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ android_gradle_plugin = "9.2.1"
|
||||
# When updating this, please also update the version in the file ./idea/kotlinc.xml
|
||||
kotlin = "2.4.0"
|
||||
kotlinpoet = "2.3.0"
|
||||
ksp = "2.3.9"
|
||||
ksp = "2.3.10"
|
||||
firebaseAppDistribution = "5.3.0"
|
||||
|
||||
# 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" }
|
||||
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" }
|
||||
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"
|
||||
|
||||
# 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-jvm = { module = "app.cash.sqldelight:sqlite-driver", 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"
|
||||
unifiedpush = "org.unifiedpush.android:connector:3.3.3"
|
||||
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
|
||||
|
||||
+1
@@ -116,6 +116,7 @@ class DefaultContentValidationState(
|
||||
|
||||
private fun calculateOverallState(states: Map<String, ContentValidationValue>): ContentValidationValue {
|
||||
return when {
|
||||
states.values.isEmpty() -> ContentValidationValue.Unknown
|
||||
states.values.any { it is ContentValidationValue.Invalid } -> ContentValidationValue.Invalid
|
||||
states.values.any { it is ContentValidationValue.Loading } -> ContentValidationValue.Loading
|
||||
states.values.any { it is ContentValidationValue.UnrecoverableError } -> states.values.first { it is ContentValidationValue.UnrecoverableError }
|
||||
|
||||
+4
-2
@@ -26,8 +26,10 @@ val LocalEventContentValidationState = staticCompositionLocalOf<EventContentVali
|
||||
/**
|
||||
* A noop implementation of the [EventContentValidationCache] that immediately returns a successful validation state. This will be used in FOSS.
|
||||
*/
|
||||
class NoopEventContentValidationCache : EventContentValidationCache {
|
||||
override operator fun get(eventId: EventId): ContentValidationState = noopValue
|
||||
class NoopEventContentValidationCache(
|
||||
private val provided: ContentValidationState = noopValue,
|
||||
) : EventContentValidationCache {
|
||||
override operator fun get(eventId: EventId): ContentValidationState = provided
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,7 @@ dependencies {
|
||||
|
||||
implementation(projects.features.enterprise.api)
|
||||
implementation(projects.features.viewfolder.api)
|
||||
implementation(projects.features.contentscanner.api)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.audio.api)
|
||||
@@ -48,6 +49,7 @@ dependencies {
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.matrixmedia.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.uiUtils)
|
||||
|
||||
+15
@@ -44,6 +44,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
@@ -54,6 +56,7 @@ class EventItemFactory(
|
||||
private val fileSizeFormatter: FileSizeFormatter,
|
||||
private val fileExtensionExtractor: FileExtensionExtractor,
|
||||
private val dateFormatter: DateFormatter,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
) {
|
||||
fun create(
|
||||
currentTimelineItem: MatrixTimelineItem.Event,
|
||||
@@ -67,6 +70,8 @@ class EventItemFactory(
|
||||
timestamp = currentTimelineItem.event.timestamp,
|
||||
mode = DateFormatterMode.Full,
|
||||
)
|
||||
val validationState = currentTimelineItem.eventId?.let { contentValidationCache[it] }
|
||||
?: NoopContentValidationState()
|
||||
return when (val content = event.content) {
|
||||
is CallNotifyContent,
|
||||
is FailedToParseMessageLikeContent,
|
||||
@@ -117,6 +122,7 @@ class EventItemFactory(
|
||||
mediaSource = c.source,
|
||||
thumbnailSource = c.info?.thumbnailSource,
|
||||
blurHash = c.info?.blurhash,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Video -> {
|
||||
@@ -138,6 +144,7 @@ class EventItemFactory(
|
||||
mediaSource = c.source,
|
||||
thumbnailSource = c.info?.thumbnailSource,
|
||||
blurHash = c.info?.blurhash,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Audio -> {
|
||||
@@ -156,6 +163,7 @@ class EventItemFactory(
|
||||
dateSentFull = dateSentFull,
|
||||
),
|
||||
mediaSource = c.source,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.File -> {
|
||||
@@ -174,6 +182,7 @@ class EventItemFactory(
|
||||
dateSentFull = dateSentFull,
|
||||
),
|
||||
mediaSource = c.source,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Other -> null
|
||||
@@ -200,6 +209,7 @@ class EventItemFactory(
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = type.source,
|
||||
validationState = validationState,
|
||||
))
|
||||
is FileMessageType -> listOf(MediaItem.File(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -221,6 +231,7 @@ class EventItemFactory(
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = type.source,
|
||||
validationState = validationState,
|
||||
// TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource
|
||||
))
|
||||
is ImageMessageType -> listOf(MediaItem.Image(
|
||||
@@ -245,6 +256,7 @@ class EventItemFactory(
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
blurHash = type.info?.blurhash,
|
||||
validationState = validationState,
|
||||
))
|
||||
is StickerMessageType -> listOf(MediaItem.Image(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -268,6 +280,7 @@ class EventItemFactory(
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
blurHash = type.info?.blurhash,
|
||||
validationState = validationState,
|
||||
))
|
||||
is VideoMessageType -> listOf(MediaItem.Video(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -291,6 +304,7 @@ class EventItemFactory(
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
blurHash = type.info?.blurhash,
|
||||
validationState = validationState,
|
||||
))
|
||||
is VoiceMessageType -> listOf(MediaItem.Voice(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -312,6 +326,7 @@ class EventItemFactory(
|
||||
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
||||
),
|
||||
mediaSource = type.source,
|
||||
validationState = validationState,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.libraries.androidutils.R
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
@@ -43,7 +44,11 @@ import io.element.android.libraries.mediaviewer.impl.model.eventId
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaInfo
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaPermissions
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaSource
|
||||
import io.element.android.libraries.mediaviewer.impl.model.thumbnailSource
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AssistedInject
|
||||
@@ -55,6 +60,7 @@ class MediaGalleryPresenter(
|
||||
private val mediaLoader: MatrixMediaLoader,
|
||||
private val localMediaActions: LocalMediaActions,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
) : Presenter<MediaGalleryState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -74,6 +80,17 @@ class MediaGalleryPresenter(
|
||||
|
||||
val groupedMediaItems by remember {
|
||||
mediaGalleryDataSource.groupedMediaItemsFlow()
|
||||
.onEach { groupedItems ->
|
||||
if (groupedItems is AsyncData.Success) {
|
||||
val items = groupedItems.data.getItems(mode)
|
||||
items.forEach { item ->
|
||||
if (item is MediaItem.Event) {
|
||||
val eventId = item.eventId() ?: return@forEach
|
||||
coroutineScope.validateMedia(eventId, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.collectAsState(AsyncData.Uninitialized)
|
||||
|
||||
@@ -220,6 +237,21 @@ class MediaGalleryPresenter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.validateMedia(eventId: EventId, mediaItem: MediaItem.Event) {
|
||||
launch {
|
||||
val thumbnailSource = mediaItem.thumbnailSource()
|
||||
val mediaSource = mediaItem.mediaSource()
|
||||
val contentValidationState = mediaItem.validationState
|
||||
val currentState = contentValidationState.overallStateFlow.first()
|
||||
|
||||
if (currentState.isLoading() || currentState.isValid()) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
contentScannerService.scan(eventId, listOfNotNull(thumbnailSource, mediaSource).distinct(), contentValidationState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mediaActionsError(throwable: Throwable): Int {
|
||||
return if (throwable is ActivityNotFoundException) {
|
||||
R.string.error_no_compatible_app_found
|
||||
|
||||
+26
-4
@@ -13,6 +13,8 @@ import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.components.media.WaveFormSamples
|
||||
import io.element.android.libraries.designsystem.preview.ROOM_NAME
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||
import io.element.android.libraries.mediaviewer.impl.details.aMediaBottomSheetStateDetails
|
||||
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
|
||||
@@ -47,9 +49,20 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
|
||||
aMediaItemImage(id = UniqueId("3")),
|
||||
aMediaItemVideo(id = UniqueId("4")),
|
||||
aMediaItemImage(id = UniqueId("5")),
|
||||
aMediaItemImage(id = UniqueId("6")),
|
||||
aMediaItemImage(id = UniqueId("7")),
|
||||
aMediaItemImage(id = UniqueId("8")),
|
||||
aMediaItemImage(
|
||||
id = UniqueId("6"),
|
||||
validationState = NoopContentValidationState(ContentValidationValue.Loading),
|
||||
blurHash = "LKO2?U%2Tw=w]~RBVZRi};RPxuwH"
|
||||
),
|
||||
aMediaItemImage(id = UniqueId("7"), validationState = NoopContentValidationState(ContentValidationValue.Invalid)),
|
||||
aMediaItemImage(
|
||||
id = UniqueId("8"),
|
||||
validationState = NoopContentValidationState(
|
||||
ContentValidationValue.UnrecoverableError(
|
||||
IllegalStateException("Failed to validate media item")
|
||||
)
|
||||
)
|
||||
),
|
||||
aMediaItemImage(id = UniqueId("9")),
|
||||
aMediaItemLoadingIndicator(),
|
||||
).toImmutableList()
|
||||
@@ -65,7 +78,7 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
|
||||
aGroupedMediaItems(
|
||||
fileItems = listOf(
|
||||
aMediaItemDateSeparator(id = UniqueId("0")),
|
||||
aMediaItemFile(id = UniqueId("1")),
|
||||
aMediaItemFile(id = UniqueId("1"), validationState = NoopContentValidationState(ContentValidationValue.Loading)),
|
||||
aMediaItemDateSeparator(
|
||||
id = UniqueId("2"),
|
||||
formattedDate = "September 2004",
|
||||
@@ -75,6 +88,15 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
|
||||
id = UniqueId("5"),
|
||||
waveform = WaveFormSamples.realisticWaveForm,
|
||||
),
|
||||
aMediaItemAudio(id = UniqueId("6"), validationState = NoopContentValidationState(ContentValidationValue.Invalid)),
|
||||
aMediaItemAudio(
|
||||
id = UniqueId("7"),
|
||||
validationState = NoopContentValidationState(
|
||||
ContentValidationValue.UnrecoverableError(
|
||||
IllegalStateException("Failed to validate media item")
|
||||
)
|
||||
)
|
||||
),
|
||||
aMediaItemLoadingIndicator(),
|
||||
).toImmutableList()
|
||||
)
|
||||
|
||||
+73
-10
@@ -9,14 +9,18 @@
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
@@ -31,6 +35,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -49,11 +55,13 @@ import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubti
|
||||
import io.element.android.libraries.designsystem.background.OnboardingBackground
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.SegmentedButton
|
||||
@@ -62,6 +70,9 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.mediaviewer.impl.R
|
||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||
import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet
|
||||
@@ -315,6 +326,14 @@ private fun MediaGalleryFilesList(
|
||||
key = { it.id() },
|
||||
contentType = { it::class.java },
|
||||
) { item ->
|
||||
val contentValidationState = remember(item is MediaItem.Event) {
|
||||
if (item is MediaItem.Event) {
|
||||
item.validationState
|
||||
} else {
|
||||
NoopContentValidationState()
|
||||
}
|
||||
}
|
||||
val contentValidationValue by contentValidationState.collectOverallState()
|
||||
when (item) {
|
||||
is MediaItem.File -> FileItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
@@ -323,6 +342,7 @@ private fun MediaGalleryFilesList(
|
||||
onLongClick = {
|
||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||
},
|
||||
contentValidationState = contentValidationValue,
|
||||
)
|
||||
is MediaItem.Audio -> AudioItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
@@ -331,6 +351,7 @@ private fun MediaGalleryFilesList(
|
||||
onLongClick = {
|
||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||
},
|
||||
contentValidationValue = contentValidationValue,
|
||||
)
|
||||
is MediaItem.Voice -> {
|
||||
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(item)
|
||||
@@ -341,16 +362,17 @@ private fun MediaGalleryFilesList(
|
||||
onLongClick = {
|
||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||
},
|
||||
contentValidationValue = contentValidationValue,
|
||||
)
|
||||
}
|
||||
is MediaItem.Image,
|
||||
is MediaItem.Video -> {
|
||||
// Should not happen
|
||||
}
|
||||
is MediaItem.DateSeparator -> DateItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item
|
||||
)
|
||||
is MediaItem.Image,
|
||||
is MediaItem.Video -> {
|
||||
// Should not happen
|
||||
}
|
||||
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item,
|
||||
@@ -387,11 +409,48 @@ private fun MediaGalleryImageGrid(
|
||||
key = { it.id() },
|
||||
contentType = { it::class.java },
|
||||
) { item ->
|
||||
val blurHash = when (item) {
|
||||
is MediaItem.Image -> item.blurHash
|
||||
is MediaItem.Video -> item.blurHash
|
||||
else -> null
|
||||
}
|
||||
|
||||
val blurHashBackgroundModifier = if (blurHash != null) {
|
||||
Modifier.blurHashBackground(blurHash)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val currentValidationState by when (item) {
|
||||
is MediaItem.Event -> item.validationState.collectOverallState()
|
||||
else -> remember { mutableStateOf(ContentValidationValue.Unknown) }
|
||||
}
|
||||
|
||||
when (item) {
|
||||
is MediaItem.DateSeparator -> DateItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item,
|
||||
)
|
||||
is MediaItem.Event if !currentValidationState.isValidated() -> {
|
||||
Box(modifier = blurHashBackgroundModifier.aspectRatio(1f), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
is MediaItem.Event if currentValidationState.isInvalid() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable { onItemClick(item) }
|
||||
.background(ElementTheme.colors.bgCriticalSubtle)
|
||||
.aspectRatio(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
imageVector = CompoundIcons.Error(),
|
||||
tint = ElementTheme.colors.iconCriticalPrimary,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
is MediaItem.Audio -> {
|
||||
// Should not happen
|
||||
}
|
||||
@@ -402,7 +461,7 @@ private fun MediaGalleryImageGrid(
|
||||
// Should not happen
|
||||
}
|
||||
is MediaItem.Image -> ImageItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
modifier = blurHashBackgroundModifier.animateItem(),
|
||||
image = item,
|
||||
onClick = { onItemClick(item) },
|
||||
onLongClick = {
|
||||
@@ -410,13 +469,17 @@ private fun MediaGalleryImageGrid(
|
||||
},
|
||||
)
|
||||
is MediaItem.Video -> VideoItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
modifier = blurHashBackgroundModifier.animateItem(),
|
||||
video = item,
|
||||
onClick = { onItemClick(item) },
|
||||
onLongClick = {
|
||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||
},
|
||||
)
|
||||
is MediaItem.DateSeparator -> DateItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item,
|
||||
)
|
||||
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item,
|
||||
|
||||
+30
-96
@@ -8,117 +8,38 @@
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.core.extensions.withBrackets
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun AudioItemView(
|
||||
audio: MediaItem.Audio,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
contentValidationValue: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
GalleryFileListItem(
|
||||
modifier = modifier,
|
||||
contentValidationValue = contentValidationValue,
|
||||
caption = audio.mediaInfo.caption,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
FilenameRow(
|
||||
audio = audio,
|
||||
GalleryFileListItemContent(
|
||||
name = audio.mediaInfo.filename,
|
||||
formattedSize = audio.mediaInfo.formattedFileSize,
|
||||
icon = CompoundIcons.Audio(),
|
||||
isValidating = contentValidationValue.isLoading(),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
val caption = audio.mediaInfo.caption
|
||||
if (caption != null) {
|
||||
CaptionView(caption)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilenameRow(
|
||||
audio: MediaItem.Audio,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||
)
|
||||
.onKeyboardContextMenuAction(onLongClick)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = ElementTheme.colors.bgActionSecondaryRest,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.size(32.dp)
|
||||
.padding(6.dp),
|
||||
imageVector = CompoundIcons.Audio(),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = audio.mediaInfo.filename,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val formattedSize = audio.mediaInfo.formattedFileSize
|
||||
if (formattedSize.isNotEmpty()) {
|
||||
Text(
|
||||
text = formattedSize.withBrackets(),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +48,22 @@ private fun FilenameRow(
|
||||
internal fun AudioItemViewPreview(
|
||||
@PreviewParameter(MediaItemAudioProvider::class) audio: MediaItem.Audio,
|
||||
) = ElementPreview {
|
||||
AudioItemView(
|
||||
audio = audio,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
val states = remember {
|
||||
listOf(
|
||||
ContentValidationValue.Valid,
|
||||
ContentValidationValue.Loading,
|
||||
ContentValidationValue.Invalid,
|
||||
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
|
||||
)
|
||||
}
|
||||
Column {
|
||||
for (state in states) {
|
||||
AudioItemView(
|
||||
contentValidationValue = state,
|
||||
audio = audio,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-96
@@ -8,117 +8,38 @@
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.core.extensions.withBrackets
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun FileItemView(
|
||||
file: MediaItem.File,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
contentValidationState: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
GalleryFileListItem(
|
||||
modifier = modifier,
|
||||
contentValidationValue = contentValidationState,
|
||||
caption = file.mediaInfo.caption
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
FilenameRow(
|
||||
file = file,
|
||||
GalleryFileListItemContent(
|
||||
name = file.mediaInfo.filename,
|
||||
formattedSize = file.mediaInfo.formattedFileSize,
|
||||
icon = CompoundIcons.Attachment(),
|
||||
onClick = onClick,
|
||||
isValidating = contentValidationState.isLoading(),
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
val caption = file.mediaInfo.caption
|
||||
if (caption != null) {
|
||||
CaptionView(caption)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilenameRow(
|
||||
file: MediaItem.File,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||
)
|
||||
.onKeyboardContextMenuAction(onLongClick)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = ElementTheme.colors.bgActionSecondaryRest,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.size(32.dp)
|
||||
.padding(6.dp),
|
||||
imageVector = CompoundIcons.Attachment(),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = file.mediaInfo.filename,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val formattedSize = file.mediaInfo.formattedFileSize
|
||||
if (formattedSize.isNotEmpty()) {
|
||||
Text(
|
||||
text = formattedSize.withBrackets(),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +48,22 @@ private fun FilenameRow(
|
||||
internal fun FileItemViewPreview(
|
||||
@PreviewParameter(MediaItemFileProvider::class) file: MediaItem.File,
|
||||
) = ElementPreview {
|
||||
FileItemView(
|
||||
file = file,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
val states = remember {
|
||||
listOf(
|
||||
ContentValidationValue.Valid,
|
||||
ContentValidationValue.Loading,
|
||||
ContentValidationValue.Invalid,
|
||||
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
|
||||
)
|
||||
}
|
||||
Column {
|
||||
for (state in states) {
|
||||
FileItemView(
|
||||
file = file,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
contentValidationState = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.core.extensions.withBrackets
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun GalleryFileListItem(
|
||||
contentValidationValue: ContentValidationValue,
|
||||
caption: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
when (contentValidationValue) {
|
||||
ContentValidationValue.Invalid -> InvalidContentView(
|
||||
modifier = invalidContentModifier(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
is ContentValidationValue.UnrecoverableError -> NotFoundContentView(
|
||||
modifier = invalidContentModifier(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
else -> {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
if (caption != null) {
|
||||
CaptionView(caption)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun invalidContentModifier(): Modifier {
|
||||
return Modifier
|
||||
.border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = RoundedCornerShape(12.dp))
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(ElementTheme.colors.bgCriticalSubtle)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GalleryFileListItemContent(
|
||||
name: String,
|
||||
formattedSize: String,
|
||||
icon: ImageVector,
|
||||
isValidating: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = onClick.takeIf { !isValidating } ?: {},
|
||||
onLongClick = onLongClick.takeIf { !isValidating } ?: {},
|
||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||
)
|
||||
.onKeyboardContextMenuAction(onLongClick)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val iconModifier = Modifier
|
||||
.background(
|
||||
color = ElementTheme.colors.bgActionSecondaryRest,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.size(32.dp)
|
||||
.padding(6.dp)
|
||||
|
||||
if (isValidating) {
|
||||
CircularProgressIndicator(
|
||||
modifier = iconModifier,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
modifier = iconModifier,
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = name,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (formattedSize.isNotEmpty()) {
|
||||
Text(
|
||||
text = formattedSize.withBrackets(),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -22,7 +22,7 @@ class MediaItemAudioProvider : PreviewParameterProvider<MediaItem.Audio> {
|
||||
caption = "A caption",
|
||||
),
|
||||
aMediaItemAudio(
|
||||
caption = loremIpsum,
|
||||
caption = loremIpsum.take(80),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ class MediaItemFileProvider : PreviewParameterProvider<MediaItem.File> {
|
||||
caption = "A caption",
|
||||
),
|
||||
aMediaItemFile(
|
||||
caption = loremIpsum,
|
||||
caption = loremIpsum.take(80),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> {
|
||||
caption = "A caption",
|
||||
),
|
||||
aMediaItemVoice(
|
||||
caption = loremIpsum,
|
||||
caption = loremIpsum.take(80),
|
||||
),
|
||||
aMediaItemVoice(
|
||||
waveform = emptyList(),
|
||||
|
||||
+61
-31
@@ -45,10 +45,10 @@ import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenu
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -64,26 +64,20 @@ fun VoiceItemView(
|
||||
state: VoiceMessageState,
|
||||
voice: MediaItem.Voice,
|
||||
onLongClick: () -> Unit,
|
||||
contentValidationValue: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
GalleryFileListItem(
|
||||
modifier = modifier,
|
||||
contentValidationValue = contentValidationValue,
|
||||
caption = voice.mediaInfo.caption,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
VoiceInfoRow(
|
||||
state = state,
|
||||
voice = voice,
|
||||
isValidating = contentValidationValue.isLoading(),
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
val caption = voice.mediaInfo.caption
|
||||
if (caption != null) {
|
||||
CaptionView(caption)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +85,7 @@ fun VoiceItemView(
|
||||
private fun VoiceInfoRow(
|
||||
state: VoiceMessageState,
|
||||
voice: MediaItem.Voice,
|
||||
isValidating: Boolean,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
fun playPause() {
|
||||
@@ -114,12 +109,21 @@ private fun VoiceInfoRow(
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (state.buttonType) {
|
||||
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
|
||||
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
|
||||
if (isValidating) {
|
||||
CustomIconButton(onClick = {}, enabled = false) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
when (state.buttonType) {
|
||||
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
|
||||
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(
|
||||
@@ -128,7 +132,7 @@ private fun VoiceInfoRow(
|
||||
) {
|
||||
PlaybackSpeedButton(
|
||||
speed = state.playbackSpeed,
|
||||
onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) },
|
||||
onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) }.takeIf { !isValidating } ?: {},
|
||||
)
|
||||
Text(
|
||||
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
|
||||
@@ -149,7 +153,7 @@ private fun VoiceInfoRow(
|
||||
onSeek = {
|
||||
state.eventSink(VoiceMessageEvent.Seek(it))
|
||||
},
|
||||
seekEnabled = true,
|
||||
seekEnabled = !isValidating,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -277,11 +281,24 @@ private fun CustomIconButton(
|
||||
internal fun VoiceItemViewPreview(
|
||||
@PreviewParameter(MediaItemVoiceProvider::class) voice: MediaItem.Voice,
|
||||
) = ElementPreview {
|
||||
VoiceItemView(
|
||||
state = aVoiceMessageState(),
|
||||
voice = voice,
|
||||
onLongClick = {},
|
||||
)
|
||||
val states = remember {
|
||||
listOf(
|
||||
ContentValidationValue.Valid,
|
||||
ContentValidationValue.Loading,
|
||||
ContentValidationValue.Invalid,
|
||||
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
|
||||
)
|
||||
}
|
||||
Column {
|
||||
for (state in states) {
|
||||
VoiceItemView(
|
||||
state = aVoiceMessageState(),
|
||||
voice = voice,
|
||||
onLongClick = {},
|
||||
contentValidationValue = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@@ -289,9 +306,22 @@ internal fun VoiceItemViewPreview(
|
||||
internal fun VoiceItemViewPlayPreview(
|
||||
@PreviewParameter(VoiceMessageStateProvider::class) state: VoiceMessageState,
|
||||
) = ElementPreview {
|
||||
VoiceItemView(
|
||||
state = state,
|
||||
voice = aMediaItemVoice(),
|
||||
onLongClick = {},
|
||||
)
|
||||
val validationState = remember {
|
||||
listOf(
|
||||
ContentValidationValue.Valid,
|
||||
ContentValidationValue.Loading,
|
||||
ContentValidationValue.Invalid,
|
||||
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
|
||||
)
|
||||
}
|
||||
Column {
|
||||
for (validationState in validationState) {
|
||||
VoiceItemView(
|
||||
state = state,
|
||||
voice = aMediaItemVoice(),
|
||||
onLongClick = {},
|
||||
contentValidationValue = validationState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-23
@@ -254,29 +254,31 @@ private fun ExoPlayerMediaVideoView(
|
||||
},
|
||||
)
|
||||
}
|
||||
MediaPlayerControllerView(
|
||||
state = mediaPlayerControllerState,
|
||||
onTogglePlay = {
|
||||
autoHideController++
|
||||
exoPlayer.togglePlay()
|
||||
},
|
||||
onSeekChange = {
|
||||
autoHideController++
|
||||
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||
seekingToMillis = it.toLong(),
|
||||
)
|
||||
exoPlayer.seekToEnsurePlaying(it.toLong())
|
||||
},
|
||||
onToggleMute = {
|
||||
autoHideController++
|
||||
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
|
||||
},
|
||||
audioFocus = audioFocus,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = bottomPaddingInPixels.toDp()),
|
||||
)
|
||||
if (localMedia != null) {
|
||||
MediaPlayerControllerView(
|
||||
state = mediaPlayerControllerState,
|
||||
onTogglePlay = {
|
||||
autoHideController++
|
||||
exoPlayer.togglePlay()
|
||||
},
|
||||
onSeekChange = {
|
||||
autoHideController++
|
||||
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||
seekingToMillis = it.toLong(),
|
||||
)
|
||||
exoPlayer.seekToEnsurePlaying(it.toLong())
|
||||
},
|
||||
onToggleMute = {
|
||||
autoHideController++
|
||||
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
|
||||
},
|
||||
audioFocus = audioFocus,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = bottomPaddingInPixels.toDp()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(exoPlayer.isPlaying) {
|
||||
|
||||
+15
-1
@@ -13,6 +13,8 @@ import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
|
||||
sealed interface MediaItem {
|
||||
@@ -27,7 +29,9 @@ sealed interface MediaItem {
|
||||
val timestamp: Long,
|
||||
) : MediaItem
|
||||
|
||||
sealed interface Event : MediaItem
|
||||
sealed interface Event : MediaItem {
|
||||
val validationState: ContentValidationState
|
||||
}
|
||||
|
||||
data class Image(
|
||||
val id: UniqueId,
|
||||
@@ -36,6 +40,7 @@ sealed interface MediaItem {
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
override val validationState: ContentValidationState,
|
||||
) : Event {
|
||||
val thumbnailMediaRequestData: MediaRequestData
|
||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
||||
@@ -48,6 +53,7 @@ sealed interface MediaItem {
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
override val validationState: ContentValidationState,
|
||||
) : Event {
|
||||
val thumbnailMediaRequestData: MediaRequestData
|
||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
||||
@@ -58,6 +64,7 @@ sealed interface MediaItem {
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
override val validationState: ContentValidationState,
|
||||
) : Event
|
||||
|
||||
data class Voice(
|
||||
@@ -65,6 +72,7 @@ sealed interface MediaItem {
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
override val validationState: ContentValidationState,
|
||||
) : Event
|
||||
|
||||
data class File(
|
||||
@@ -72,6 +80,7 @@ sealed interface MediaItem {
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
override val validationState: ContentValidationState,
|
||||
) : Event
|
||||
}
|
||||
|
||||
@@ -134,3 +143,8 @@ fun MediaItem.Event.blurHash(): String? {
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaItem.isMediaValid(): Boolean? = when (this) {
|
||||
is MediaItem.Event -> validationState.getCurrentOverallState() == ContentValidationValue.Valid
|
||||
else -> null
|
||||
}
|
||||
|
||||
+14
-1
@@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
|
||||
@@ -25,6 +27,8 @@ fun aMediaItemImage(
|
||||
eventId: EventId? = null,
|
||||
senderId: UserId? = null,
|
||||
mediaSourceUrl: String = "",
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
blurHash: String? = null,
|
||||
): MediaItem.Image {
|
||||
return MediaItem.Image(
|
||||
id = id,
|
||||
@@ -34,7 +38,8 @@ fun aMediaItemImage(
|
||||
),
|
||||
mediaSource = MediaSource(mediaSourceUrl),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
blurHash = blurHash,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,6 +47,7 @@ fun aMediaItemVideo(
|
||||
id: UniqueId = UniqueId("videoId"),
|
||||
mediaSource: MediaSource = MediaSource(""),
|
||||
duration: String? = "1:23",
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
): MediaItem.Video {
|
||||
return MediaItem.Video(
|
||||
id = id,
|
||||
@@ -52,6 +58,7 @@ fun aMediaItemVideo(
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,6 +67,7 @@ fun aMediaItemFile(
|
||||
eventId: EventId? = null,
|
||||
filename: String = "filename",
|
||||
caption: String? = null,
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
): MediaItem.File {
|
||||
return MediaItem.File(
|
||||
id = id,
|
||||
@@ -69,6 +77,7 @@ fun aMediaItemFile(
|
||||
caption = caption,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,6 +86,7 @@ fun aMediaItemAudio(
|
||||
eventId: EventId? = null,
|
||||
filename: String = "filename",
|
||||
caption: String? = null,
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
): MediaItem.Audio {
|
||||
return MediaItem.Audio(
|
||||
id = id,
|
||||
@@ -86,6 +96,7 @@ fun aMediaItemAudio(
|
||||
caption = caption,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -95,6 +106,7 @@ fun aMediaItemVoice(
|
||||
caption: String? = null,
|
||||
duration: String? = "1:23",
|
||||
waveform: List<Float> = WaveFormSamples.realisticWaveForm,
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
): MediaItem.Voice {
|
||||
return MediaItem.Voice(
|
||||
id = id,
|
||||
@@ -106,6 +118,7 @@ fun aMediaItemVoice(
|
||||
waveForm = waveform,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -11,6 +11,7 @@ import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryInfo
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryItemData
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
@@ -37,6 +38,7 @@ class GalleryMediaGalleryDataSource(
|
||||
eventId: EventId?,
|
||||
galleryItems: List<GalleryItemData>,
|
||||
galleryInfo: GalleryInfo,
|
||||
contentValidationState: ContentValidationState,
|
||||
): GalleryMediaGalleryDataSource {
|
||||
val mixedItems = mutableListOf<MediaItem.Event>()
|
||||
galleryItems.forEachIndexed { index, galleryItem ->
|
||||
@@ -64,18 +66,21 @@ class GalleryMediaGalleryDataSource(
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
thumbnailSource = galleryItem.thumbnailSource,
|
||||
blurHash = galleryItem.blurHash,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
GalleryItemData.Type.Audio -> MediaItem.Audio(
|
||||
id = id,
|
||||
eventId = eventId,
|
||||
mediaInfo = itemMediaInfo,
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
GalleryItemData.Type.File -> MediaItem.File(
|
||||
id = id,
|
||||
eventId = eventId,
|
||||
mediaInfo = itemMediaInfo,
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
GalleryItemData.Type.Image -> MediaItem.Image(
|
||||
id = id,
|
||||
@@ -84,6 +89,7 @@ class GalleryMediaGalleryDataSource(
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
thumbnailSource = galleryItem.thumbnailSource,
|
||||
blurHash = galleryItem.blurHash,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
mixedItems.add(mediaItem)
|
||||
|
||||
+27
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -24,6 +25,9 @@ import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint.MediaViewerMode
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||
@@ -57,6 +61,8 @@ class MediaViewerDataSource(
|
||||
private val localMediaFactory: LocalMediaFactory,
|
||||
private val systemClock: SystemClock,
|
||||
private val pagerKeysHandler: PagerKeysHandler,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
) {
|
||||
// List of media files that are currently being loaded
|
||||
private val mediaFiles: ConcurrentHashMap<MediaSource, MediaFile> = ConcurrentHashMap()
|
||||
@@ -71,6 +77,9 @@ class MediaViewerDataSource(
|
||||
private val localMediaStates: MutableMap<String, MutableState<AsyncData<LocalMedia>>> =
|
||||
mutableMapOf()
|
||||
|
||||
private val mediaValidationState: MutableMap<String, ContentValidationState> =
|
||||
mutableMapOf()
|
||||
|
||||
fun setup(coroutineScope: CoroutineScope) {
|
||||
galleryDataSource.start(coroutineScope)
|
||||
}
|
||||
@@ -162,6 +171,9 @@ class MediaViewerDataSource(
|
||||
val localMedia = localMediaStates.getOrPut(sourceUrl) {
|
||||
mutableStateOf(AsyncData.Uninitialized)
|
||||
}
|
||||
val validationState = mediaValidationState.getOrPut(sourceUrl) {
|
||||
mediaItem.eventId()?.let { contentValidationCache[it] } ?: DefaultContentValidationState()
|
||||
}
|
||||
add(
|
||||
MediaViewerPageData.MediaViewerData(
|
||||
eventId = mediaItem.eventId(),
|
||||
@@ -170,6 +182,7 @@ class MediaViewerDataSource(
|
||||
thumbnailSource = mediaItem.thumbnailSource(),
|
||||
downloadedMedia = localMedia,
|
||||
pagerKey = pagerKeysHandler.getKey(mediaItem),
|
||||
validationState = validationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -229,6 +242,20 @@ class MediaViewerDataSource(
|
||||
}
|
||||
}
|
||||
|
||||
fun validateMedia(
|
||||
eventId: EventId,
|
||||
mediaSource: MediaSource,
|
||||
) {
|
||||
val validationState = mediaValidationState[mediaSource.safeUrl] ?: return
|
||||
val currentState = validationState.getCurrentMediaState(mediaSource.safeUrl)
|
||||
|
||||
if (currentState.isLoading() || currentState.isValid()) {
|
||||
return
|
||||
}
|
||||
|
||||
contentScannerService.scan(eventId, listOf(mediaSource), validationState)
|
||||
}
|
||||
|
||||
fun cancelLoadingMedia(data: MediaViewerPageData.MediaViewerData) {
|
||||
if (localMediaStates[data.mediaSource.safeUrl]?.value?.isLoading() == true) {
|
||||
Timber.d("cancelLoadingMedia for ${data.eventId}")
|
||||
|
||||
+2
@@ -9,6 +9,7 @@
|
||||
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
|
||||
sealed interface MediaViewerEvent {
|
||||
@@ -30,4 +31,5 @@ sealed interface MediaViewerEvent {
|
||||
data class OnNavigateTo(val index: Int) : MediaViewerEvent
|
||||
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvent
|
||||
data class CancelLoadingMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvent
|
||||
data class ValidateMedia(val eventId: EventId, val mediaSource: MediaSource) : MediaViewerEvent
|
||||
}
|
||||
|
||||
+11
-3
@@ -22,6 +22,7 @@ import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.compound.theme.ForcedDarkElementTheme
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||
import io.element.android.libraries.architecture.callback
|
||||
@@ -33,6 +34,8 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||
import io.element.android.libraries.mediaviewer.impl.datasource.FocusedTimelineMediaGalleryDataSourceFactory
|
||||
@@ -57,6 +60,8 @@ class MediaViewerNode(
|
||||
private val audioFocus: AudioFocus,
|
||||
private val sessionId: SessionId,
|
||||
private val enterpriseService: EnterpriseService,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
) : Node(buildContext, plugins = plugins),
|
||||
MediaViewerNavigator {
|
||||
private val callback: MediaViewerEntryPoint.Callback = callback()
|
||||
@@ -76,12 +81,13 @@ class MediaViewerNode(
|
||||
|
||||
private val mediaGallerySource = when (inputs) {
|
||||
is MediaViewerEntryPoint.Params.Avatar ->
|
||||
SingleMediaGalleryDataSource.createFrom(inputs)
|
||||
SingleMediaGalleryDataSource.createFrom(inputs, contentValidationCache)
|
||||
is MediaViewerEntryPoint.Params.EventGallery ->
|
||||
GalleryMediaGalleryDataSource.createFrom(
|
||||
eventId = inputs.eventId,
|
||||
galleryItems = inputs.galleryItems,
|
||||
galleryInfo = inputs.galleryInfo,
|
||||
contentValidationState = inputs.eventId?.let { contentValidationCache[it] } ?: NoopContentValidationState(),
|
||||
)
|
||||
is MediaViewerEntryPoint.Params.RoomMedia -> {
|
||||
val eventId = inputs.eventId
|
||||
@@ -103,7 +109,7 @@ class MediaViewerNode(
|
||||
} else {
|
||||
focusedTimelineMediaGalleryDataSourceFactory.createFor(
|
||||
eventId = eventId,
|
||||
mediaItem = inputs.toMediaItem(),
|
||||
mediaItem = inputs.toMediaItem(contentValidationCache[eventId]),
|
||||
onlyPinnedEvents = false,
|
||||
)
|
||||
}
|
||||
@@ -111,7 +117,7 @@ class MediaViewerNode(
|
||||
Timeline.Mode.PinnedEvents -> {
|
||||
focusedTimelineMediaGalleryDataSourceFactory.createFor(
|
||||
eventId = eventId,
|
||||
mediaItem = inputs.toMediaItem(),
|
||||
mediaItem = inputs.toMediaItem(contentValidationCache[eventId]),
|
||||
onlyPinnedEvents = true,
|
||||
)
|
||||
}
|
||||
@@ -142,6 +148,8 @@ class MediaViewerNode(
|
||||
localMediaFactory = localMediaFactory,
|
||||
systemClock = systemClock,
|
||||
pagerKeysHandler = pagerKeysHandler,
|
||||
contentScannerService = contentScannerService,
|
||||
contentValidationCache = contentValidationCache,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+5
-1
@@ -42,7 +42,9 @@ import io.element.android.libraries.mediaviewer.impl.model.MediaPermissions
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaPermissions
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import io.element.android.libraries.androidutils.R as UtilsR
|
||||
@@ -70,6 +72,7 @@ class MediaViewerPresenter(
|
||||
private val eventId = inputs.eventId()
|
||||
private val mediaSource = inputs.mediaSource()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Composable
|
||||
override fun present(): MediaViewerState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -93,7 +96,7 @@ class MediaViewerPresenter(
|
||||
// Restore index based on the eventId after the initial items have been loaded
|
||||
currentIndex.intValue = dataSource.findEventIndex(eventId, mediaSource) ?: 0
|
||||
}
|
||||
value = new
|
||||
value = new.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +194,7 @@ class MediaViewerPresenter(
|
||||
is MediaViewerEvent.LoadMore -> coroutineScope.launch {
|
||||
dataSource.loadMore(event.direction)
|
||||
}
|
||||
is MediaViewerEvent.ValidateMedia -> dataSource.validateMedia(event.eventId, event.mediaSource)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -15,6 +15,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||
@@ -51,6 +52,7 @@ sealed interface MediaViewerPageData {
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val downloadedMedia: State<AsyncData<LocalMedia>>,
|
||||
val validationState: ContentValidationState,
|
||||
override val pagerKey: Long,
|
||||
) : MediaViewerPageData
|
||||
}
|
||||
|
||||
+33
@@ -17,6 +17,9 @@ import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.aTxtMediaInfo
|
||||
@@ -275,6 +278,34 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
|
||||
)
|
||||
)
|
||||
},
|
||||
anImageMediaInfo(
|
||||
senderName = "Frank",
|
||||
dateSent = "26 NOV, 2024",
|
||||
).let {
|
||||
aMediaViewerState(
|
||||
listOf(
|
||||
aMediaViewerPageData(
|
||||
downloadedMedia = AsyncData.Uninitialized,
|
||||
validationState = NoopContentValidationState(ContentValidationValue.Loading),
|
||||
mediaInfo = it,
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
anImageMediaInfo(
|
||||
senderName = "Frank",
|
||||
dateSent = "26 NOV, 2024",
|
||||
).let {
|
||||
aMediaViewerState(
|
||||
listOf(
|
||||
aMediaViewerPageData(
|
||||
downloadedMedia = AsyncData.Uninitialized,
|
||||
validationState = NoopContentValidationState(ContentValidationValue.Invalid),
|
||||
mediaInfo = it,
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -293,6 +324,7 @@ fun aMediaViewerPageData(
|
||||
downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
|
||||
mediaInfo: MediaInfo = anImageMediaInfo(),
|
||||
mediaSource: MediaSource = MediaSource(""),
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
): MediaViewerPageData.MediaViewerData = MediaViewerPageData.MediaViewerData(
|
||||
eventId = null,
|
||||
mediaInfo = mediaInfo,
|
||||
@@ -300,6 +332,7 @@ fun aMediaViewerPageData(
|
||||
thumbnailSource = null,
|
||||
downloadedMedia = mutableStateOf(downloadedMedia),
|
||||
pagerKey = 0L,
|
||||
validationState = validationState,
|
||||
)
|
||||
|
||||
fun aMediaViewerState(
|
||||
|
||||
+84
-33
@@ -12,11 +12,14 @@ package io.element.android.libraries.mediaviewer.impl.viewer
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -73,7 +76,10 @@ import io.element.android.libraries.androidutils.text.safeLinkify
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncLoading
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||
@@ -176,7 +182,7 @@ fun MediaViewerView(
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) {
|
||||
) { padding ->
|
||||
val pagerState = rememberPagerState(state.currentIndex, 0f) {
|
||||
state.listData.size
|
||||
}
|
||||
@@ -211,17 +217,33 @@ fun MediaViewerView(
|
||||
)
|
||||
}
|
||||
is MediaViewerPageData.MediaViewerData -> {
|
||||
val eventId = dataForPage.eventId
|
||||
|
||||
val isMediaValid = dataForPage.validationState.getCurrentMediaState(dataForPage.mediaSource.safeUrl).isValid()
|
||||
|
||||
// Check if the media to display is valid or dangerous
|
||||
LaunchedEffect(eventId) {
|
||||
if (eventId != null) {
|
||||
state.eventSink(MediaViewerEvent.ValidateMedia(eventId, dataForPage.mediaSource))
|
||||
}
|
||||
}
|
||||
|
||||
var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.onVisibilityChanged(minDurationMs = 200L) { isVisible ->
|
||||
val loadMediaOnVisibilityChangedModifier = remember(isMediaValid) {
|
||||
if (isMediaValid == true) {
|
||||
Modifier.onVisibilityChanged(minDurationMs = 200L) { isVisible ->
|
||||
if (isVisible) {
|
||||
state.eventSink(MediaViewerEvent.LoadMedia(dataForPage))
|
||||
} else {
|
||||
state.eventSink(MediaViewerEvent.CancelLoadingMedia(dataForPage))
|
||||
}
|
||||
}
|
||||
.fillMaxSize()
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = loadMediaOnVisibilityChangedModifier.fillMaxSize()
|
||||
) {
|
||||
val isDisplayed = remember(pagerState.settledPage) {
|
||||
// This 'item provider' lambda will be called when the data source changes with an outdated `settlePage` value
|
||||
@@ -232,6 +254,7 @@ fun MediaViewerView(
|
||||
MediaViewerPage(
|
||||
isDisplayed = isDisplayed,
|
||||
showOverlay = showOverlay,
|
||||
containerPadding = padding,
|
||||
bottomPaddingInPixels = (bottomPaddingInPixels - navigationBarPadding).coerceAtLeast(0),
|
||||
data = dataForPage,
|
||||
textFileViewer = textFileViewer,
|
||||
@@ -333,6 +356,7 @@ private fun MediaViewerPage(
|
||||
isDisplayed: Boolean,
|
||||
showOverlay: Boolean,
|
||||
bottomPaddingInPixels: Int,
|
||||
containerPadding: PaddingValues,
|
||||
data: MediaViewerPageData.MediaViewerData,
|
||||
textFileViewer: TextFileViewer,
|
||||
isUserSelected: Boolean,
|
||||
@@ -365,6 +389,25 @@ private fun MediaViewerPage(
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = data.validationState.getCurrentMediaState(data.mediaSource.safeUrl).isLoading(),
|
||||
modifier = Modifier.padding(top = containerPadding.calculateTopPadding()).align(Alignment.TopCenter),
|
||||
enter = fadeIn(spring(stiffness = 500F)),
|
||||
exit = fadeOut(spring(stiffness = 500F)),
|
||||
) {
|
||||
AsyncIndicator.Loading(
|
||||
text = stringResource(CommonStrings.content_scanner_scanning),
|
||||
)
|
||||
}
|
||||
|
||||
if (showProgress) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val zoomableState = rememberZoomableState(
|
||||
zoomSpec = ZoomSpec(maxZoomFactor = 4f, overzoomEffect = OverzoomEffect.NoLimits)
|
||||
@@ -380,29 +423,44 @@ private fun MediaViewerPage(
|
||||
}
|
||||
}
|
||||
|
||||
LocalMediaView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
isDisplayed = isDisplayed,
|
||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||
localMediaViewState = localMediaViewState,
|
||||
localMedia = downloadedMedia.dataOrNull(),
|
||||
mediaInfo = data.mediaInfo,
|
||||
textFileViewer = textFileViewer,
|
||||
onClick = {
|
||||
if (playableState is PlayableState.NotPlayable) {
|
||||
currentOnShowOverlayChange(!currentShowOverlay)
|
||||
}
|
||||
},
|
||||
onOpenWith = onOpenWith,
|
||||
isUserSelected = isUserSelected,
|
||||
audioFocus = audioFocus,
|
||||
forPreview = false,
|
||||
)
|
||||
if (showThumbnail) {
|
||||
ThumbnailView(
|
||||
val isMediaValid = data.validationState.getCurrentMediaState(data.mediaSource.safeUrl).isValid()
|
||||
|
||||
if (isMediaValid == false) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
IconTitleSubtitleMolecule(
|
||||
iconStyle = BigIcon.Style.AlertSolid,
|
||||
title = "The file is not safe",
|
||||
subTitle = "Preview and download have been disabled.",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LocalMediaView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
isDisplayed = isDisplayed,
|
||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||
localMediaViewState = localMediaViewState,
|
||||
localMedia = downloadedMedia.dataOrNull(),
|
||||
mediaInfo = data.mediaInfo,
|
||||
thumbnailSource = data.thumbnailSource,
|
||||
textFileViewer = textFileViewer,
|
||||
onClick = {
|
||||
if (playableState is PlayableState.NotPlayable) {
|
||||
currentOnShowOverlayChange(!currentShowOverlay)
|
||||
}
|
||||
},
|
||||
onOpenWith = onOpenWith,
|
||||
isUserSelected = isUserSelected,
|
||||
audioFocus = audioFocus,
|
||||
forPreview = false,
|
||||
)
|
||||
if (showThumbnail) {
|
||||
ThumbnailView(
|
||||
mediaInfo = data.mediaInfo,
|
||||
thumbnailSource = data.thumbnailSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showError) {
|
||||
ErrorView(
|
||||
@@ -412,13 +470,6 @@ private fun MediaViewerPage(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showProgress) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-5
@@ -16,6 +16,9 @@ import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
|
||||
@@ -27,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
class SingleMediaGalleryDataSource(
|
||||
private val data: GroupedMediaItems,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
) : MediaGalleryDataSource {
|
||||
override fun start(coroutineScope: CoroutineScope) = Unit
|
||||
override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data))
|
||||
@@ -38,7 +42,10 @@ class SingleMediaGalleryDataSource(
|
||||
override suspend fun deleteItem(eventId: EventId) = Unit
|
||||
|
||||
companion object {
|
||||
fun createFrom(params: MediaViewerEntryPoint.Params.Avatar) = SingleMediaGalleryDataSource(
|
||||
fun createFrom(
|
||||
params: MediaViewerEntryPoint.Params.Avatar,
|
||||
contentValidationCache: EventContentValidationCache,
|
||||
) = SingleMediaGalleryDataSource(
|
||||
data = GroupedMediaItems(
|
||||
// Always use imageAndVideoItems, in Single mode, this is the data that will be used
|
||||
imageAndVideoItems = persistentListOf(
|
||||
@@ -64,15 +71,19 @@ class SingleMediaGalleryDataSource(
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
blurHash = params.blurHash,
|
||||
validationState = DefaultContentValidationState(),
|
||||
)
|
||||
),
|
||||
fileItems = persistentListOf(),
|
||||
)
|
||||
),
|
||||
contentValidationCache = contentValidationCache,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem(
|
||||
contentValidationState: ContentValidationState,
|
||||
) = when {
|
||||
mediaInfo.mimeType.isMimeTypeImage() -> {
|
||||
MediaItem.Image(
|
||||
id = UniqueId("dummy"),
|
||||
@@ -80,7 +91,8 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
blurHash = blurHash,
|
||||
blurHash = null,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
mediaInfo.mimeType.isMimeTypeVideo() -> {
|
||||
@@ -90,7 +102,8 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
blurHash = blurHash,
|
||||
blurHash = null,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
mediaInfo.mimeType.isMimeTypeAudio() -> {
|
||||
@@ -100,6 +113,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
eventId = eventId,
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
} else {
|
||||
MediaItem.Voice(
|
||||
@@ -107,6 +121,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
eventId = eventId,
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -116,6 +131,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
eventId = eventId,
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -12,11 +12,13 @@ import android.net.Uri
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaplayer.test.FakeAudioFocus
|
||||
import io.element.android.libraries.mediaviewer.api.AvatarInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
@@ -71,6 +73,8 @@ class DefaultMediaViewerEntryPointTest {
|
||||
audioFocus = FakeAudioFocus(),
|
||||
sessionId = A_SESSION_ID,
|
||||
enterpriseService = FakeEnterpriseService(),
|
||||
contentValidationCache = NoopEventContentValidationCache(),
|
||||
contentScannerService = ContentScannerService { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
val callback = object : MediaViewerEntryPoint.Callback {
|
||||
@@ -118,6 +122,8 @@ class DefaultMediaViewerEntryPointTest {
|
||||
audioFocus = FakeAudioFocus(),
|
||||
sessionId = A_SESSION_ID,
|
||||
enterpriseService = FakeEnterpriseService(),
|
||||
contentValidationCache = NoopEventContentValidationCache(),
|
||||
contentScannerService = ContentScannerService { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
val callback = object : MediaViewerEntryPoint.Callback {
|
||||
|
||||
+14
@@ -52,6 +52,8 @@ import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageCo
|
||||
import io.element.android.libraries.matrix.test.timeline.aStickerContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
||||
@@ -174,6 +176,7 @@ class EventItemFactoryTest {
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -227,6 +230,7 @@ class EventItemFactoryTest {
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -274,6 +278,7 @@ class EventItemFactoryTest {
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -328,6 +333,7 @@ class EventItemFactoryTest {
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -379,6 +385,7 @@ class EventItemFactoryTest {
|
||||
duration = "7:36",
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -440,6 +447,7 @@ class EventItemFactoryTest {
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -502,6 +510,7 @@ class EventItemFactoryTest {
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -557,6 +566,7 @@ class EventItemFactoryTest {
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource("audio_url"),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -613,6 +623,7 @@ class EventItemFactoryTest {
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource("file_url"),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -749,13 +760,16 @@ class EventItemFactoryTest {
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val noopValidationState = NoopContentValidationState()
|
||||
private fun createEventItemFactory() = EventItemFactory(
|
||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
contentValidationCache = NoopEventContentValidationCache(noopValidationState),
|
||||
)
|
||||
|
||||
+7
-1
@@ -34,6 +34,8 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
@@ -250,6 +252,7 @@ class TimelineMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("url"),
|
||||
thumbnailSource = MediaSource("url_thumbnail"),
|
||||
blurHash = A_BLUR_HASH,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
),
|
||||
fileItems = persistentListOf()
|
||||
@@ -260,6 +263,8 @@ class TimelineMediaGalleryDataSourceTest {
|
||||
}
|
||||
}
|
||||
|
||||
private val noopValidationState = NoopContentValidationState()
|
||||
|
||||
internal fun TestScope.createTimelineMediaGalleryDataSource(
|
||||
room: JoinedRoom = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline(),
|
||||
@@ -282,5 +287,6 @@ fun TestScope.createTimelineMediaItemsFactory() = TimelineMediaItemsFactory(
|
||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
),
|
||||
contentValidationCache = NoopEventContentValidationCache(noopValidationState),
|
||||
)
|
||||
)
|
||||
|
||||
+1
@@ -534,6 +534,7 @@ class MediaGalleryPresenterTest {
|
||||
mediaLoader = matrixMediaLoader,
|
||||
localMediaActions = localMediaActions,
|
||||
snackbarDispatcher = snackbarDispatcher,
|
||||
contentScannerService = { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryInfo
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryItemData
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
@@ -95,6 +96,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
@@ -106,6 +108,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -125,6 +128,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
@@ -136,6 +140,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -155,6 +160,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
@@ -164,6 +170,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = expectedMediaInfo("audio.mp3", MimeTypes.Mp3),
|
||||
mediaSource = MediaSource("audio_url"),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -183,6 +190,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
@@ -192,6 +200,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = expectedMediaInfo("document.pdf", MimeTypes.Pdf),
|
||||
mediaSource = MediaSource("file_url"),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -211,6 +220,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
val item = data.imageAndVideoItems.single() as MediaItem.Image
|
||||
@@ -249,6 +259,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
),
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
@@ -258,6 +269,8 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
assertThat((data.imageAndVideoItems[2] as MediaItem.Video).id).isEqualTo(UniqueId("${AN_EVENT_ID.value}_2"))
|
||||
}
|
||||
|
||||
private val noopValidationState = NoopContentValidationState()
|
||||
|
||||
private fun aGalleryInfo() = GalleryInfo(
|
||||
caption = "A caption",
|
||||
formattedCaption = null,
|
||||
|
||||
+3
@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint.MediaViewerMode
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||
import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource
|
||||
@@ -290,5 +291,7 @@ class MediaViewerDataSourceTest {
|
||||
localMediaFactory = localMediaFactory,
|
||||
systemClock = FakeSystemClock(),
|
||||
pagerKeysHandler = PagerKeysHandler(),
|
||||
contentScannerService = { _, _, _ -> },
|
||||
contentValidationCache = NoopEventContentValidationCache(),
|
||||
)
|
||||
}
|
||||
|
||||
+3
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.AvatarInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
|
||||
@@ -969,6 +970,8 @@ internal fun TestScope.createMediaViewerPresenter(
|
||||
localMediaFactory = localMediaFactory,
|
||||
systemClock = FakeSystemClock(),
|
||||
pagerKeysHandler = PagerKeysHandler(),
|
||||
contentScannerService = { _, _, _ -> },
|
||||
contentValidationCache = NoopEventContentValidationCache(),
|
||||
),
|
||||
room = room,
|
||||
localMediaActions = localMediaActions,
|
||||
|
||||
+14
-6
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.media.aMediaSource
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
||||
@@ -36,22 +37,24 @@ class SingleMediaGalleryDataSourceTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private val contentValidationCache = NoopEventContentValidationCache()
|
||||
|
||||
@Test
|
||||
fun `function start is no op`() = runTest {
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems(), contentValidationCache)
|
||||
sut.start(backgroundScope)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `function loadMore is no op`() = runTest {
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems(), contentValidationCache)
|
||||
sut.loadMore(Timeline.PaginationDirection.BACKWARDS)
|
||||
sut.loadMore(Timeline.PaginationDirection.FORWARDS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `function deleteItem is no op`() = runTest {
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems(), contentValidationCache)
|
||||
sut.deleteItem(AN_EVENT_ID)
|
||||
}
|
||||
|
||||
@@ -61,7 +64,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
imageAndVideoItems = listOf(aMediaItemImage()),
|
||||
fileItems = listOf(aMediaItemFile()),
|
||||
)
|
||||
val sut = SingleMediaGalleryDataSource(data)
|
||||
val sut = SingleMediaGalleryDataSource(data, contentValidationCache)
|
||||
assertThat(sut.getLastData()).isEqualTo(AsyncData.Success(data))
|
||||
}
|
||||
|
||||
@@ -71,7 +74,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
imageAndVideoItems = listOf(aMediaItemImage()),
|
||||
fileItems = listOf(aMediaItemFile()),
|
||||
)
|
||||
val sut = SingleMediaGalleryDataSource(data)
|
||||
val sut = SingleMediaGalleryDataSource(data, contentValidationCache)
|
||||
sut.groupedMediaItemsFlow().test {
|
||||
assertThat(awaitItem()).isEqualTo(AsyncData.Success(data))
|
||||
awaitComplete()
|
||||
@@ -90,6 +93,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
blurHash = null,
|
||||
validationState = contentValidationCache[AN_EVENT_ID],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -107,6 +111,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
blurHash = null,
|
||||
validationState = contentValidationCache[AN_EVENT_ID],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -122,6 +127,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
eventId = params.eventId,
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
validationState = contentValidationCache[AN_EVENT_ID],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -140,6 +146,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
eventId = params.eventId,
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
validationState = contentValidationCache[AN_EVENT_ID],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -155,6 +162,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
eventId = params.eventId,
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
validationState = contentValidationCache[AN_EVENT_ID],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -165,7 +173,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
expectedResult: (MediaViewerEntryPoint.Params.RoomMedia) -> MediaItem,
|
||||
) {
|
||||
val params = aMediaViewerEntryPointParams(mediaInfo)
|
||||
val result = params.toMediaItem()
|
||||
val result = params.toMediaItem(contentValidationCache[AN_EVENT_ID],)
|
||||
assertThat(result).isEqualTo(expectedResult(params))
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -23,14 +23,17 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun FormattingOption(
|
||||
@@ -52,6 +55,8 @@ internal fun FormattingOption(
|
||||
FormattingOptionState.Default -> ElementTheme.colors.iconSecondary
|
||||
FormattingOptionState.Disabled -> ElementTheme.colors.iconDisabled
|
||||
}
|
||||
val activeStateDescription = stringResource(CommonStrings.a11y_active)
|
||||
val inactiveStateDescription = stringResource(CommonStrings.a11y_inactive)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clickable(
|
||||
@@ -77,6 +82,13 @@ internal fun FormattingOption(
|
||||
)
|
||||
.clearAndSetSemantics {
|
||||
this.contentDescription = contentDescription
|
||||
if (toggleable) {
|
||||
this.stateDescription = if (state == FormattingOptionState.Selected) {
|
||||
activeStateDescription
|
||||
} else {
|
||||
inactiveStateDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
|
||||
@@ -10,4 +10,6 @@
|
||||
<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_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>
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user