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

@ -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 {