Add media file limit size warning and media quality selection (#5131)

* Add `VideoCompressorPreset` enum

This represents the different compression presets used for processing videos before uploading them

* Add `VideoCompressorHelper` util class to calculate the scaled output size of the video given an input size and its optimal bitrate

Also add `MediaOptimizationConfig` which will be used to decide how to apply compression in `MediaPreProcessor`

* Add `RustMatrixClient.getMaxFileUploadSize()` function and `MaxUploadSizeProvider` so we can import only this functionality into other components

* Try preloading the max file upload size the first time we get network connectivity - it's a best effort

This should help ensure we'll have this value available later, even if we still need to load it asynchronously.

* Split the `compressMedia` preference into `compressImages` and `compressMediaPreset`

* Modify the media processing parts to use the new classes and utils

* Add `MediaOptimizationSelectorPresenter`, which will retrieve the compression values and the max file upload size, also estimating the compressed video file sizes if needed.

* Add a feature flag to allow selecting the media upload quality per upload

* Integrate the previous changes with the attachments preview screen

Add strings from localazy too.

* Adapt the rest of the app calls to upload media to using the media optimization configs

* Allow modifying the default compression values in advanced settings, based on the feature flag value

* Pass the `fileSize` in `MediaUploadInfo` too, to be able to check it against the `maxUploadSize`

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2025-08-11 17:22:46 +02:00
committed by GitHub
parent ffe183c952
commit a170d80cb3
174 changed files with 2152 additions and 340 deletions

View File

@@ -52,6 +52,8 @@ import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.logout.api.LogoutEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
@@ -125,6 +127,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val logoutEntryPoint: LogoutEntryPoint,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
private val networkMonitor: NetworkMonitor,
snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
@@ -192,6 +195,12 @@ class LoggedInFlowNode @AssistedInject constructor(
matrixClient.sessionVerificationService().setListener(verificationListener)
mediaPreviewConfigMigration()
sessionCoroutineScope.launch {
// Wait for the network to be connected before pre-fetching the max file upload size
networkMonitor.connectivity.first { networkStatus -> networkStatus == NetworkStatus.Connected }
matrixClient.getMaxFileUploadSize()
}
ftueService.state
.onEach { ftueState ->
when (ftueState) {

View File

@@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEffect
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
@@ -57,6 +58,7 @@ class ConfigureRoomPresenter @Inject constructor(
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val featureFlagService: FeatureFlagService,
private val roomAliasHelper: RoomAliasHelper,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) : Presenter<ConfigureRoomState> {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false
@@ -201,7 +203,7 @@ class ConfigureRoomPresenter @Inject constructor(
uri = avatarUri,
mimeType = MimeTypes.Jpeg,
deleteOriginal = false,
compressIfPossible = false,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
).getOrThrow()
val byteArray = preprocessed.file.readBytes()
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow()

View File

@@ -35,6 +35,7 @@ import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
@@ -411,6 +412,7 @@ class ConfigureBaseRoomPresenterTest {
analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
isKnockFeatureEnabled: Boolean = true,
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
) = ConfigureRoomPresenter(
dataStore = createRoomDataStore,
matrixClient = matrixClient,
@@ -421,6 +423,7 @@ class ConfigureBaseRoomPresenterTest {
roomAliasHelper = roomAliasHelper,
featureFlagService = FakeFeatureFlagService(
mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled)
)
),
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}

View File

@@ -17,5 +17,7 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.mediaviewer.api)
implementation(projects.libraries.preferences.api)
api(projects.libraries.textcomposer.impl)
}

View File

@@ -462,6 +462,7 @@ class MessagesFlowNode @AssistedInject constructor(
eventId = event.eventId,
mediaInfo = MediaInfo(
filename = content.filename,
fileSize = content.fileSize,
caption = content.caption,
mimeType = content.mimeType,
formattedFileSize = content.formattedFileSize,

View File

@@ -22,21 +22,26 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.firstInstanceOf
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.allFiles
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import kotlinx.coroutines.CancellationException
@@ -54,6 +59,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter,
private val featureFlagService: FeatureFlagService,
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
) : Presenter<AttachmentsPreviewState> {
@@ -89,21 +95,80 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
var useSendQueue by remember { mutableStateOf(false) }
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
LaunchedEffect(Unit) {
useSendQueue = featureFlagService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
preprocessMediaJob = preProcessAttachment(
attachment,
sendActionState
)
val mediaAttachment = attachment as Attachment.Media
val mediaOptimizationSelectorPresenter = remember {
mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia)
}
val mediaOptimizationSelectorState = mediaOptimizationSelectorPresenter.present()
val observableSendState = snapshotFlow { sendActionState.value }
var displayFileTooLargeError by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
useSendQueue = featureFlagService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
}
LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews) {
// If the media optimization selector is not displayed, we can pre-process the media
// to prepare it for sending. This is done to avoid blocking the UI thread when the
// user clicks on the send button.
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false) {
val mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true,
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
)
preprocessMediaJob = preProcessAttachment(
attachment = attachment,
mediaOptimizationConfig = mediaOptimizationConfig,
displayProgress = false,
sendActionState = sendActionState,
)
}
}
val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull()
LaunchedEffect(maxUploadSize) {
// Check file upload size if the media won't be processed for upload
val isImageFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeImage()
val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo()
if (maxUploadSize != null && !(isImageFile || isVideoFile)) {
// If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed.
val fileSize = mediaAttachment.localMedia.info.fileSize ?: 0L
if (maxUploadSize < fileSize) {
displayFileTooLargeError = true
}
}
}
val videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull()
LaunchedEffect(videoSizeEstimations) {
if (videoSizeEstimations != null) {
// Check if the video size estimations are too large for the max upload size
displayFileTooLargeError = videoSizeEstimations.none { it.canUpload }
}
}
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (attachmentsPreviewEvents) {
is AttachmentsPreviewEvents.SendAttachment -> {
ongoingSendAttachmentJob.value = coroutineScope.launch {
// If the media optimization selector is displayed, we need to wait for the user to select the options
// before we can pre-process the media.
if (mediaOptimizationSelectorState.displayMediaSelectorViews == true) {
val config = MediaOptimizationConfig(
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true,
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
)
preprocessMediaJob = preProcessAttachment(
attachment = attachment,
mediaOptimizationConfig = config,
displayProgress = true,
sendActionState = sendActionState,
)
}
// If the processing was hidden before, make it visible now
if (sendActionState.value is SendActionState.Sending.Processing) {
sendActionState.value = SendActionState.Sending.Processing(displayProgress = true)
@@ -138,6 +203,8 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
}
}
AttachmentsPreviewEvents.CancelAndDismiss -> {
displayFileTooLargeError = false
// Cancel media preprocessing and sending
preprocessMediaJob?.cancel()
// If we couldn't send the pre-processed media, remove it
@@ -173,18 +240,24 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
textEditorState = textEditorState,
allowCaption = allowCaption,
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
displayFileTooLargeError = displayFileTooLargeError,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.preProcessAttachment(
attachment: Attachment,
mediaOptimizationConfig: MediaOptimizationConfig,
displayProgress: Boolean,
sendActionState: MutableState<SendActionState>,
) = launch(dispatchers.io) {
when (attachment) {
is Attachment.Media -> {
preProcessMedia(
mediaAttachment = attachment,
mediaOptimizationConfig = mediaOptimizationConfig,
displayProgress = displayProgress,
sendActionState = sendActionState,
)
}
@@ -193,12 +266,15 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
private suspend fun preProcessMedia(
mediaAttachment: Attachment.Media,
mediaOptimizationConfig: MediaOptimizationConfig,
displayProgress: Boolean,
sendActionState: MutableState<SendActionState>,
) {
sendActionState.value = SendActionState.Sending.Processing(displayProgress = false)
sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress)
mediaSender.preProcessMedia(
uri = mediaAttachment.localMedia.uri,
mimeType = mediaAttachment.localMedia.info.mimeType,
mediaOptimizationConfig = mediaOptimizationConfig,
).fold(
onSuccess = { mediaUploadInfo ->
sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfo)

View File

@@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.textcomposer.model.TextEditorState
@@ -18,6 +19,8 @@ data class AttachmentsPreviewState(
val textEditorState: TextEditorState,
val allowCaption: Boolean,
val showCaptionCompatibilityWarning: Boolean,
val mediaOptimizationSelectorState: MediaOptimizationSelectorState,
val displayFileTooLargeError: Boolean,
val eventSink: (AttachmentsPreviewEvents) -> Unit
)

View File

@@ -10,14 +10,21 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.io.File
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
@@ -36,6 +43,21 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())),
anAttachmentsPreviewState(allowCaption = false),
anAttachmentsPreviewState(showCaptionCompatibilityWarning = true),
anAttachmentsPreviewState(displayFileTooLargeError = true),
anAttachmentsPreviewState(
mediaInfo = aVideoMediaInfo(),
mediaOptimizationSelectorState = aMediaOptimisationSelectorState(
selectedVideoPreset = VideoCompressionPreset.STANDARD,
videoSizeEstimations = aVideoSizeEstimationList(),
)
),
anAttachmentsPreviewState(
mediaInfo = aVideoMediaInfo(),
mediaOptimizationSelectorState = aMediaOptimisationSelectorState(
videoSizeEstimations = aVideoSizeEstimationList(),
displayVideoPresetSelectorDialog = true,
)
),
)
}
@@ -45,6 +67,8 @@ fun anAttachmentsPreviewState(
sendActionState: SendActionState = SendActionState.Idle,
allowCaption: Boolean = true,
showCaptionCompatibilityWarning: Boolean = true,
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
displayFileTooLargeError: Boolean = false,
) = AttachmentsPreviewState(
attachment = Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
@@ -53,6 +77,8 @@ fun anAttachmentsPreviewState(
textEditorState = textEditorState,
allowCaption = allowCaption,
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
displayFileTooLargeError = displayFileTooLargeError,
eventSink = {}
)
@@ -72,3 +98,35 @@ fun aMediaUploadInfo(
),
thumbnailFile = thumbnailFilePath?.let { File(it) },
)
fun aMediaOptimisationSelectorState(
maxUploadSize: Long = 100,
videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>> = AsyncData.Success(persistentListOf()),
isImageOptimizationEnabled: Boolean = true,
selectedVideoPreset: VideoCompressionPreset = VideoCompressionPreset.STANDARD,
displayMediaSelectorViews: Boolean = true,
displayVideoPresetSelectorDialog: Boolean = false,
) = MediaOptimizationSelectorState(
maxUploadSize = AsyncData.Success(maxUploadSize),
videoSizeEstimations = videoSizeEstimations,
isImageOptimizationEnabled = isImageOptimizationEnabled,
selectedVideoPreset = selectedVideoPreset,
displayMediaSelectorViews = displayMediaSelectorViews,
displayVideoPresetSelectorDialog = displayVideoPresetSelectorDialog,
eventSink = {},
)
internal fun aVideoSizeEstimationList(): AsyncData<ImmutableList<VideoUploadEstimation>> = AsyncData.Success(
persistentListOf(
VideoUploadEstimation(
preset = VideoCompressionPreset.HIGH,
sizeInBytes = 8_200_000L,
canUpload = false,
),
VideoUploadEstimation(
preset = VideoCompressionPreset.STANDARD,
sizeInBytes = 4_200_000L,
canUpload = true,
),
)
)

View File

@@ -13,6 +13,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -21,31 +22,56 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
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.features.messages.impl.R
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorEvent
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.ProgressDialogType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Switch
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.textcomposer.TextComposer
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.formatter.rememberFileSizeFormatter
import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -109,7 +135,7 @@ private fun AttachmentSendStateView(
if (sendActionState.displayProgress) {
ProgressDialog(
type = ProgressDialogType.Indeterminate,
text = stringResource(id = CommonStrings.common_sending),
text = stringResource(CommonStrings.common_preparing),
showCancelButton = true,
onDismissRequest = onDismissClick,
)
@@ -157,6 +183,26 @@ private fun AttachmentPreviewContent(
}
}
}
val mimeType = (state.attachment as? Attachment.Media)?.localMedia?.info?.mimeType
if (mimeType?.isMimeTypeImage() == true) {
ImageOptimizationSelector(state.mediaOptimizationSelectorState)
} else if (mimeType?.isMimeTypeVideo() == true) {
VideoPresetSelector(state = state.mediaOptimizationSelectorState)
}
val sizeFormatter = rememberFileSizeFormatter()
if (state.displayFileTooLargeError) {
val maxFileUploadSize = state.mediaOptimizationSelectorState.maxUploadSize.dataOrNull()
if (maxFileUploadSize != null) {
val content = stringResource(CommonStrings.dialog_file_too_large_to_upload_subtitle, sizeFormatter.format(maxFileUploadSize, true))
AlertDialog(
title = stringResource(CommonStrings.dialog_file_too_large_to_upload_title),
content = content,
onDismiss = { state.eventSink(AttachmentsPreviewEvents.CancelAndDismiss) },
)
}
}
AttachmentsPreviewBottomActions(
state = state,
onSendClick = onSendClick,
@@ -169,6 +215,144 @@ private fun AttachmentPreviewContent(
}
}
@Composable
private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
if (state.displayMediaSelectorViews == true) {
Row(
modifier = Modifier.fillMaxWidth()
.niceClickable {
state.isImageOptimizationEnabled?.let { value ->
state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(!value))
}
}
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
Text(
modifier = Modifier.weight(1f).align(Alignment.CenterVertically),
text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title),
style = ElementTheme.materialTypography.bodyLarge,
)
Switch(
modifier = Modifier.height(32.dp),
checked = state.isImageOptimizationEnabled.orFalse(),
onCheckedChange = { value -> state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(value)) },
)
}
}
}
@Composable
private fun VideoPresetSelector(
state: MediaOptimizationSelectorState,
) {
val videoPresets = state.videoSizeEstimations.dataOrNull()
var selectedPreset by remember(state.selectedVideoPreset) { mutableStateOf(state.selectedVideoPreset) }
val displayDialog = state.displayVideoPresetSelectorDialog
val sizeFormatter = rememberFileSizeFormatter()
if (state.displayMediaSelectorViews == true && videoPresets != null && state.selectedVideoPreset != null) {
Column(
modifier = Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
.niceClickable { state.eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) }
) {
val estimation = videoPresets.find { it.preset == selectedPreset }
val estimationMb = estimation?.sizeInBytes?.let { sizeFormatter.format(it, true) }
val title = buildString {
append(state.selectedVideoPreset.title())
if (estimationMb != null) {
append(" ($estimationMb)")
}
}
Text(text = title, style = ElementTheme.typography.fontBodyLgMedium)
Text(
text = stringResource(R.string.screen_media_upload_preview_change_video_quality_prompt),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textSecondary,
)
}
}
if (displayDialog) {
VideoQualitySelectorDialog(
selectedPreset = selectedPreset ?: VideoCompressionPreset.STANDARD,
videoSizeEstimations = videoPresets ?: persistentListOf(),
maxFileUploadSize = state.maxUploadSize.dataOrNull(),
onSubmit = { preset ->
selectedPreset = preset
state.eventSink(MediaOptimizationSelectorEvent.SelectVideoPreset(preset))
},
onDismiss = { state.eventSink(MediaOptimizationSelectorEvent.DismissVideoPresetSelectorDialog) }
)
}
}
@Composable
private fun VideoQualitySelectorDialog(
selectedPreset: VideoCompressionPreset,
videoSizeEstimations: ImmutableList<VideoUploadEstimation>,
maxFileUploadSize: Long?,
onSubmit: (VideoCompressionPreset) -> Unit,
onDismiss: () -> Unit,
) {
val sizeFormatter = rememberFileSizeFormatter()
var localSelectedPreset by remember(selectedPreset) { mutableStateOf(selectedPreset) }
val subtitlePartNoFileSize = stringResource(CommonStrings.dialog_video_quality_selector_subtitle_no_file_size)
val subtitlePartWithFileSize = stringResource(CommonStrings.dialog_video_quality_selector_subtitle_file_size)
val subtitle = remember(maxFileUploadSize) {
buildString {
append(subtitlePartNoFileSize)
if (maxFileUploadSize != null) {
append(String.format(subtitlePartWithFileSize, sizeFormatter.format(maxFileUploadSize, true)))
}
}
}
ListDialog(
title = stringResource(CommonStrings.dialog_video_quality_selector_title),
subtitle = subtitle,
onSubmit = { onSubmit(localSelectedPreset) },
onDismissRequest = onDismiss,
applyPaddingToContents = false,
) {
for (videoEstimation in videoSizeEstimations) {
val preset = videoEstimation.preset
val isSelected = preset == localSelectedPreset
item(
key = preset,
contentType = preset,
) {
val estimationMb = sizeFormatter.format(videoEstimation.sizeInBytes, true)
val title = "${preset.title()} ($estimationMb)"
ListItem(
headlineContent = {
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
)
},
supportingContent = {
Text(
text = preset.subtitle(),
style = ElementTheme.materialTypography.bodyMedium,
color = ElementTheme.colors.textSecondary,
)
},
leadingContent = ListItemContent.RadioButton(
selected = isSelected,
),
onClick = {
localSelectedPreset = preset
},
enabled = videoEstimation.canUpload,
)
}
}
}
}
@Composable
private fun AttachmentsPreviewBottomActions(
state: AttachmentsPreviewState,
@@ -221,3 +405,43 @@ internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewS
}
)
}
@PreviewsDayNight
@Composable
internal fun VideoQualitySelectorDialogPreview() {
ElementPreview {
VideoQualitySelectorDialog(
selectedPreset = VideoCompressionPreset.STANDARD,
videoSizeEstimations = persistentListOf(
VideoUploadEstimation(VideoCompressionPreset.HIGH, 2_000_000, canUpload = false),
VideoUploadEstimation(VideoCompressionPreset.STANDARD, 1_000_000, canUpload = true),
VideoUploadEstimation(VideoCompressionPreset.LOW, 500_000, canUpload = true)
),
maxFileUploadSize = 1_500_000,
onSubmit = {},
onDismiss = {},
)
}
}
@Composable
fun VideoCompressionPreset.title(): String {
return stringResource(
when (this) {
VideoCompressionPreset.STANDARD -> CommonStrings.common_video_quality_standard
VideoCompressionPreset.HIGH -> CommonStrings.common_video_quality_high
VideoCompressionPreset.LOW -> CommonStrings.common_video_quality_low
}
)
}
@Composable
fun VideoCompressionPreset.subtitle(): String {
return stringResource(
when (this) {
VideoCompressionPreset.STANDARD -> CommonStrings.common_video_quality_standard_description
VideoCompressionPreset.HIGH -> CommonStrings.common_video_quality_high_description
VideoCompressionPreset.LOW -> CommonStrings.common_video_quality_low_description
}
)
}

View File

@@ -7,15 +7,15 @@
package io.element.android.features.messages.impl.attachments.preview.error
import io.element.android.features.messages.impl.R
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.ui.strings.CommonStrings
fun sendAttachmentError(
throwable: Throwable
): Int {
return if (throwable is MediaPreProcessor.Failure) {
CommonStrings.screen_media_upload_preview_error_failed_processing
R.string.screen_media_upload_preview_error_failed_processing
} else {
CommonStrings.screen_media_upload_preview_error_failed_sending
R.string.screen_media_upload_preview_error_failed_sending
}
}

View File

@@ -0,0 +1,192 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.video
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider
import io.element.android.libraries.mediaupload.api.compressorHelper
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.first
import timber.log.Timber
import kotlin.math.roundToLong
class DefaultMediaOptimizationSelectorPresenter @AssistedInject constructor(
@Assisted private val localMedia: LocalMedia,
private val maxUploadSizeProvider: MaxUploadSizeProvider,
private val sessionPreferencesStore: SessionPreferencesStore,
private val featureFlagService: FeatureFlagService,
mediaExtractorFactory: VideoMetadataExtractor.Factory,
) : MediaOptimizationSelectorPresenter {
@ContributesBinding(SessionScope::class)
@AssistedFactory
interface Factory : MediaOptimizationSelectorPresenter.Factory {
override fun create(
localMedia: LocalMedia,
): DefaultMediaOptimizationSelectorPresenter
}
private val mediaExtractor = mediaExtractorFactory.create(localMedia.uri)
@Composable
override fun present(): MediaOptimizationSelectorState {
val displayMediaSelectorViews by produceState<Boolean?>(null) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality)
}
var displayVideoPresetSelectorDialog by remember { mutableStateOf(false) }
val maxUploadSize by produceState(AsyncData.Loading()) {
maxUploadSizeProvider.getMaxUploadSize().fold(
onSuccess = { value = AsyncData.Success(it) },
onFailure = {
Timber.e(it, "Failed to retrieve max upload size for video optimization selector")
value = AsyncData.Success((100 * 1024 * 1024).toLong()) // Default to 100 MB if we can't retrieve the max upload size
}
)
}
val mediaMimeType = localMedia.info.mimeType
val videoSizeEstimations by produceState<AsyncData<ImmutableList<VideoUploadEstimation>>>(
initialValue = AsyncData.Loading(),
key1 = maxUploadSize,
) {
if (maxUploadSize !is AsyncData.Success) {
return@produceState
}
if (!mediaMimeType.isMimeTypeVideo()) {
value = AsyncData.Uninitialized
return@produceState
}
val (videoDimensions, duration) = mediaExtractor.use {
val size = it.getSize()
.getOrElse { exception ->
value = AsyncData.Failure(exception)
return@produceState
}
val duration = it.getDuration()
.getOrElse { exception ->
value = AsyncData.Failure(exception)
return@produceState
}
size to duration
}
val sizeEstimations = VideoCompressionPreset.entries
.map { preset ->
val bitRate = preset.compressorHelper().calculateOptimalBitrate(videoDimensions, 30)
val calculatedSize = (bitRate * duration / 8 * 1.1).roundToLong() // Adding 10% overhead for safety
VideoUploadEstimation(
preset = preset,
sizeInBytes = calculatedSize,
canUpload = calculatedSize <= (maxUploadSize as AsyncData.Success).data
)
}
.toPersistentList()
.also { sizes ->
Timber.d(sizes.joinToString("\n") { "Calculated size for ${it.preset}: ${it.sizeInBytes} MB. Max upload size: $maxUploadSize" })
}
value = AsyncData.Success(sizeEstimations)
}
var selectedImageOptimization by remember { mutableStateOf<AsyncData<Boolean>>(AsyncData.Loading()) }
var selectedVideoOptimizationPreset by remember { mutableStateOf<AsyncData<VideoCompressionPreset>>(AsyncData.Loading()) }
LaunchedEffect(videoSizeEstimations.dataOrNull()) {
selectedImageOptimization = AsyncData.Success(sessionPreferencesStore.doesOptimizeImages().first())
// Find the best video preset based on the default preset and the video size estimations
// Since the estimation for the current preset may be way too large to upload, we check the ones that provide lower file sizes
selectedVideoOptimizationPreset = findBestVideoPreset(
defaultVideoPreset = sessionPreferencesStore.getVideoCompressionPreset().first(),
videoSizeEstimations = videoSizeEstimations,
)
}
fun handleEvent(event: MediaOptimizationSelectorEvent) {
when (event) {
is MediaOptimizationSelectorEvent.SelectImageOptimization -> {
selectedImageOptimization = AsyncData.Success(event.enabled)
}
is MediaOptimizationSelectorEvent.SelectVideoPreset -> {
val estimations = videoSizeEstimations.dataOrNull()
if (estimations != null) {
val preset = estimations.find { it.preset == event.preset }
if (preset == null) {
Timber.e("Selected video preset ${event.preset} is not available in the estimations")
return
}
if (!preset.canUpload) {
Timber.w("Selected video preset ${event.preset} exceeds max upload size")
return
}
} else {
Timber.e("Video size estimations are not available")
return
}
selectedVideoOptimizationPreset = AsyncData.Success(event.preset)
displayVideoPresetSelectorDialog = false
}
is MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog -> {
displayVideoPresetSelectorDialog = true
}
is MediaOptimizationSelectorEvent.DismissVideoPresetSelectorDialog -> {
displayVideoPresetSelectorDialog = false
}
}
}
return MediaOptimizationSelectorState(
maxUploadSize = maxUploadSize,
videoSizeEstimations = videoSizeEstimations,
isImageOptimizationEnabled = selectedImageOptimization.dataOrNull(),
selectedVideoPreset = selectedVideoOptimizationPreset.dataOrNull(),
displayMediaSelectorViews = displayMediaSelectorViews,
displayVideoPresetSelectorDialog = displayVideoPresetSelectorDialog,
eventSink = { handleEvent(it) },
)
}
private fun findBestVideoPreset(
defaultVideoPreset: VideoCompressionPreset,
videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
): AsyncData<VideoCompressionPreset> {
val estimations = videoSizeEstimations.dataOrNull() ?: return AsyncData.Loading()
// This will find the best video preset that can be used to produce a video that can be uploaded
val bestEstimation = estimations.find { it.preset.ordinal >= defaultVideoPreset.ordinal && it.canUpload }?.preset
return if (bestEstimation != null) {
AsyncData.Success(bestEstimation)
} else {
AsyncData.Failure(
IllegalStateException("No suitable video preset found for default preset: $defaultVideoPreset")
)
}
}
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.video
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
sealed interface MediaOptimizationSelectorEvent {
data class SelectImageOptimization(val enabled: Boolean) : MediaOptimizationSelectorEvent
data class SelectVideoPreset(val preset: VideoCompressionPreset) : MediaOptimizationSelectorEvent
data object OpenVideoPresetSelectorDialog : MediaOptimizationSelectorEvent
data object DismissVideoPresetSelectorDialog : MediaOptimizationSelectorEvent
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.video
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
fun interface MediaOptimizationSelectorPresenter : Presenter<MediaOptimizationSelectorState> {
interface Factory {
fun create(
localMedia: LocalMedia,
): MediaOptimizationSelectorPresenter
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.video
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.ImmutableList
data class MediaOptimizationSelectorState(
val maxUploadSize: AsyncData<Long>,
val videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
val isImageOptimizationEnabled: Boolean?,
val selectedVideoPreset: VideoCompressionPreset?,
val displayMediaSelectorViews: Boolean?,
val displayVideoPresetSelectorDialog: Boolean,
val eventSink: (MediaOptimizationSelectorEvent) -> Unit
)
data class VideoUploadEstimation(
val preset: VideoCompressionPreset,
val sizeInBytes: Long,
val canUpload: Boolean,
)

View File

@@ -0,0 +1,68 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.video
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.util.Size
import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
interface VideoMetadataExtractor : AutoCloseable {
fun getSize(): Result<Size>
fun getDuration(): Result<Long>
interface Factory {
fun create(uri: Uri): VideoMetadataExtractor
}
}
@ContributesBinding(AppScope::class)
class DefaultVideoMetadataExtractor @AssistedInject constructor(
@ApplicationContext context: Context,
@Assisted private val uri: Uri,
) : VideoMetadataExtractor {
@ContributesBinding(AppScope::class)
@AssistedFactory
interface Factory : VideoMetadataExtractor.Factory {
override fun create(uri: Uri): DefaultVideoMetadataExtractor
}
private val mediaMetadataRetriever = MediaMetadataRetriever()
init {
mediaMetadataRetriever.setDataSource(context, uri)
}
override fun getSize(): Result<Size> = runCatchingExceptions {
val width = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt()
val height = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt()
@Suppress("ComplexCondition")
if (width != null && width > 0 && height != null && height > 0) {
Size(width, height)
} else {
error("Could not retrieve video size from metadata for $uri")
}
}
override fun getDuration(): Result<Long> = runCatchingExceptions {
mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong()
?.takeIf { it > 0L }
?: error("Could not retrieve video duration from metadata")
}
override fun close() {
mediaMetadataRetriever.release()
}
}

View File

@@ -60,6 +60,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTran
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsEvents
@@ -120,6 +121,7 @@ class MessageComposerPresenter @AssistedInject constructor(
private val mentionSpanProvider: MentionSpanProvider,
private val pillificationHelper: TextPillificationHelper,
private val suggestionsProcessor: SuggestionsProcessor,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) : Presenter<MessageComposerState> {
@AssistedFactory
interface Factory {
@@ -519,6 +521,7 @@ class MessageComposerPresenter @AssistedInject constructor(
uri = uri,
mimeType = mimeType,
progressCallback = null,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
).getOrThrow()
}
.onFailure { cause ->

View File

@@ -86,6 +86,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemImageContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
@@ -106,6 +107,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemStickerContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
@@ -142,6 +144,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemVideoContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
@@ -162,6 +165,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
is AudioMessageType -> {
TimelineItemAudioContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
@@ -178,6 +182,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
TimelineItemVoiceContent(
eventId = eventId,
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
@@ -192,6 +197,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
false -> {
TimelineItemAudioContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
@@ -208,6 +214,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
val fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
TimelineItemFileContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,

View File

@@ -34,6 +34,7 @@ class TimelineItemContentStickerFactory @Inject constructor(
return TimelineItemStickerContent(
filename = content.filename,
fileSize = content.info.size ?: 0L,
caption = content.body,
formattedCaption = null,
isEdited = false,

View File

@@ -13,6 +13,7 @@ import kotlin.time.Duration
data class TimelineItemAudioContent(
override val filename: String,
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,

View File

@@ -28,6 +28,7 @@ fun aTimelineItemAudioContent(
caption: String? = null,
) = TimelineItemAudioContent(
filename = fileName,
fileSize = 100 * 1024L,
caption = caption,
formattedCaption = null,
isEdited = false,

View File

@@ -26,6 +26,7 @@ sealed interface TimelineItemEventContentWithAttachment :
TimelineItemEventContent,
TimelineItemEventMutableContent {
val filename: String
val fileSize: Long?
val caption: String?
val formattedCaption: CharSequence?
val mediaSource: MediaSource

View File

@@ -12,6 +12,7 @@ import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAn
data class TimelineItemFileContent(
override val filename: String,
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,

View File

@@ -27,6 +27,7 @@ fun aTimelineItemFileContent(
caption: String? = null,
) = TimelineItemFileContent(
filename = fileName,
fileSize = 100 * 1024L,
caption = caption,
formattedCaption = null,
isEdited = false,

View File

@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.ui.media.MediaRequestData
data class TimelineItemImageContent(
override val filename: String,
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,

View File

@@ -29,6 +29,7 @@ fun aTimelineItemImageContent(
caption: String? = null,
) = TimelineItemImageContent(
filename = filename,
fileSize = 4 * 1024 * 1024L,
caption = caption,
formattedCaption = null,
isEdited = false,

View File

@@ -11,6 +11,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
data class TimelineItemStickerContent(
override val filename: String,
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,

View File

@@ -27,6 +27,7 @@ fun aTimelineItemStickerContent(
blurhash: String? = A_BLUR_HASH,
) = TimelineItemStickerContent(
filename = "a sticker.gif",
fileSize = 4 * 1024 * 1024L,
caption = "a body",
formattedCaption = null,
isEdited = false,

View File

@@ -12,6 +12,7 @@ import kotlin.time.Duration
data class TimelineItemVideoContent(
override val filename: String,
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,

View File

@@ -28,6 +28,7 @@ fun aTimelineItemVideoContent(
blurhash: String? = A_BLUR_HASH,
) = TimelineItemVideoContent(
filename = "Video.mp4",
fileSize = 14 * 1024 * 1024L,
caption = null,
formattedCaption = null,
isEdited = false,

View File

@@ -15,6 +15,7 @@ import kotlin.time.Duration
data class TimelineItemVoiceContent(
val eventId: EventId?,
override val filename: String,
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,

View File

@@ -45,6 +45,7 @@ fun aTimelineItemVoiceContent(
waveform: List<Float> = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
) = TimelineItemVoiceContent(
eventId = eventId,
fileSize = 1024 * 1024,
filename = filename,
caption = caption,
formattedCaption = null,

View File

@@ -8,6 +8,8 @@
<string name="emoji_picker_category_people">"Усмешкі &amp; Удзельнікі"</string>
<string name="emoji_picker_category_places">"Падарожжы &amp; Месцы"</string>
<string name="emoji_picker_category_symbols">"Сімвалы"</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз."</string>
<string name="screen_report_content_block_user">"Заблакіраваць карыстальніка"</string>
<string name="screen_report_content_block_user_hint">"Адзначце, ці хочаце вы схаваць усе бягучыя і будучыя паведамленні ад гэтага карыстальніка"</string>
<string name="screen_report_content_explanation">"Гэтае паведамленне будзе перададзена адміністратару вашага хатняга сервера. Яны не змогуць прачытаць зашыфраваныя паведамленні."</string>

View File

@@ -8,6 +8,12 @@
<string name="emoji_picker_category_people">"Smajlíci a lidé"</string>
<string name="emoji_picker_category_places">"Cestování a místa"</string>
<string name="emoji_picker_category_symbols">"Symboly"</string>
<string name="screen_media_upload_preview_caption_warning">"Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace."</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Soubor nelze nahrát."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_media_upload_preview_error_too_large_message">"Maximální povolená velikost souboru je %1$s."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Soubor je pro nahrání příliš velký."</string>
<string name="screen_report_content_block_user">"Zablokovat uživatele"</string>
<string name="screen_report_content_block_user_hint">"Zaškrtněte, pokud chcete skrýt všechny aktuální a budoucí zprávy od tohoto uživatele"</string>
<string name="screen_report_content_explanation">"Tato zpráva bude nahlášena správci vašeho domovského serveru. Nebude si moci přečíst žádné šifrované zprávy."</string>

View File

@@ -8,6 +8,9 @@
<string name="emoji_picker_category_people">"Wynebau Hapus a Phobl"</string>
<string name="emoji_picker_category_places">"Teithio a Llefydd"</string>
<string name="emoji_picker_category_symbols">"Symbolau"</string>
<string name="screen_media_upload_preview_caption_warning">"Efallai na fydd capsiynau yn weladwy i bobl sy\'n defnyddio apiau hŷn."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Wedi methu â phrosesu cyfryngau i\'w llwytho, ceisiwch eto."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Wedi methu llwytho cyfryngau, ceisiwch eto."</string>
<string name="screen_report_content_block_user">"Rhwystro defnyddiwr"</string>
<string name="screen_report_content_block_user_hint">"Gwiriwch a ydych am guddio\'r holl negeseuon presennol ac yn y dyfodol gan y defnyddiwr hwn"</string>
<string name="screen_report_content_explanation">"Bydd y neges hon yn cael ei hadrodd i weinyddwr eich gweinyddwr cartref. Fyddan nhw ddim yn gallu darllen unrhyw negeseuon wedi\'u hamgryptio."</string>

View File

@@ -8,6 +8,12 @@
<string name="emoji_picker_category_people">"Smileys og mennesker"</string>
<string name="emoji_picker_category_places">"Rejser og steder"</string>
<string name="emoji_picker_category_symbols">"Symboler"</string>
<string name="screen_media_upload_preview_caption_warning">"Billedtekster er muligvis ikke synlige for personer, der bruger ældre apps."</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Filen kunne ikke uploades."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Det lykkedes ikke at behandle medier til upload. Prøv venligst igen."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Upload af medier mislykkedes. Prøv igen."</string>
<string name="screen_media_upload_preview_error_too_large_message">"Den maksimalt tilladte filstørrelse er %1$s ."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Filen er for stor til at kunne uploades."</string>
<string name="screen_report_content_block_user">"Bloker bruger"</string>
<string name="screen_report_content_block_user_hint">"Marker, hvis du vil skjule alle nuværende og fremtidige beskeder fra denne bruger"</string>
<string name="screen_report_content_explanation">"Denne meddelelse vil blive indberettet til administratoren af din hjemmeserver. De vil ikke være i stand til at læse nogen krypterede meddelelser."</string>

View File

@@ -8,6 +8,9 @@
<string name="emoji_picker_category_people">"Smileys &amp; Menschen"</string>
<string name="emoji_picker_category_places">"Reisen &amp; Orte"</string>
<string name="emoji_picker_category_symbols">"Symbole"</string>
<string name="screen_media_upload_preview_caption_warning">"Bildunterschriften sind für Nutzer älterer Apps möglicherweise nicht sichtbar."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Das Hochladen der Medien ist fehlgeschlagen. Bitte versuche es erneut."</string>
<string name="screen_report_content_block_user">"Nutzer blockieren"</string>
<string name="screen_report_content_block_user_hint">"Prüfen Sie, ob Sie alle aktuellen und zukünftigen Nachrichten dieses Nutzers ausblenden wollen"</string>
<string name="screen_report_content_explanation">"Diese Nachricht wird dem Administrator ihres Homeservers gemeldet. Dieser kann allerdings keine verschlüsselten Nachrichten lesen."</string>

View File

@@ -8,6 +8,9 @@
<string name="emoji_picker_category_people">"Φατσούλες &amp; Άνθρωποι"</string>
<string name="emoji_picker_category_places">"Ταξίδια &amp; Μέρη"</string>
<string name="emoji_picker_category_symbols">"Σύμβολα"</string>
<string name="screen_media_upload_preview_caption_warning">"Οι λεζάντες ενδέχεται να μην είναι ορατές σε άτομα που χρησιμοποιούν παλαιότερες εφαρμογές."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Αποτυχία μεταφόρτωσης μέσου, δοκίμασε ξανά."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Αποτυχία μεταφόρτωσης πολυμέσων, δοκίμασε ξανά."</string>
<string name="screen_report_content_block_user">"Αποκλεισμός χρήστη"</string>
<string name="screen_report_content_block_user_hint">"Επέλεξε εάν θες να αποκρύψεις όλα τα τρέχοντα και μελλοντικά μηνύματα από αυτόν τον χρήστη"</string>
<string name="screen_report_content_explanation">"Αυτό το μήνυμα θα αναφερθεί στον διαχειριστή του οικιακού διακομιστή σας. Δεν θα μπορεί να διαβάσει κρυπτογραφημένα μηνύματα."</string>

View File

@@ -8,6 +8,9 @@
<string name="emoji_picker_category_people">"Emojis y personas"</string>
<string name="emoji_picker_category_places">"Viajes y lugares"</string>
<string name="emoji_picker_category_symbols">"Símbolos"</string>
<string name="screen_media_upload_preview_caption_warning">"Es posible que las leyendas no sean visibles para las personas que usan aplicaciones más antiguas."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Error al procesar el contenido multimedia, por favor inténtalo de nuevo."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Error al subir el contenido multimedia, por favor inténtalo de nuevo."</string>
<string name="screen_report_content_block_user">"Bloquear usuario"</string>
<string name="screen_report_content_block_user_hint">"Marca esta casilla si quieres ocultar todos los mensajes actuales y futuros de este usuario"</string>
<string name="screen_report_content_explanation">"Se denunciará este mensaje al administrador de tu servidor base. No será capaz de leer ningún mensaje cifrado."</string>

View File

@@ -8,6 +8,12 @@
<string name="emoji_picker_category_people">"Emotikonid ja inimesed"</string>
<string name="emoji_picker_category_places">"Reisimine ja kohad"</string>
<string name="emoji_picker_category_symbols">"Sümbolid"</string>
<string name="screen_media_upload_preview_caption_warning">"Selgitused ja alapealkirjad ei pruugi olla nähtavad vanemate rakenduste kasutajatele."</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Faili üleslaadimine ei õnnestunud."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Meediafaili üleslaadimine ei õnnestunud. Palun proovi uuesti."</string>
<string name="screen_media_upload_preview_error_too_large_message">"Maksimaalne lubatud failisuurus on %1$s."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Fail on üleslaadimiseks liiga suur"</string>
<string name="screen_report_content_block_user">"Blokeeri kasutaja"</string>
<string name="screen_report_content_block_user_hint">"Vali see eelistus, kui sa soovid peita selle kasutaja kõik senised ja tulevased sõnumid"</string>
<string name="screen_report_content_explanation">"Teade selle sõnumi kohta edastatakse sinu koduserveri haldajale. Haldajal ei ole võimalik lugeda krüptitud sõnumite sisu."</string>

View File

@@ -8,6 +8,7 @@
<string name="emoji_picker_category_people">"Irribartxoak eta jendea"</string>
<string name="emoji_picker_category_places">"Bidaiak eta tokiak"</string>
<string name="emoji_picker_category_symbols">"Ikurrak"</string>
<string name="screen_media_upload_preview_error_failed_sending">"Huts egin du multimedia igotzeak, saiatu berriro."</string>
<string name="screen_report_content_block_user">"Blokeatu erabiltzailea"</string>
<string name="screen_report_content_explanation">"Mezua zure zerbitzariko administratzaileari jakinaraziko zaio. Ezingo dute zifratutako mezurik irakurri."</string>
<string name="screen_report_content_hint">"Edukia salatzeko arrazoia"</string>

View File

@@ -8,6 +8,8 @@
<string name="emoji_picker_category_people">"شکلک‌ها و افراد"</string>
<string name="emoji_picker_category_places">"سفر و مکان‌ها"</string>
<string name="emoji_picker_category_symbols">"نمادها"</string>
<string name="screen_media_upload_preview_error_failed_processing">"پردازش رسانه برای بارگذاری شکست خورد. لطفاً دوباره تلاش کنید."</string>
<string name="screen_media_upload_preview_error_failed_sending">"بارگذاری رسانه شکست خورد. لطفاً دوباره تلاش کنید."</string>
<string name="screen_report_content_block_user">"انسداد کاربر"</string>
<string name="screen_report_content_block_user_hint">"اگر می‌خواهید همه پیام‌های فعلی و آینده را از این کاربر را پنهان کنید، علامت بزنید"</string>
<string name="screen_report_content_explanation">"این پیام به مدیر کارساز خانگی شما گزارش خواهد شد. آنها قادر به خواندن پیام های رمزگذاری شده نخواهند بود."</string>

View File

@@ -8,6 +8,12 @@
<string name="emoji_picker_category_people">"Hymiöt ja ihmiset"</string>
<string name="emoji_picker_category_places">"Matkustaminen ja paikat"</string>
<string name="emoji_picker_category_symbols">"Symbolit"</string>
<string name="screen_media_upload_preview_caption_warning">"Kuvatekstit eivät välttämättä näy ihmisille, jotka käyttävät vanhempia sovelluksia."</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Tiedostoa ei voitu lähettää."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Median käsittely epäonnistui, yritä uudelleen."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Median lähettäminen epäonnistui, yritä uudelleen."</string>
<string name="screen_media_upload_preview_error_too_large_message">"Suurin sallittu tiedostokoko on %1$s."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Tiedosto on liian suuri lähetettäväksi"</string>
<string name="screen_report_content_block_user">"Estä käyttäjä"</string>
<string name="screen_report_content_block_user_hint">"Valitse tämä, jos haluat piilottaa kaikki nykyiset ja tulevat viestit tältä käyttäjältä"</string>
<string name="screen_report_content_explanation">"Tämä viesti ilmoitetaan kotipalvelimesi ylläpitäjälle. Ylläpitäjä ei pysty lukemaan salattuja viestejä."</string>

View File

@@ -8,6 +8,12 @@
<string name="emoji_picker_category_people">"Émoticônes et personnes"</string>
<string name="emoji_picker_category_places">"Voyages &amp; lieux"</string>
<string name="emoji_picker_category_symbols">"Symboles"</string>
<string name="screen_media_upload_preview_caption_warning">"Les légendes peuvent ne pas être visibles pour les utilisateurs danciennes applications."</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Le fichier na pas pu être envoyé."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Échec du traitement des médias à télécharger, veuillez réessayer."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Échec du téléchargement du média, veuillez réessayer."</string>
<string name="screen_media_upload_preview_error_too_large_message">"La taille maximale autorisée pour les fichiers est de %1$s."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Le fichier est trop volumineux pour être envoyé."</string>
<string name="screen_report_content_block_user">"Bloquer lutilisateur"</string>
<string name="screen_report_content_block_user_hint">"Cochez si vous souhaitez masquer tous les messages actuels et futurs de cet utilisateur."</string>
<string name="screen_report_content_explanation">"Ce message sera signalé à ladministrateur de votre serveur daccueil. Il ne pourra lire aucun message chiffré."</string>

View File

@@ -8,6 +8,12 @@
<string name="emoji_picker_category_people">"Mosolyok és emberek"</string>
<string name="emoji_picker_category_places">"Utazás és helyek"</string>
<string name="emoji_picker_category_symbols">"Szimbólumok"</string>
<string name="screen_media_upload_preview_caption_warning">"Előfordulhat, hogy a feliratok nem láthatók a régebbi alkalmazásokat használók számára."</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"A fájl nem tölthető fel."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nem sikerült a média feltöltése, próbálja újra."</string>
<string name="screen_media_upload_preview_error_too_large_message">"A maximálisan megengedett fájlméret: %1$s ."</string>
<string name="screen_media_upload_preview_error_too_large_title">"A fájl túl nagy a feltöltéshez"</string>
<string name="screen_report_content_block_user">"Felhasználó letiltása"</string>
<string name="screen_report_content_block_user_hint">"Jelölje be, ha el akarja rejteni az összes jelenlegi és jövőbeli üzenetet ettől a felhasználótól"</string>
<string name="screen_report_content_explanation">"Ez az üzenet jelentve lesz a Matrix-kiszolgáló adminisztrátorának. Nem fogja tudni elolvasni a titkosított üzeneteket."</string>

View File

@@ -8,6 +8,9 @@
<string name="emoji_picker_category_people">"Senyuman &amp; Orang"</string>
<string name="emoji_picker_category_places">"Wisata &amp; Tempat"</string>
<string name="emoji_picker_category_symbols">"Simbol"</string>
<string name="screen_media_upload_preview_caption_warning">"Keterangan mungkin tidak terlihat oleh orang yang menggunakan aplikasi lama."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Gagal memproses media untuk diunggah, silakan coba lagi."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Gagal mengunggah media, silakan coba lagi."</string>
<string name="screen_report_content_block_user">"Blokir pengguna"</string>
<string name="screen_report_content_block_user_hint">"Centang jika Anda ingin menyembunyikan semua pesan saat ini dan yang akan datang dari pengguna ini"</string>
<string name="screen_report_content_explanation">"Pesan ini akan dilaporkan ke administrator homeserver Anda. Mereka tidak akan dapat membaca pesan terenkripsi apa pun."</string>

View File

@@ -8,6 +8,9 @@
<string name="emoji_picker_category_people">"Faccine &amp; Persone"</string>
<string name="emoji_picker_category_places">"Viaggi &amp; Luoghi"</string>
<string name="emoji_picker_category_symbols">"Simboli"</string>
<string name="screen_media_upload_preview_caption_warning">"Le didascalie potrebbero non essere visibili agli utenti di app meno recenti."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Elaborazione del file multimediale da caricare fallita, riprova."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Caricamento del file multimediale fallito, riprova."</string>
<string name="screen_report_content_block_user">"Blocca utente"</string>
<string name="screen_report_content_block_user_hint">"Seleziona se vuoi nascondere tutti i messaggi attuali e futuri di questo utente"</string>
<string name="screen_report_content_explanation">"Questo messaggio verrà segnalato all\'amministratore dell\'homeserver. Questi non sarà in grado di leggere i messaggi cifrati."</string>

View File

@@ -8,6 +8,8 @@
<string name="emoji_picker_category_people">"ღიმილები &amp; ხალხი"</string>
<string name="emoji_picker_category_places">"მოგზაურობა და ადგილები"</string>
<string name="emoji_picker_category_symbols">"სიმბოლოები"</string>
<string name="screen_media_upload_preview_error_failed_processing">"მედიის ატვირთვა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა."</string>
<string name="screen_media_upload_preview_error_failed_sending">"მედიის ატვირთვა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა."</string>
<string name="screen_report_content_block_user">"მომხმარებლის დაბლოკვა"</string>
<string name="screen_report_content_block_user_hint">"შეამოწმეთ, გსურთ თუ არა ამ მომხმარებლის ყველა მიმდინარე და მომავალი შეტყობინების დამალვა"</string>
<string name="screen_report_content_explanation">"ეს შეტყობინება გაგზავნილი იქნება თქვენი სახლის სერვერის ადმინისტრატორისადმი. მას არ ექნება დაშიფვრული შეტყობინებების წაკითხვის შესაძლებლობა."</string>

View File

@@ -8,6 +8,8 @@
<string name="emoji_picker_category_people">"Šypsenėlės ir Žmonės"</string>
<string name="emoji_picker_category_places">"Kelionės ir Vietovės"</string>
<string name="emoji_picker_category_symbols">"Simboliai"</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nepavyko apdoroti įkeliamos laikmenos, bandykite dar kartą."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nepavyko įkelti laikmenos, pabandykite dar kartą."</string>
<string name="screen_report_content_block_user">"Blokuoti vartotoją"</string>
<string name="screen_report_content_block_user_hint">"Pažymėkite, jei norite paslėpti visas esamas ir būsimas šio vartotojo žinutes"</string>
<string name="screen_report_content_explanation">"Apie šią žinutę bus pranešta Jūsų serverio administracijai. Jie negalės perskaityti jokių užšifruotų žinučių."</string>

View File

@@ -8,6 +8,9 @@
<string name="emoji_picker_category_people">"Smilefjes og mennesker"</string>
<string name="emoji_picker_category_places">"Reising og steder"</string>
<string name="emoji_picker_category_symbols">"Symboler"</string>
<string name="screen_media_upload_preview_caption_warning">"Teksting er kanskje ikke synlig for personer som bruker eldre apper."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Kunne ikke behandle medier for opplasting, vennligst prøv igjen."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Opplasting av medier mislyktes, vennligst prøv igjen."</string>
<string name="screen_report_content_block_user">"Blokker bruker"</string>
<string name="screen_report_content_block_user_hint">"Kryss av for om du vil skjule alle nåværende og fremtidige meldinger fra denne brukeren"</string>
<string name="screen_report_content_explanation">"Denne meldingen vil bli rapportert til hjemmeserverens administratorer. De vil ikke kunne lese noen krypterte meldinger."</string>

View File

@@ -8,6 +8,8 @@
<string name="emoji_picker_category_people">"Smileys &amp; Mensen"</string>
<string name="emoji_picker_category_places">"Reizen &amp; Locaties"</string>
<string name="emoji_picker_category_symbols">"Symbolen"</string>
<string name="screen_media_upload_preview_error_failed_processing">"Het verwerken van media voor uploaden is mislukt. Probeer het opnieuw."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Het uploaden van media is mislukt. Probeer het opnieuw."</string>
<string name="screen_report_content_block_user">"Gebruiker blokkeren"</string>
<string name="screen_report_content_block_user_hint">"Vink aan als je alle huidige en toekomstige berichten van deze gebruiker wilt verbergen"</string>
<string name="screen_report_content_explanation">"Dit bericht wordt gerapporteerd aan de beheerder van je homeserver. Ze zullen geen versleutelde berichten kunnen lezen."</string>

View File

@@ -8,6 +8,12 @@
<string name="emoji_picker_category_people">"Buźki i osoby"</string>
<string name="emoji_picker_category_places">"Podróż i miejsca"</string>
<string name="emoji_picker_category_symbols">"Symbole"</string>
<string name="screen_media_upload_preview_caption_warning">"Opis może być niedostępny dla osób korzystających ze starszej wersji aplikacji."</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Nie udało się przesłać pliku."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Przetwarzanie multimediów do przesłania nie powiodło się, spróbuj ponownie."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Przesyłanie multimediów nie powiodło się, spróbuj ponownie."</string>
<string name="screen_media_upload_preview_error_too_large_message">"Maksymalny dozwolony rozmiar pliku to %1$s."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Plik jest za duży, aby go przesłać."</string>
<string name="screen_report_content_block_user">"Zablokuj użytkownika"</string>
<string name="screen_report_content_block_user_hint">"Sprawdź, czy chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika."</string>
<string name="screen_report_content_explanation">"Ta wiadomość zostanie zgłoszona do administratora Twojego serwera domowego. Nie będzie mógł on przeczytać żadnych zaszyfrowanych wiadomości."</string>

View File

@@ -8,6 +8,9 @@
<string name="emoji_picker_category_people">"Sorrisos &amp; Pessoas"</string>
<string name="emoji_picker_category_places">"Viagens &amp; Lugares"</string>
<string name="emoji_picker_category_symbols">"Símbolos"</string>
<string name="screen_media_upload_preview_caption_warning">"As legendas podem não ser visíveis para pessoas que usam aplicativos mais antigos."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Falha ao processar mídia para upload. Tente novamente."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Falha ao enviar mídia. Tente novamente."</string>
<string name="screen_report_content_block_user">"Bloquear usuário"</string>
<string name="screen_report_content_block_user_hint">"Marque se você deseja ocultar todas as mensagens atuais e futuras desse usuário"</string>
<string name="screen_report_content_explanation">"Essa mensagem será reportada ao administrador do seu homeserver. Eles não conseguirão ler nenhuma mensagem criptografada."</string>

View File

@@ -8,6 +8,12 @@
<string name="emoji_picker_category_people">"Caras e Pessoas"</string>
<string name="emoji_picker_category_places">"Viagens e Lugares"</string>
<string name="emoji_picker_category_symbols">"Símbolos"</string>
<string name="screen_media_upload_preview_caption_warning">"As legendas poderão não ser visíveis em versões mais antigas da aplicação."</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Não foi possível enviar o ficheiro"</string>
<string name="screen_media_upload_preview_error_failed_processing">"Falha ao processar multimédia para carregamento, por favor tente novamente."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Falhar ao carregar multimédia, por favor tente novamente."</string>
<string name="screen_media_upload_preview_error_too_large_message">"O tamanho máximo permitido é %1$s."</string>
<string name="screen_media_upload_preview_error_too_large_title">"O ficheiro é demasiado grande para enviar"</string>
<string name="screen_report_content_block_user">"Bloquear utilizador"</string>
<string name="screen_report_content_block_user_hint">"Ativar para ocultar todas as atuais e futuras mensagens deste utilizador"</string>
<string name="screen_report_content_explanation">"Esta mensagem será denunciada ao administrador do teu servidor. Porém, não lhe será possível ler quaisquer mensagens cifradas."</string>

View File

@@ -8,6 +8,8 @@
<string name="emoji_picker_category_people">"Fețe zâmbitoare &amp; Oameni"</string>
<string name="emoji_picker_category_places">"Călătorii &amp; Locuri"</string>
<string name="emoji_picker_category_symbols">"Simboluri"</string>
<string name="screen_media_upload_preview_error_failed_processing">"Procesarea datelor media a eșuat, vă rugăm să încercați din nou."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Încărcarea fișierelor media a eșuat, încercați din nou."</string>
<string name="screen_report_content_block_user">"Blocați utilizatorul"</string>
<string name="screen_report_content_block_user_hint">"Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator"</string>
<string name="screen_report_content_explanation">"Acest mesaj va fi raportat administratorilor homeserver-ului tau. Ei nu vor putea citi niciun mesaj criptat."</string>

View File

@@ -8,6 +8,9 @@
<string name="emoji_picker_category_people">"Улыбки и люди"</string>
<string name="emoji_picker_category_places">"Путешествия и места"</string>
<string name="emoji_picker_category_symbols">"Символы"</string>
<string name="screen_media_upload_preview_caption_warning">"Подпись может быть не видна пользователям старых приложений."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не удалось загрузить медиафайлы, попробуйте еще раз."</string>
<string name="screen_report_content_block_user">"Заблокировать пользователя"</string>
<string name="screen_report_content_block_user_hint">"Отметьте, хотите ли вы скрыть все текущие и будущие сообщения от этого пользователя"</string>
<string name="screen_report_content_explanation">"Это сообщение будет передано администратору вашего домашнего сервера. Они не смогут прочитать зашифрованные сообщения."</string>

View File

@@ -8,6 +8,12 @@
<string name="emoji_picker_category_people">"Smajlíky a ľudia"</string>
<string name="emoji_picker_category_places">"Cestovanie a miesta"</string>
<string name="emoji_picker_category_symbols">"Symboly"</string>
<string name="screen_media_upload_preview_caption_warning">"Titulky nemusia byť viditeľné pre ľudí používajúcich staršie aplikácie."</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Súbor sa nepodarilo nahrať."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nepodarilo sa nahrať médiá, skúste to prosím znova."</string>
<string name="screen_media_upload_preview_error_too_large_message">"Maximálna povolená veľkosť súboru je %1$s."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Súbor je príliš veľký na nahratie"</string>
<string name="screen_report_content_block_user">"Zablokovať používateľa"</string>
<string name="screen_report_content_block_user_hint">"Označte, či chcete skryť všetky aktuálne a budúce správy od tohto používateľa"</string>
<string name="screen_report_content_explanation">"Táto správa bude nahlásená správcovi vášho domovského servera. Nebude môcť prečítať žiadne šifrované správy."</string>

View File

@@ -8,6 +8,9 @@
<string name="emoji_picker_category_people">"Smileys &amp; personer"</string>
<string name="emoji_picker_category_places">"Resor &amp; platser"</string>
<string name="emoji_picker_category_symbols">"Symboler"</string>
<string name="screen_media_upload_preview_caption_warning">"Bildtexter kanske inte är synliga för personer som använder äldre appar."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Misslyckades att bearbeta media för uppladdning, vänligen pröva igen."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Misslyckades att ladda upp media, vänligen pröva igen."</string>
<string name="screen_report_content_block_user">"Blockera användare"</string>
<string name="screen_report_content_block_user_hint">"Markera om du vill dölja alla nuvarande och framtida meddelanden från denna användare"</string>
<string name="screen_report_content_explanation">"Det här meddelandet kommer att rapporteras till din hemservers administratör. Denne kommer inte att kunna läsa några krypterade meddelanden."</string>

View File

@@ -8,6 +8,9 @@
<string name="emoji_picker_category_people">"İfadeler ve İnsanlar"</string>
<string name="emoji_picker_category_places">"Seyahat ve Yerler"</string>
<string name="emoji_picker_category_symbols">"Simgeler"</string>
<string name="screen_media_upload_preview_caption_warning">"Açıklamalar, eski uygulamaları kullanan kişiler tarafından görülemeyebilir."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Medya yüklenemedi, lütfen tekrar deneyin."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Medya yüklenemedi, lütfen tekrar deneyin."</string>
<string name="screen_report_content_block_user">"Kullanıcıyı engelle"</string>
<string name="screen_report_content_block_user_hint">"Bu kullanıcıdan gelen mevcut ve gelecekteki tüm mesajları gizlemek isteyip istemediğinizi işaretleyin"</string>
<string name="screen_report_content_explanation">"Bu mesaj ana sunucunuzun yöneticisine bildirilecektir. Şifrelenmiş mesajları okuyamayacaklardır."</string>

View File

@@ -8,6 +8,12 @@
<string name="emoji_picker_category_people">"Смайлики та люди"</string>
<string name="emoji_picker_category_places">"Подорожі та місця"</string>
<string name="emoji_picker_category_symbols">"Символи"</string>
<string name="screen_media_upload_preview_caption_warning">"Користувачі старих застосунків можуть не бачити підписи."</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Файл не може бути вивантажено."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не вдалося обробити медіафайл для завантаження, спробуйте ще раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не вдалося завантажити медіафайл, спробуйте ще раз."</string>
<string name="screen_media_upload_preview_error_too_large_message">"Максимально дозволений розмір файлу — %1$s."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Файл завеликий для вивантаження"</string>
<string name="screen_report_content_block_user">"Заблокувати користувача"</string>
<string name="screen_report_content_block_user_hint">"Перевірте, чи хочете ви приховати всі поточні та майбутні повідомлення від цього користувача"</string>
<string name="screen_report_content_explanation">"Це повідомлення буде надіслано адміністраторам вашого домашнього сервера. Вони не зможуть прочитати зашифровані повідомлення."</string>

View File

@@ -8,6 +8,8 @@
<string name="emoji_picker_category_people">"مسکراہٹیں و لوگ"</string>
<string name="emoji_picker_category_places">"سفر و مقامات"</string>
<string name="emoji_picker_category_symbols">"علامتیں"</string>
<string name="screen_media_upload_preview_error_failed_processing">"وسائط کا معالجہ برائے ترفیع ناکام، برائے مہربانی دوبارہ کوشش کریں۔"</string>
<string name="screen_media_upload_preview_error_failed_sending">"وسائط رفع کرنے میں ناکام، برائے مہربانی دوبارہ کوشش کریں۔"</string>
<string name="screen_report_content_block_user">"صارف کو مسدود کریں"</string>
<string name="screen_report_content_block_user_hint">"پڑتال کریں کہ کیا آپ اس صارف سے تمام موجودہ اور مستقبلی پیغامات چھپانا چاہتے ہیں۔"</string>
<string name="screen_report_content_explanation">"اس پیغام کی اطلاع آپکے منزلی خادم کے منتظم کو دی جائیگی۔ وہ کوئی مرموزکردہ پیغامات نہیں پڑھ سکیں گے۔"</string>

View File

@@ -8,6 +8,8 @@
<string name="emoji_picker_category_people">"Smayllar va odamlar"</string>
<string name="emoji_picker_category_places">"Sayohat va Joylar"</string>
<string name="emoji_picker_category_symbols">"Belgilar"</string>
<string name="screen_media_upload_preview_error_failed_processing">"Mediani yuklab bolmadi, qayta urinib koring."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Media yuklanmadi, qayta urinib koring."</string>
<string name="screen_report_content_block_user">"Foydalanuvchini bloklash"</string>
<string name="screen_report_content_block_user_hint">"Ushbu foydalanuvchidan barcha joriy va kelajakdagi xabarlarni yashirishni xohlayotganingizni tekshiring"</string>
<string name="screen_report_content_explanation">"Bu xabar uy serveringiz administratoriga xabar qilinadi. Ular hech qanday shifrlangan xabarlarni o\'qiy olmaydi."</string>

View File

@@ -8,6 +8,9 @@
<string name="emoji_picker_category_people">"表情與人物"</string>
<string name="emoji_picker_category_places">"旅行與景點"</string>
<string name="emoji_picker_category_symbols">"標誌"</string>
<string name="screen_media_upload_preview_caption_warning">"使用舊應用程式的使用者可能看不到標題。"</string>
<string name="screen_media_upload_preview_error_failed_processing">"無法處理要上傳的媒體,請再試一次。"</string>
<string name="screen_media_upload_preview_error_failed_sending">"無法上傳媒體檔案,請稍後再試。"</string>
<string name="screen_report_content_block_user">"封鎖使用者"</string>
<string name="screen_report_content_block_user_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</string>
<string name="screen_report_content_explanation">"此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。"</string>

View File

@@ -8,6 +8,9 @@
<string name="emoji_picker_category_people">"表情和人物"</string>
<string name="emoji_picker_category_places">"旅行和地点"</string>
<string name="emoji_picker_category_symbols">"符号"</string>
<string name="screen_media_upload_preview_caption_warning">"使用旧版应用程序的用户可能无法看到字幕。"</string>
<string name="screen_media_upload_preview_error_failed_processing">"处理要上传的媒体失败,请重试。"</string>
<string name="screen_media_upload_preview_error_failed_sending">"上传媒体失败,请重试。"</string>
<string name="screen_report_content_block_user">"封禁用户"</string>
<string name="screen_report_content_block_user_hint">"请确认是否要隐藏该用户当前和未来的所有信息"</string>
<string name="screen_report_content_explanation">"此消息将举报给您的服务器管理员。他们无法读取任何加密消息。"</string>

View File

@@ -8,6 +8,15 @@
<string name="emoji_picker_category_people">"Smileys &amp; People"</string>
<string name="emoji_picker_category_places">"Travel &amp; Places"</string>
<string name="emoji_picker_category_symbols">"Symbols"</string>
<string name="screen_media_upload_preview_caption_warning">"Captions might not be visible to people using older apps."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tap to change the video upload quality"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"The file could not be uploaded."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
<string name="screen_media_upload_preview_error_too_large_message">"The maximum file size allowed is %1$s."</string>
<string name="screen_media_upload_preview_error_too_large_title">"The file is too large to upload"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimise image quality"</string>
<string name="screen_media_upload_preview_processing">"Processing…"</string>
<string name="screen_report_content_block_user">"Block user"</string>
<string name="screen_report_content_block_user_hint">"Check if you want to hide all current and future messages from this user"</string>
<string name="screen_report_content_explanation">"This message will be reported to your homeservers administrator. They will not be able to read any encrypted messages."</string>

View File

@@ -325,6 +325,7 @@ class MessagesPresenterTest {
val mediaMessage = aMessageEvent(
content = TimelineItemImageContent(
filename = "image.jpg",
fileSize = 4 * 1024 * 1024L,
caption = null,
formattedCaption = null,
isEdited = false,
@@ -365,6 +366,7 @@ class MessagesPresenterTest {
val mediaMessage = aMessageEvent(
content = TimelineItemVideoContent(
filename = "video.mp4",
fileSize = 50 * 1024 * 1024L,
caption = null,
formattedCaption = null,
isEdited = false,
@@ -406,6 +408,7 @@ class MessagesPresenterTest {
val mediaMessage = aMessageEvent(
content = TimelineItemFileContent(
filename = "file.pdf",
fileSize = 10 * 1024 * 1024L,
caption = null,
isEdited = false,
formattedCaption = null,

View File

@@ -18,8 +18,12 @@ import io.element.android.features.messages.impl.attachments.preview.Attachments
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
import io.element.android.features.messages.impl.attachments.preview.OnDoneListener
import io.element.android.features.messages.impl.attachments.preview.SendActionState
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
import io.element.android.features.messages.impl.fixtures.aMediaAttachment
import io.element.android.features.messages.test.attachments.video.FakeMediaOptimizationSelectorPresenterFactory
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@@ -36,15 +40,19 @@ import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaError
@@ -53,6 +61,7 @@ import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
@@ -64,6 +73,7 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
@Suppress("LargeClass")
@RunWith(RobolectricTestRunner::class)
class AttachmentsPreviewPresenterTest {
@get:Rule
@@ -548,6 +558,111 @@ class AttachmentsPreviewPresenterTest {
}
}
@Test
fun `present - file too large will display error`() = runTest {
val onDoneListenerResult = lambdaRecorder<Unit> {}
val localMedia = aLocalMedia(uri = Uri.EMPTY, mediaInfo = anApkMediaInfo())
val maxUploadSize = 999L // Set a max upload size smaller than the file size
val presenter = createAttachmentsPreviewPresenter(
localMedia = localMedia,
room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendFileLambda = { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
}
),
mediaUploadOnSendQueueEnabled = true,
onDoneListener = onDoneListenerResult,
mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory {
MediaOptimizationSelectorState(
// Set a max upload size smaller than the file size
maxUploadSize = AsyncData.Success(maxUploadSize),
videoSizeEstimations = AsyncData.Uninitialized,
isImageOptimizationEnabled = null,
selectedVideoPreset = null,
displayMediaSelectorViews = false,
displayVideoPresetSelectorDialog = false,
eventSink = {},
)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(localMedia.info.fileSize).isGreaterThan(maxUploadSize)
consumeItemsUntilPredicate { it.mediaOptimizationSelectorState.maxUploadSize.isSuccess() }
assertThat(awaitItem().displayFileTooLargeError).isTrue()
}
}
@Test
fun `present - video size estimations too large will display error`() = runTest {
val onDoneListenerResult = lambdaRecorder<Unit> {}
val localMedia = aLocalMedia(uri = Uri.EMPTY, mediaInfo = aVideoMediaInfo())
val presenter = createAttachmentsPreviewPresenter(
localMedia = localMedia,
room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendFileLambda = { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
}
),
mediaUploadOnSendQueueEnabled = true,
onDoneListener = onDoneListenerResult,
mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory {
MediaOptimizationSelectorState(
// Set a max upload size smaller than the file size
maxUploadSize = AsyncData.Success(Long.MAX_VALUE),
videoSizeEstimations = AsyncData.Success(
persistentListOf(
VideoUploadEstimation(
preset = VideoCompressionPreset.LOW,
// The important field is canUpload, it will normally be based on the sizeInBytes
canUpload = false,
sizeInBytes = 0L,
),
VideoUploadEstimation(
preset = VideoCompressionPreset.STANDARD,
canUpload = false,
sizeInBytes = 0L,
),
VideoUploadEstimation(
preset = VideoCompressionPreset.HIGH,
canUpload = false,
sizeInBytes = 0L,
),
)
),
isImageOptimizationEnabled = null,
selectedVideoPreset = null,
displayMediaSelectorViews = false,
displayVideoPresetSelectorDialog = false,
eventSink = {},
)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
consumeItemsUntilPredicate {
it.mediaOptimizationSelectorState.maxUploadSize.isSuccess() &&
it.mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull()?.isNotEmpty() == true
}
assertThat(awaitItem().displayFileTooLargeError).isTrue()
}
}
private fun TestScope.createAttachmentsPreviewPresenter(
localMedia: LocalMedia = aLocalMedia(
uri = mockMediaUrl,
@@ -560,11 +675,27 @@ class AttachmentsPreviewPresenterTest {
mediaUploadOnSendQueueEnabled: Boolean = true,
allowCaption: Boolean = true,
showCaptionCompatibilityWarning: Boolean = true,
displayMediaQualitySelectorViews: Boolean = false,
mediaOptimizationSelectorPresenterFactory: FakeMediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory(
fakePresenter = {
MediaOptimizationSelectorState(
maxUploadSize = AsyncData.Uninitialized,
videoSizeEstimations = AsyncData.Uninitialized,
isImageOptimizationEnabled = null,
selectedVideoPreset = null,
displayMediaSelectorViews = displayMediaQualitySelectorViews,
displayVideoPresetSelectorDialog = false,
eventSink = {},
)
}
),
): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia),
onDoneListener = onDoneListener,
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
mediaSender = MediaSender(mediaPreProcessor, room, {
MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD)
}),
permalinkBuilder = permalinkBuilder,
temporaryUriDeleter = temporaryUriDeleter,
featureFlagService = FakeFeatureFlagService(
@@ -576,6 +707,7 @@ class AttachmentsPreviewPresenterTest {
),
sessionCoroutineScope = this,
dispatchers = testCoroutineDispatchers(),
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
)
}

View File

@@ -0,0 +1,249 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.video
import android.net.Uri
import android.util.Size
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.test.attachments.video.FakeVideoMetadataExtractor
import io.element.android.features.messages.test.attachments.video.FakeVideoMetadataExtractorFactory
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DefaultMediaOptimizationSelectorPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val mockMediaUrl: Uri = mockk("localMediaUri")
@Test
fun `present - initial state`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().run {
// Loading
assertThat(videoSizeEstimations).isInstanceOf(AsyncData.Loading::class.java)
assertThat(maxUploadSize).isInstanceOf(AsyncData.Loading::class.java)
// Not loaded yet
assertThat(isImageOptimizationEnabled).isNull()
assertThat(selectedVideoPreset).isNull()
assertThat(displayMediaSelectorViews).isNull()
assertThat(displayVideoPresetSelectorDialog).isFalse()
}
// The data will load after the first recomposition
awaitItem().run {
assertThat(videoSizeEstimations).isInstanceOf(AsyncData.Success::class.java)
assertThat(maxUploadSize).isInstanceOf(AsyncData.Success::class.java)
assertThat(isImageOptimizationEnabled).isTrue()
assertThat(selectedVideoPreset).isEqualTo(VideoCompressionPreset.STANDARD)
assertThat(displayMediaSelectorViews).isTrue()
assertThat(displayVideoPresetSelectorDialog).isFalse()
}
}
}
@Test
fun `present - if media info is not video, the video state won't load`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter(
localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo())
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip loading state
skipItems(1)
// The data will load after the first recomposition
awaitItem().run {
assertThat(videoSizeEstimations).isInstanceOf(AsyncData.Uninitialized::class.java)
assertThat(selectedVideoPreset).isNull()
}
}
}
@Test
fun `present - OpenVideoPresetSelectorDialog displays it, DismissVideoPresetSelectorDialog hides it`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip loading state
val eventSink = awaitItem().eventSink
assertThat(awaitItem().displayVideoPresetSelectorDialog).isFalse()
eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog)
assertThat(awaitItem().displayVideoPresetSelectorDialog).isTrue()
eventSink(MediaOptimizationSelectorEvent.DismissVideoPresetSelectorDialog)
assertThat(awaitItem().displayVideoPresetSelectorDialog).isFalse()
}
}
@Test
fun `present - SelectVideoPreset sets it and dismisses the dialog`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip loading state
val eventSink = awaitItem().eventSink
assertThat(awaitItem().displayVideoPresetSelectorDialog).isFalse()
eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog)
assertThat(awaitItem().displayVideoPresetSelectorDialog).isTrue()
eventSink(MediaOptimizationSelectorEvent.SelectVideoPreset(VideoCompressionPreset.LOW))
assertThat(awaitItem().selectedVideoPreset).isEqualTo(VideoCompressionPreset.LOW)
assertThat(awaitItem().displayVideoPresetSelectorDialog).isFalse()
}
}
@Test
fun `present - SelectVideoPreset won't do anything if there is no metadata`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter(
mediaExtractorFactory = FakeVideoMetadataExtractorFactory(FakeVideoMetadataExtractor(sizeResult = Result.failure(AN_EXCEPTION))),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip loading state
val eventSink = awaitItem().eventSink
assertThat(awaitItem().videoSizeEstimations.dataOrNull()).isNull()
eventSink(MediaOptimizationSelectorEvent.SelectVideoPreset(VideoCompressionPreset.LOW))
ensureAllEventsConsumed()
}
}
@Test
fun `present - SelectVideoPreset won't select the preset if it won't allow to upload the video`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter(
mediaExtractorFactory = FakeVideoMetadataExtractorFactory(
FakeVideoMetadataExtractor(
sizeResult = Result.success(Size(10_000, 10_000)),
duration = Result.success(600L)
)
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip loading and loaded states
val eventSink = awaitItem().eventSink
skipItems(1)
// No video results could be uploaded
awaitItem().run {
val videoSizeEstimations = videoSizeEstimations.dataOrNull()
assertThat(videoSizeEstimations).isNotNull()
assertThat(videoSizeEstimations!!.none { it.canUpload }).isTrue()
}
eventSink(MediaOptimizationSelectorEvent.SelectVideoPreset(VideoCompressionPreset.HIGH))
ensureAllEventsConsumed()
}
}
@Test
fun `present - SelectImageOptimization sets the new value`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter(
localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip loading state
val eventSink = awaitItem().eventSink
assertThat(awaitItem().isImageOptimizationEnabled).isTrue()
eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(false))
assertThat(awaitItem().isImageOptimizationEnabled).isFalse()
}
}
@Test
fun `present - max upload size will default to 100MB if we can't get it`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter(
maxUploadSizeProvider = MaxUploadSizeProvider(FakeMatrixClient(getMaxUploadSizeResult = { Result.failure(AN_EXCEPTION) }))
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip loading and loaded state
skipItems(1)
assertThat(awaitItem().maxUploadSize.dataOrNull()).isEqualTo(1024 * 1024 * 100)
}
}
@Test
fun `present - with feature flag disabled won't display the media quality selector views`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter(
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to false)),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip loading and loaded state
skipItems(1)
assertThat(awaitItem().displayMediaSelectorViews).isFalse()
}
}
private fun createDefaultMediaOptimizationSelectorPresenter(
localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider(
FakeMatrixClient(getMaxUploadSizeResult = { Result.success(1_000L) }),
),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)),
mediaExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(),
): DefaultMediaOptimizationSelectorPresenter {
return DefaultMediaOptimizationSelectorPresenter(
localMedia = localMedia,
maxUploadSizeProvider = maxUploadSizeProvider,
sessionPreferencesStore = sessionPreferencesStore,
featureFlagService = featureFlagService,
mediaExtractorFactory = mediaExtractorFactory,
)
}
}

View File

@@ -74,15 +74,18 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
@@ -1542,6 +1545,7 @@ class MessageComposerPresenterTest {
textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(),
isRichTextEditorEnabled: Boolean = true,
draftService: ComposerDraftService = FakeComposerDraftService(),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
) = MessageComposerPresenter(
navigator = navigator,
sessionCoroutineScope = this,
@@ -1550,7 +1554,11 @@ class MessageComposerPresenterTest {
featureFlagService = featureFlagService,
sessionPreferencesStore = sessionPreferencesStore,
localMediaFactory = localMediaFactory,
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
mediaSender = MediaSender(
preProcessor = mediaPreProcessor,
room = room,
mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) }
),
snackbarDispatcher = snackbarDispatcher,
analyticsService = analyticsService,
locationService = locationService,
@@ -1565,6 +1573,7 @@ class MessageComposerPresenterTest {
mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper,
suggestionsProcessor = SuggestionsProcessor(),
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled

View File

@@ -231,6 +231,7 @@ class TimelineItemContentMessageFactoryTest {
)
val expected = TimelineItemVideoContent(
filename = "filename",
fileSize = 0L,
caption = null,
formattedCaption = null,
isEdited = false,
@@ -283,6 +284,7 @@ class TimelineItemContentMessageFactoryTest {
)
val expected = TimelineItemVideoContent(
filename = "body.mp4",
fileSize = 555L,
caption = "body.mp4 caption",
formattedCaption = SpannedString("formatted"),
isEdited = true,
@@ -312,6 +314,7 @@ class TimelineItemContentMessageFactoryTest {
)
val expected = TimelineItemAudioContent(
filename = "filename",
fileSize = 0L,
caption = null,
formattedCaption = null,
isEdited = false,
@@ -347,6 +350,7 @@ class TimelineItemContentMessageFactoryTest {
)
val expected = TimelineItemAudioContent(
filename = "body.mp3",
fileSize = 123L,
caption = null,
formattedCaption = null,
isEdited = true,
@@ -369,6 +373,7 @@ class TimelineItemContentMessageFactoryTest {
)
val expected = TimelineItemVoiceContent(
filename = "filename",
fileSize = 0L,
eventId = AN_EVENT_ID,
caption = null,
formattedCaption = null,
@@ -411,6 +416,7 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemVoiceContent(
eventId = AN_EVENT_ID,
filename = "body.ogg",
fileSize = 123L,
caption = null,
formattedCaption = null,
isEdited = true,
@@ -440,6 +446,7 @@ class TimelineItemContentMessageFactoryTest {
)
val expected = TimelineItemAudioContent(
filename = "filename",
fileSize = 0L,
caption = null,
formattedCaption = null,
isEdited = false,
@@ -462,6 +469,7 @@ class TimelineItemContentMessageFactoryTest {
)
val expected = TimelineItemImageContent(
filename = "filename",
fileSize = 0L,
caption = "body",
formattedCaption = null,
isEdited = false,
@@ -492,6 +500,7 @@ class TimelineItemContentMessageFactoryTest {
)
val expected = TimelineItemStickerContent(
filename = "filename",
fileSize = 8_192L,
caption = null,
formattedCaption = null,
isEdited = false,
@@ -540,6 +549,7 @@ class TimelineItemContentMessageFactoryTest {
)
val expected = TimelineItemImageContent(
filename = "body.jpg",
fileSize = 888L,
caption = "body.jpg caption",
formattedCaption = SpannedString("formatted"),
isEdited = true,
@@ -568,6 +578,7 @@ class TimelineItemContentMessageFactoryTest {
)
val expected = TimelineItemFileContent(
filename = "filename",
fileSize = 0L,
caption = null,
formattedCaption = null,
isEdited = false,
@@ -609,6 +620,7 @@ class TimelineItemContentMessageFactoryTest {
)
val expected = TimelineItemFileContent(
filename = "body.pdf",
fileSize = 123L,
caption = null,
formattedCaption = null,
isEdited = true,

View File

@@ -26,13 +26,14 @@ import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.aPermissionsState
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
@@ -72,7 +73,11 @@ class VoiceMessageComposerPresenterTest {
},
)
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
private val mediaSender = MediaSender(mediaPreProcessor, joinedRoom, InMemorySessionPreferencesStore())
private val mediaSender = MediaSender(
preProcessor = mediaPreProcessor,
room = joinedRoom,
mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) },
)
private val messageComposerContext = FakeMessageComposerContext()
companion object {

View File

@@ -14,6 +14,8 @@ android {
}
dependencies {
api(projects.features.messages.api)
api(projects.features.messages.impl)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.mediaviewer.api)
implementation(projects.libraries.preferences.api)
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.test.attachments.video
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
class FakeMediaOptimizationSelectorPresenterFactory(
private val fakePresenter: MediaOptimizationSelectorPresenter = MediaOptimizationSelectorPresenter {
MediaOptimizationSelectorState(
maxUploadSize = AsyncData.Uninitialized,
videoSizeEstimations = AsyncData.Uninitialized,
isImageOptimizationEnabled = null,
selectedVideoPreset = null,
displayMediaSelectorViews = null,
displayVideoPresetSelectorDialog = false,
eventSink = {},
)
}
) : MediaOptimizationSelectorPresenter.Factory {
override fun create(localMedia: LocalMedia): MediaOptimizationSelectorPresenter {
return fakePresenter
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.test.attachments.video
import android.net.Uri
import android.util.Size
import io.element.android.features.messages.impl.attachments.video.VideoMetadataExtractor
class FakeVideoMetadataExtractor(
private val sizeResult: Result<Size> = Result.success(Size(1, 1)),
private val duration: Result<Long> = Result.success(1L),
) : VideoMetadataExtractor {
override fun getSize(): Result<Size> = sizeResult
override fun getDuration(): Result<Long> = duration
override fun close() = Unit
}
class FakeVideoMetadataExtractorFactory(
private val fakeVideoMetadataExtractor: FakeVideoMetadataExtractor = FakeVideoMetadataExtractor(),
) : VideoMetadataExtractor.Factory {
override fun create(uri: Uri): VideoMetadataExtractor {
return fakeVideoMetadataExtractor
}
}

View File

@@ -8,11 +8,14 @@
package io.element.android.features.preferences.impl.advanced
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
sealed interface AdvancedSettingsEvents {
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data class SetCompressMedia(val compress: Boolean) : AdvancedSettingsEvents
data class SetCompressImages(val compress: Boolean) : AdvancedSettingsEvents
data class SetVideoUploadQuality(val videoPreset: VideoCompressionPreset) : AdvancedSettingsEvents
data class SetTheme(val theme: ThemeOption) : AdvancedSettingsEvents
data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : AdvancedSettingsEvents
data class SetHideInviteAvatars(val value: Boolean) : AdvancedSettingsEvents

View File

@@ -11,15 +11,19 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.compound.theme.Theme
import io.element.android.compound.theme.mapToTheme
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -29,6 +33,7 @@ class AdvancedSettingsPresenter @Inject constructor(
private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val featureFlagService: FeatureFlagService,
) : Presenter<AdvancedSettingsState> {
@Composable
override fun present(): AdvancedSettingsState {
@@ -38,9 +43,6 @@ class AdvancedSettingsPresenter @Inject constructor(
val isSharePresenceEnabled by remember {
sessionPreferencesStore.isSharePresenceEnabled()
}.collectAsState(initial = true)
val doesCompressMedia by remember {
sessionPreferencesStore.doesCompressMedia()
}.collectAsState(initial = true)
val theme = remember {
appPreferencesStore.getThemeFlow().mapToTheme()
}.collectAsState(initial = Theme.System)
@@ -57,6 +59,28 @@ class AdvancedSettingsPresenter @Inject constructor(
}
}
val hasSplitMediaQualityOptions by produceState<Boolean?>(null) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality)
}
val mediaOptimizationState by produceState<MediaOptimizationState?>(null) {
val hasSplitMediaQualityOptionsFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.SelectableMediaQuality)
combine(
hasSplitMediaQualityOptionsFlow,
sessionPreferencesStore.doesOptimizeImages(),
sessionPreferencesStore.getVideoCompressionPreset()
) { hasSplitOptions, compressImages, videoPreset ->
if (hasSplitMediaQualityOptions == true) {
value = MediaOptimizationState.Split(
compressImages = compressImages,
videoPreset = videoPreset,
)
} else if (hasSplitMediaQualityOptions == false) {
value = MediaOptimizationState.AllMedia(isEnabled = compressImages)
}
}.collect()
}
fun handleEvents(event: AdvancedSettingsEvents) {
when (event) {
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> sessionCoroutineScope.launch {
@@ -66,7 +90,7 @@ class AdvancedSettingsPresenter @Inject constructor(
sessionPreferencesStore.setSharePresence(event.enabled)
}
is AdvancedSettingsEvents.SetCompressMedia -> sessionCoroutineScope.launch {
sessionPreferencesStore.setCompressMedia(event.compress)
sessionPreferencesStore.setOptimizeImages(event.compress)
}
is AdvancedSettingsEvents.SetTheme -> sessionCoroutineScope.launch {
when (event.theme) {
@@ -77,13 +101,19 @@ class AdvancedSettingsPresenter @Inject constructor(
}
is AdvancedSettingsEvents.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value)
is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value)
is AdvancedSettingsEvents.SetCompressImages -> sessionCoroutineScope.launch {
sessionPreferencesStore.setOptimizeImages(event.compress)
}
is AdvancedSettingsEvents.SetVideoUploadQuality -> sessionCoroutineScope.launch {
sessionPreferencesStore.setVideoCompressionPreset(event.videoPreset)
}
}
}
return AdvancedSettingsState(
isDeveloperModeEnabled = isDeveloperModeEnabled,
isSharePresenceEnabled = isSharePresenceEnabled,
doesCompressMedia = doesCompressMedia,
mediaOptimizationState = mediaOptimizationState,
theme = themeOption,
mediaPreviewConfigState = mediaPreviewConfigState,
eventSink = ::handleEvents,

View File

@@ -11,17 +11,31 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.preferences.DropdownOption
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.ui.strings.CommonStrings
data class AdvancedSettingsState(
val isDeveloperModeEnabled: Boolean,
val isSharePresenceEnabled: Boolean,
val doesCompressMedia: Boolean,
val mediaOptimizationState: MediaOptimizationState?,
val theme: ThemeOption,
val mediaPreviewConfigState: MediaPreviewConfigState,
val eventSink: (AdvancedSettingsEvents) -> Unit
)
sealed interface MediaOptimizationState {
data class AllMedia(val isEnabled: Boolean) : MediaOptimizationState
data class Split(
val compressImages: Boolean,
val videoPreset: VideoCompressionPreset,
) : MediaOptimizationState
val shouldCompressImages: Boolean get() = when (this) {
is AllMedia -> isEnabled
is Split -> compressImages
}
}
enum class ThemeOption : DropdownOption {
System {
@Composable

View File

@@ -10,6 +10,7 @@ package io.element.android.features.preferences.impl.advanced
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSettingsState> {
override val values: Sequence<AdvancedSettingsState>
@@ -17,18 +18,22 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
aAdvancedSettingsState(),
aAdvancedSettingsState(isDeveloperModeEnabled = true),
aAdvancedSettingsState(isSharePresenceEnabled = true),
aAdvancedSettingsState(doesCompressMedia = true),
aAdvancedSettingsState(mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true)),
aAdvancedSettingsState(hideInviteAvatars = true),
aAdvancedSettingsState(timelineMediaPreviewValue = MediaPreviewValue.Off),
aAdvancedSettingsState(setHideInviteAvatarsAction = AsyncAction.Loading),
aAdvancedSettingsState(setTimelineMediaPreviewAction = AsyncAction.Loading),
aAdvancedSettingsState(mediaOptimizationState = MediaOptimizationState.Split(
compressImages = true,
videoPreset = VideoCompressionPreset.HIGH,
)),
)
}
fun aAdvancedSettingsState(
isDeveloperModeEnabled: Boolean = false,
isSharePresenceEnabled: Boolean = false,
doesCompressMedia: Boolean = false,
mediaOptimizationState: MediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = false),
theme: ThemeOption = ThemeOption.System,
hideInviteAvatars: Boolean = false,
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
@@ -38,7 +43,7 @@ fun aAdvancedSettingsState(
) = AdvancedSettingsState(
isDeveloperModeEnabled = isDeveloperModeEnabled,
isSharePresenceEnabled = isSharePresenceEnabled,
doesCompressMedia = doesCompressMedia,
mediaOptimizationState = mediaOptimizationState,
theme = theme,
mediaPreviewConfigState = MediaPreviewConfigState(
hideInviteAvatars = hideInviteAvatars,

View File

@@ -10,20 +10,27 @@ package io.element.android.features.preferences.impl.advanced
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
@@ -34,6 +41,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@@ -94,6 +102,11 @@ fun AdvancedSettingsView(
),
onClick = { state.eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) }
)
val compressImages = state.mediaOptimizationState?.shouldCompressImages
when (state.mediaOptimizationState) {
null -> Unit
is MediaOptimizationState.AllMedia -> {
ListItem(
headlineContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_title))
@@ -102,10 +115,10 @@ fun AdvancedSettingsView(
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_description))
},
trailingContent = ListItemContent.Switch(
checked = state.doesCompressMedia,
checked = compressImages ?: false,
),
onClick = {
val newValue = !state.doesCompressMedia
val newValue = !(compressImages ?: false)
analyticsService.captureInteraction(
if (newValue) {
Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled
@@ -116,10 +129,126 @@ fun AdvancedSettingsView(
state.eventSink(AdvancedSettingsEvents.SetCompressMedia(newValue))
}
)
}
is MediaOptimizationState.Split -> {
ListItem(
headlineContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_title))
},
supportingContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_description))
},
trailingContent = ListItemContent.Switch(
checked = compressImages ?: false,
),
onClick = {
val newValue = !(compressImages ?: false)
analyticsService.captureInteraction(
if (newValue) {
Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled
} else {
Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled
}
)
state.eventSink(AdvancedSettingsEvents.SetCompressMedia(newValue))
}
)
var displaySelectorDialog by remember { mutableStateOf(false) }
ListItem(
headlineContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_title))
},
supportingContent = {
val description = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_description)
val quality = when (state.mediaOptimizationState.videoPreset) {
VideoCompressionPreset.LOW -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_low)
VideoCompressionPreset.STANDARD -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_standard)
VideoCompressionPreset.HIGH -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_high)
}
val descriptionWithValue = remember(quality) {
String.format(description, quality)
}
Text(text = descriptionWithValue)
},
onClick = { displaySelectorDialog = true },
)
if (displaySelectorDialog) {
VideoQualitySelectorDialog(
selectedPreset = state.mediaOptimizationState.videoPreset,
onSubmit = { preset ->
state.eventSink(AdvancedSettingsEvents.SetVideoUploadQuality(preset))
displaySelectorDialog = false
},
onDismiss = { displaySelectorDialog = false },
)
}
}
}
ModerationAndSafety(state)
}
}
@Composable
private fun VideoQualitySelectorDialog(
selectedPreset: VideoCompressionPreset,
onSubmit: (VideoCompressionPreset) -> Unit,
onDismiss: () -> Unit
) {
val videoPresets = VideoCompressionPreset.entries
var localSelectedPreset by remember { mutableStateOf(selectedPreset) }
ListDialog(
title = stringResource(CommonStrings.dialog_video_quality_selector_title),
subtitle = stringResource(CommonStrings.dialog_default_video_quality_selector_subtitle),
onSubmit = { onSubmit(localSelectedPreset) },
onDismissRequest = onDismiss,
applyPaddingToContents = false,
) {
for (preset in videoPresets) {
val isSelected = preset == localSelectedPreset
item(
key = preset,
contentType = preset,
) {
val title = when (preset) {
VideoCompressionPreset.LOW -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_low)
VideoCompressionPreset.STANDARD -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_standard)
VideoCompressionPreset.HIGH -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_high)
}
val subtitle = when (preset) {
VideoCompressionPreset.LOW -> stringResource(CommonStrings.common_video_quality_low_description)
VideoCompressionPreset.STANDARD -> stringResource(CommonStrings.common_video_quality_standard_description)
VideoCompressionPreset.HIGH -> stringResource(CommonStrings.common_video_quality_high_description)
}
ListItem(
headlineContent = {
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
)
},
supportingContent = {
Text(
text = subtitle,
style = ElementTheme.materialTypography.bodyMedium,
color = ElementTheme.colors.textSecondary,
)
},
leadingContent = ListItemContent.RadioButton(
selected = isSelected,
),
onClick = {
localSelectedPreset = preset
},
)
}
}
}
}
@Composable
private fun ModerationAndSafety(
state: AdvancedSettingsState,
@@ -202,3 +331,15 @@ private fun ContentToPreview(state: AdvancedSettingsState) {
onBackClick = { }
)
}
@Composable
@PreviewsDayNight
internal fun VideoQualitySelectorDialogPreview() {
ElementPreview {
VideoQualitySelectorDialog(
selectedPreset = VideoCompressionPreset.STANDARD,
onSubmit = { /* no-op */ },
onDismiss = { /* no-op */ }
)
}
}

View File

@@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
@@ -46,6 +47,7 @@ class EditUserProfilePresenter @AssistedInject constructor(
private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor,
private val temporaryUriDeleter: TemporaryUriDeleter,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<EditUserProfileState> {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
@@ -175,7 +177,7 @@ class EditUserProfilePresenter @AssistedInject constructor(
uri = avatarUri,
mimeType = MimeTypes.Jpeg,
deleteOriginal = false,
compressIfPossible = false,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
).getOrThrow()
matrixClient.uploadAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
} else {

View File

@@ -12,7 +12,10 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
@@ -34,13 +37,19 @@ class AdvancedSettingsPresenterTest {
with(awaitItem()) {
assertThat(isDeveloperModeEnabled).isFalse()
assertThat(isSharePresenceEnabled).isTrue()
assertThat(doesCompressMedia).isTrue()
assertThat(mediaOptimizationState).isNull()
assertThat(theme).isEqualTo(ThemeOption.System)
assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Uninitialized)
}
// After the initial state, we expect the media optimization state to be set
with(awaitItem()) {
assertThat(mediaOptimizationState).isInstanceOf(MediaOptimizationState.AllMedia::class.java)
assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue()
}
}
}
@@ -50,6 +59,9 @@ class AdvancedSettingsPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip until the initial data it loaded
skipItems(1)
with(awaitItem()) {
assertThat(isDeveloperModeEnabled).isFalse()
eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(true))
@@ -70,6 +82,9 @@ class AdvancedSettingsPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip until the initial data it loaded
skipItems(1)
with(awaitItem()) {
assertThat(isSharePresenceEnabled).isTrue()
eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(false))
@@ -90,16 +105,73 @@ class AdvancedSettingsPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip until the initial data it loaded
skipItems(1)
with(awaitItem()) {
assertThat(doesCompressMedia).isTrue()
assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue()
eventSink(AdvancedSettingsEvents.SetCompressMedia(false))
}
with(awaitItem()) {
assertThat(doesCompressMedia).isFalse()
assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isFalse()
eventSink(AdvancedSettingsEvents.SetCompressMedia(true))
}
with(awaitItem()) {
assertThat(doesCompressMedia).isTrue()
assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue()
}
}
}
@Test
fun `present - compress images off on`() = runTest {
val presenter = createAdvancedSettingsPresenter(
featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip until the initial data it loaded
skipItems(1)
with(awaitItem()) {
assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue()
eventSink(AdvancedSettingsEvents.SetCompressImages(false))
}
with(awaitItem()) {
assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isFalse()
eventSink(AdvancedSettingsEvents.SetCompressImages(true))
}
with(awaitItem()) {
assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue()
}
}
}
@Test
fun `present - video upload quality selector`() = runTest {
val presenter = createAdvancedSettingsPresenter(
featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip until the initial data it loaded
skipItems(1)
with(awaitItem()) {
assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.STANDARD)
eventSink(AdvancedSettingsEvents.SetVideoUploadQuality(VideoCompressionPreset.LOW))
}
with(awaitItem()) {
assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.LOW)
eventSink(AdvancedSettingsEvents.SetVideoUploadQuality(VideoCompressionPreset.HIGH))
}
with(awaitItem()) {
assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.HIGH)
}
}
}
@@ -110,6 +182,9 @@ class AdvancedSettingsPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip until the initial data it loaded
skipItems(1)
with(awaitItem()) {
assertThat(theme).isEqualTo(ThemeOption.System)
eventSink(AdvancedSettingsEvents.SetTheme(ThemeOption.Dark))
@@ -135,6 +210,9 @@ class AdvancedSettingsPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip until the initial data it loaded
skipItems(1)
with(awaitItem()) {
assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(true))
@@ -157,6 +235,9 @@ class AdvancedSettingsPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip until the initial data it loaded
skipItems(1)
with(awaitItem()) {
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
@@ -184,6 +265,9 @@ class AdvancedSettingsPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip until the initial data it loaded
skipItems(1)
with(awaitItem()) {
assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue()
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
@@ -201,6 +285,9 @@ class AdvancedSettingsPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Skip until the initial data it loaded
skipItems(1)
with(awaitItem()) {
assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Loading)
assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Success(Unit))
@@ -212,10 +299,12 @@ class AdvancedSettingsPresenterTest {
appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
) = AdvancedSettingsPresenter(
appPreferencesStore = appPreferencesStore,
sessionPreferencesStore = sessionPreferencesStore,
mediaPreviewConfigStateStore = mediaPreviewConfigStateStore,
featureFlagService = featureFlagService,
sessionCoroutineScope = this,
)
}

View File

@@ -115,7 +115,7 @@ class AdvancedSettingsViewTest {
val analyticsService = FakeAnalyticsService()
rule.setAdvancedSettingsView(
state = aAdvancedSettingsState(
doesCompressMedia = true,
mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true),
eventSink = eventsRecorder,
),
analyticsService = analyticsService

View File

@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
@@ -78,6 +79,7 @@ class EditUserProfilePresenterTest {
matrixUser: MatrixUser = aMatrixUser(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
): EditUserProfilePresenter {
return EditUserProfilePresenter(
matrixClient = matrixClient,
@@ -86,6 +88,7 @@ class EditUserProfilePresenterTest {
mediaPreProcessor = fakeMediaPreProcessor,
temporaryUriDeleter = temporaryUriDeleter,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}

View File

@@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.ui.room.avatarUrl
import io.element.android.libraries.matrix.ui.room.rawName
import io.element.android.libraries.matrix.ui.room.topic
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
@@ -49,6 +50,7 @@ class RoomDetailsEditPresenter @Inject constructor(
private val mediaPreProcessor: MediaPreProcessor,
private val temporaryUriDeleter: TemporaryUriDeleter,
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) : Presenter<RoomDetailsEditState> {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false
@@ -223,7 +225,7 @@ class RoomDetailsEditPresenter @Inject constructor(
uri = avatarUri,
mimeType = MimeTypes.Jpeg,
deleteOriginal = false,
compressIfPossible = false,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
).getOrThrow()
room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
} else {

View File

@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_RAW_NAME
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
@@ -77,6 +78,7 @@ class RoomDetailsEditPresenterTest {
room: JoinedRoom,
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
): RoomDetailsEditPresenter {
return RoomDetailsEditPresenter(
room = room,
@@ -84,6 +86,7 @@ class RoomDetailsEditPresenterTest {
mediaPreProcessor = fakeMediaPreProcessor,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
temporaryUriDeleter = temporaryUriDeleter,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}

View File

@@ -22,9 +22,9 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -37,8 +37,8 @@ class SharePresenter @AssistedInject constructor(
private val shareIntentHandler: ShareIntentHandler,
private val matrixClient: MatrixClient,
private val mediaPreProcessor: MediaPreProcessor,
private val sessionPreferencesStore: SessionPreferencesStore,
private val activeRoomsHolder: ActiveRoomsHolder,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) : Presenter<ShareState> {
@AssistedFactory
interface Factory {
@@ -88,13 +88,14 @@ class SharePresenter @AssistedInject constructor(
val mediaSender = MediaSender(
preProcessor = mediaPreProcessor,
room = room,
sessionPreferencesStore = sessionPreferencesStore,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
filesToShare
.map { fileToShare ->
val result = mediaSender.sendMedia(
uri = fileToShare.uri,
mimeType = fileToShare.mimeType,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
)
// If the coroutine was cancelled, destroy the room and rethrow the exception
val cancellationException = result.exceptionOrNull() as? CancellationException

View File

@@ -26,8 +26,8 @@ import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -166,6 +166,7 @@ class SharePresenterTest {
matrixClient: MatrixClient = FakeMatrixClient(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
): SharePresenter {
return SharePresenter(
intent = intent,
@@ -173,8 +174,8 @@ class SharePresenterTest {
shareIntentHandler = shareIntentHandler,
matrixClient = matrixClient,
mediaPreProcessor = mediaPreProcessor,
sessionPreferencesStore = InMemorySessionPreferencesStore(),
activeRoomsHolder = activeRoomsHolder,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.media
import android.util.Size
import kotlin.math.min
import kotlin.math.roundToInt
/**
* Helper class to calculate the resulting output size and optimal bitrate for video compression.
*/
class VideoCompressorHelper(
/**
* The maximum size (in pixels) for the output video.
* The output will maintain the aspect ratio of the input video.
*/
val maxSize: Int,
) {
/**
* Calculates the output size for video compression based on the input size and [maxSize].
*/
fun getOutputSize(inputSize: Size): Size {
val resultMajor = min(inputSize.major(), maxSize)
val aspectRatio = inputSize.major().toFloat() / inputSize.minor().toFloat()
return Size(resultMajor, (resultMajor / aspectRatio).roundToInt())
}
/**
* Calculates the optimal bitrate for video compression based on the input size and frame rate.
*/
fun calculateOptimalBitrate(inputSize: Size, frameRate: Int): Long {
val outputSize = getOutputSize(inputSize)
val pixelsPerFrame = outputSize.width * outputSize.height
// Apparently, 0.1 bits per pixel is a sweet spot for video compression
val bitsPerPixel = 0.1f
return (pixelsPerFrame * bitsPerPixel * frameRate).toLong() / 1000
}
}
internal fun Size.major(): Int = if (width > height) width else height
internal fun Size.minor(): Int = if (width < height) width else height

View File

@@ -184,4 +184,12 @@ enum class FeatureFlags(
// False so it's displayed in the developer options screen
isFinished = false,
),
SelectableMediaQuality(
key = "feature.selectable_media_quality",
title = "Select media quality per upload",
description = "You can select the media quality for each attachment you upload.",
defaultValue = { false },
// False so it's displayed in the developer options screen
isFinished = false,
),
}

View File

@@ -171,6 +171,11 @@ interface MatrixClient {
* Return true if Livekit Rtc is supported, i.e. if Element Call is available.
*/
suspend fun isLivekitRtcSupported(): Boolean
/**
* Returns the maximum file upload size allowed by the Matrix server.
*/
suspend fun getMaxFileUploadSize(): Result<Long>
}
/**

View File

@@ -692,6 +692,10 @@ class RustMatrixClient(
innerClient.isLivekitRtcSupported()
}
override suspend fun getMaxFileUploadSize(): Result<Long> = withContext(sessionDispatcher) {
runCatchingExceptions { innerClient.getMaxMediaUploadSize().toLong() }
}
private suspend fun File.getCacheSize(
includeCryptoDb: Boolean = false,
): Long = withContext(sessionDispatcher) {

View File

@@ -94,6 +94,7 @@ class FakeMatrixClient(
private val canReportRoomLambda: () -> Boolean = { false },
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
private val getMaxUploadSizeResult: () -> Result<Long> = { lambdaError() },
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
private set
@@ -343,4 +344,8 @@ class FakeMatrixClient(
override suspend fun isLivekitRtcSupported(): Boolean {
return isLivekitRtcSupportedLambda()
}
override suspend fun getMaxFileUploadSize(): Result<Long> {
return getMaxUploadSizeResult()
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaupload.api
import io.element.android.libraries.matrix.api.MatrixClient
import javax.inject.Inject
/**
* Provides the maximum upload size allowed by the Matrix server.
*/
class MaxUploadSizeProvider @Inject constructor(
private val matrixClient: MatrixClient,
) {
suspend fun getMaxUploadSize(): Result<Long> {
return matrixClient.getMaxFileUploadSize()
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaupload.api
import io.element.android.libraries.androidutils.media.VideoCompressorHelper
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
data class MediaOptimizationConfig(
val compressImages: Boolean,
val videoCompressionPreset: VideoCompressionPreset,
)
fun VideoCompressionPreset.compressorHelper(): VideoCompressorHelper = when (this) {
VideoCompressionPreset.STANDARD -> VideoCompressorHelper(1280)
VideoCompressionPreset.HIGH -> VideoCompressorHelper(1920)
VideoCompressionPreset.LOW -> VideoCompressorHelper(640)
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaupload.api
fun interface MediaOptimizationConfigProvider {
suspend fun get(): MediaOptimizationConfig
}

View File

@@ -19,7 +19,7 @@ interface MediaPreProcessor {
uri: Uri,
mimeType: String,
deleteOriginal: Boolean,
compressIfPossible: Boolean,
mediaOptimizationConfig: MediaOptimizationConfig,
): Result<MediaUploadInfo>
/**

View File

@@ -14,17 +14,15 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
class MediaSender @Inject constructor(
private val preProcessor: MediaPreProcessor,
private val room: JoinedRoom,
private val sessionPreferencesStore: SessionPreferencesStore,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) {
private val ongoingUploadJobs = ConcurrentHashMap<Job.Key, MediaUploadHandler>()
val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty()
@@ -32,14 +30,14 @@ class MediaSender @Inject constructor(
suspend fun preProcessMedia(
uri: Uri,
mimeType: String,
mediaOptimizationConfig: MediaOptimizationConfig,
): Result<MediaUploadInfo> {
val compressIfPossible = sessionPreferencesStore.doesCompressMedia().first()
return preProcessor
.process(
uri = uri,
mimeType = mimeType,
deleteOriginal = false,
compressIfPossible = compressIfPossible,
mediaOptimizationConfig = mediaOptimizationConfig,
)
}
@@ -67,14 +65,14 @@ class MediaSender @Inject constructor(
formattedCaption: String? = null,
progressCallback: ProgressCallback? = null,
inReplyToEventId: EventId? = null,
mediaOptimizationConfig: MediaOptimizationConfig,
): Result<Unit> {
val compressIfPossible = sessionPreferencesStore.doesCompressMedia().first()
return preProcessor
.process(
uri = uri,
mimeType = mimeType,
deleteOriginal = false,
compressIfPossible = compressIfPossible,
mediaOptimizationConfig = mediaOptimizationConfig,
)
.flatMapCatching { info ->
room.liveTimeline.sendMedia(
@@ -100,7 +98,7 @@ class MediaSender @Inject constructor(
uri = uri,
mimeType = mimeType,
deleteOriginal = true,
compressIfPossible = false,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
)
.flatMapCatching { info ->
val audioInfo = (info as MediaUploadInfo.Audio).audioInfo

View File

@@ -19,8 +19,7 @@ import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
@@ -34,6 +33,11 @@ import java.io.File
@RunWith(RobolectricTestRunner::class)
class MediaSenderTest {
private val mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
)
@Test
fun `given an attachment when sending it the preprocessor always runs`() = runTest {
val preProcessor = FakeMediaPreProcessor()
@@ -57,7 +61,7 @@ class MediaSenderTest {
)
val uri = Uri.parse("content://image.jpg")
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
assertThat(preProcessor.processCallCount).isEqualTo(1)
}
@@ -76,7 +80,7 @@ class MediaSenderTest {
val sender = createMediaSender(room = room)
val uri = Uri.parse("content://image.jpg")
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
}
@Test
@@ -87,7 +91,7 @@ class MediaSenderTest {
val sender = createMediaSender(preProcessor)
val uri = Uri.parse("content://image.jpg")
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
assertThat(result.exceptionOrNull()).isNotNull()
}
@@ -112,7 +116,7 @@ class MediaSenderTest {
)
val uri = Uri.parse("content://image.jpg")
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
assertThat(result.exceptionOrNull()).isNotNull()
}
@@ -132,7 +136,7 @@ class MediaSenderTest {
val sender = createMediaSender(room = room)
val sendJob = launch {
val uri = Uri.parse("content://image.jpg")
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
}
// Wait until several internal tasks run and the file is being uploaded
advanceTimeBy(3L)
@@ -154,10 +158,10 @@ class MediaSenderTest {
private fun createMediaSender(
preProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
room: JoinedRoom = FakeJoinedRoom(),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = MediaOptimizationConfigProvider { mediaOptimizationConfig },
) = MediaSender(
preProcessor = preProcessor,
room = room,
sessionPreferencesStore = sessionPreferencesStore,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}

View File

@@ -32,8 +32,10 @@ import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
@@ -76,7 +78,7 @@ class AndroidMediaPreProcessor @Inject constructor(
uri: Uri,
mimeType: String,
deleteOriginal: Boolean,
compressIfPossible: Boolean,
mediaOptimizationConfig: MediaOptimizationConfig,
): Result<MediaUploadInfo> = withContext(coroutineDispatchers.computation) {
runCatchingExceptions {
val result = when {
@@ -85,10 +87,10 @@ class AndroidMediaPreProcessor @Inject constructor(
processFile(uri, mimeType)
}
mimeType.isMimeTypeImage() -> {
val shouldBeCompressed = compressIfPossible && mimeType !in notCompressibleImageTypes
val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes
processImage(uri, mimeType, shouldBeCompressed)
}
mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, compressIfPossible)
mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, mediaOptimizationConfig.videoCompressionPreset)
mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType)
else -> processFile(uri, mimeType)
}
@@ -214,9 +216,9 @@ class AndroidMediaPreProcessor @Inject constructor(
}
}
private suspend fun processVideo(uri: Uri, mimeType: String?, shouldBeCompressed: Boolean): MediaUploadInfo {
private suspend fun processVideo(uri: Uri, mimeType: String?, videoCompressionPreset: VideoCompressionPreset): MediaUploadInfo {
val resultFile = runCatchingExceptions {
videoCompressor.compress(uri, shouldBeCompressed)
videoCompressor.compress(uri, videoCompressionPreset)
.onEach {
if (it is VideoTranscodingEvent.Progress) {
Timber.d("Video compression progress: ${it.value}%")

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaupload.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.coroutines.flow.first
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultMediaOptimizationConfigProvider @Inject constructor(
private val sessionPreferencesStore: SessionPreferencesStore,
) : MediaOptimizationConfigProvider {
override suspend fun get(): MediaOptimizationConfig = MediaOptimizationConfig(
compressImages = sessionPreferencesStore.doesOptimizeImages().first(),
videoCompressionPreset = sessionPreferencesStore.getVideoCompressionPreset().first(),
)
}

View File

@@ -11,10 +11,10 @@ import android.content.Context
import android.media.MediaCodecInfo
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.util.Size
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.Size
import androidx.media3.common.util.UnstableApi
import androidx.media3.effect.Presentation
import androidx.media3.transformer.Composition
@@ -31,6 +31,7 @@ import io.element.android.libraries.androidutils.file.createTmpFile
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
@@ -47,12 +48,12 @@ class VideoCompressor @Inject constructor(
@ApplicationContext private val context: Context,
) {
@OptIn(UnstableApi::class)
fun compress(uri: Uri, shouldBeCompressed: Boolean): Flow<VideoTranscodingEvent> = callbackFlow {
fun compress(uri: Uri, videoCompressionPreset: VideoCompressionPreset): Flow<VideoTranscodingEvent> = callbackFlow {
val metadata = getVideoMetadata(uri)
val videoCompressorConfig = VideoCompressorConfigFactory.create(
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
preset = videoCompressionPreset,
)
val tmpFile = context.createTmpFile(extension = "mp4")
@@ -60,7 +61,7 @@ class VideoCompressor @Inject constructor(
val width = metadata?.width ?: Int.MAX_VALUE
val height = metadata?.height ?: Int.MAX_VALUE
val videoResizeEffect = videoCompressorConfig.resizer?.let {
val videoResizeEffect = videoCompressorConfig.videoCompressorHelper?.let {
val outputSize = it.getOutputSize(Size(width, height))
if (metadata?.rotation == 90 || metadata?.rotation == 270) {
// If the video is rotated, we need to swap width and height

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