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

* Add `VideoCompressorPreset` enum

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

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

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

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

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

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

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

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

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

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

* Integrate the previous changes with the attachments preview screen

Add strings from localazy too.

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

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

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

* Update screenshots

---------

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -22,21 +22,26 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment 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.TemporaryUriDeleter
import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.firstInstanceOf import io.element.android.libraries.core.coroutine.firstInstanceOf
import io.element.android.libraries.core.extensions.runCatchingExceptions 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.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags 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.EventId
import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder 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.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.allFiles 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.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -54,6 +59,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
private val permalinkBuilder: PermalinkBuilder, private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter, private val temporaryUriDeleter: TemporaryUriDeleter,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
) : Presenter<AttachmentsPreviewState> { ) : Presenter<AttachmentsPreviewState> {
@ -89,21 +95,80 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
var useSendQueue by remember { mutableStateOf(false) } var useSendQueue by remember { mutableStateOf(false) }
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) } var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
LaunchedEffect(Unit) {
useSendQueue = featureFlagService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
preprocessMediaJob = preProcessAttachment( val mediaAttachment = attachment as Attachment.Media
attachment, val mediaOptimizationSelectorPresenter = remember {
sendActionState mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia)
)
} }
val mediaOptimizationSelectorState = mediaOptimizationSelectorPresenter.present()
val observableSendState = snapshotFlow { sendActionState.value } 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) { fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (attachmentsPreviewEvents) { when (attachmentsPreviewEvents) {
is AttachmentsPreviewEvents.SendAttachment -> { is AttachmentsPreviewEvents.SendAttachment -> {
ongoingSendAttachmentJob.value = coroutineScope.launch { 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 the processing was hidden before, make it visible now
if (sendActionState.value is SendActionState.Sending.Processing) { if (sendActionState.value is SendActionState.Sending.Processing) {
sendActionState.value = SendActionState.Sending.Processing(displayProgress = true) sendActionState.value = SendActionState.Sending.Processing(displayProgress = true)
@ -138,6 +203,8 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
} }
} }
AttachmentsPreviewEvents.CancelAndDismiss -> { AttachmentsPreviewEvents.CancelAndDismiss -> {
displayFileTooLargeError = false
// Cancel media preprocessing and sending // Cancel media preprocessing and sending
preprocessMediaJob?.cancel() preprocessMediaJob?.cancel()
// If we couldn't send the pre-processed media, remove it // If we couldn't send the pre-processed media, remove it
@ -173,18 +240,24 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
textEditorState = textEditorState, textEditorState = textEditorState,
allowCaption = allowCaption, allowCaption = allowCaption,
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning, showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
displayFileTooLargeError = displayFileTooLargeError,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.preProcessAttachment( private fun CoroutineScope.preProcessAttachment(
attachment: Attachment, attachment: Attachment,
mediaOptimizationConfig: MediaOptimizationConfig,
displayProgress: Boolean,
sendActionState: MutableState<SendActionState>, sendActionState: MutableState<SendActionState>,
) = launch(dispatchers.io) { ) = launch(dispatchers.io) {
when (attachment) { when (attachment) {
is Attachment.Media -> { is Attachment.Media -> {
preProcessMedia( preProcessMedia(
mediaAttachment = attachment, mediaAttachment = attachment,
mediaOptimizationConfig = mediaOptimizationConfig,
displayProgress = displayProgress,
sendActionState = sendActionState, sendActionState = sendActionState,
) )
} }
@ -193,12 +266,15 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
private suspend fun preProcessMedia( private suspend fun preProcessMedia(
mediaAttachment: Attachment.Media, mediaAttachment: Attachment.Media,
mediaOptimizationConfig: MediaOptimizationConfig,
displayProgress: Boolean,
sendActionState: MutableState<SendActionState>, sendActionState: MutableState<SendActionState>,
) { ) {
sendActionState.value = SendActionState.Sending.Processing(displayProgress = false) sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress)
mediaSender.preProcessMedia( mediaSender.preProcessMedia(
uri = mediaAttachment.localMedia.uri, uri = mediaAttachment.localMedia.uri,
mimeType = mediaAttachment.localMedia.info.mimeType, mimeType = mediaAttachment.localMedia.info.mimeType,
mediaOptimizationConfig = mediaOptimizationConfig,
).fold( ).fold(
onSuccess = { mediaUploadInfo -> onSuccess = { mediaUploadInfo ->
sendActionState.value = SendActionState.Sending.ReadyToUpload(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 androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment 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.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
@ -18,6 +19,8 @@ data class AttachmentsPreviewState(
val textEditorState: TextEditorState, val textEditorState: TextEditorState,
val allowCaption: Boolean, val allowCaption: Boolean,
val showCaptionCompatibilityWarning: Boolean, val showCaptionCompatibilityWarning: Boolean,
val mediaOptimizationSelectorState: MediaOptimizationSelectorState,
val displayFileTooLargeError: Boolean,
val eventSink: (AttachmentsPreviewEvents) -> Unit 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.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment 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.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaviewer.api.MediaInfo 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.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia 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.TextEditorState
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.io.File import java.io.File
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> { open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
@ -36,6 +43,21 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())), anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())),
anAttachmentsPreviewState(allowCaption = false), anAttachmentsPreviewState(allowCaption = false),
anAttachmentsPreviewState(showCaptionCompatibilityWarning = true), 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, sendActionState: SendActionState = SendActionState.Idle,
allowCaption: Boolean = true, allowCaption: Boolean = true,
showCaptionCompatibilityWarning: Boolean = true, showCaptionCompatibilityWarning: Boolean = true,
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
displayFileTooLargeError: Boolean = false,
) = AttachmentsPreviewState( ) = AttachmentsPreviewState(
attachment = Attachment.Media( attachment = Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo), localMedia = LocalMedia("file://path".toUri(), mediaInfo),
@ -53,6 +77,8 @@ fun anAttachmentsPreviewState(
textEditorState = textEditorState, textEditorState = textEditorState,
allowCaption = allowCaption, allowCaption = allowCaption,
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning, showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
displayFileTooLargeError = displayFileTooLargeError,
eventSink = {} eventSink = {}
) )
@ -72,3 +98,35 @@ fun aMediaUploadInfo(
), ),
thumbnailFile = thumbnailFilePath?.let { File(it) }, 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -21,31 +22,56 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError 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.ProgressDialog
import io.element.android.libraries.designsystem.components.ProgressDialogType import io.element.android.libraries.designsystem.components.ProgressDialogType
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.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.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.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.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.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables 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.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer 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.TextComposer
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.VoiceMessageState import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.ui.strings.CommonStrings 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 io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -109,7 +135,7 @@ private fun AttachmentSendStateView(
if (sendActionState.displayProgress) { if (sendActionState.displayProgress) {
ProgressDialog( ProgressDialog(
type = ProgressDialogType.Indeterminate, type = ProgressDialogType.Indeterminate,
text = stringResource(id = CommonStrings.common_sending), text = stringResource(CommonStrings.common_preparing),
showCancelButton = true, showCancelButton = true,
onDismissRequest = onDismissClick, 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( AttachmentsPreviewBottomActions(
state = state, state = state,
onSendClick = onSendClick, 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 @Composable
private fun AttachmentsPreviewBottomActions( private fun AttachmentsPreviewBottomActions(
state: AttachmentsPreviewState, 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 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.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.ui.strings.CommonStrings
fun sendAttachmentError( fun sendAttachmentError(
throwable: Throwable throwable: Throwable
): Int { ): Int {
return if (throwable is MediaPreProcessor.Failure) { return if (throwable is MediaPreProcessor.Failure) {
CommonStrings.screen_media_upload_preview_error_failed_processing R.string.screen_media_upload_preview_error_failed_processing
} else { } 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.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider 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.mediaupload.api.MediaSender
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsEvents
@ -120,6 +121,7 @@ class MessageComposerPresenter @AssistedInject constructor(
private val mentionSpanProvider: MentionSpanProvider, private val mentionSpanProvider: MentionSpanProvider,
private val pillificationHelper: TextPillificationHelper, private val pillificationHelper: TextPillificationHelper,
private val suggestionsProcessor: SuggestionsProcessor, private val suggestionsProcessor: SuggestionsProcessor,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -519,6 +521,7 @@ class MessageComposerPresenter @AssistedInject constructor(
uri = uri, uri = uri,
mimeType = mimeType, mimeType = mimeType,
progressCallback = null, progressCallback = null,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
).getOrThrow() ).getOrThrow()
} }
.onFailure { cause -> .onFailure { cause ->

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,6 +15,7 @@ import kotlin.time.Duration
data class TimelineItemVoiceContent( data class TimelineItemVoiceContent(
val eventId: EventId?, val eventId: EventId?,
override val filename: String, override val filename: String,
override val fileSize: Long?,
override val caption: String?, override val caption: String?,
override val formattedCaption: CharSequence?, override val formattedCaption: CharSequence?,
override val isEdited: Boolean, 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), waveform: List<Float> = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
) = TimelineItemVoiceContent( ) = TimelineItemVoiceContent(
eventId = eventId, eventId = eventId,
fileSize = 1024 * 1024,
filename = filename, filename = filename,
caption = caption, caption = caption,
formattedCaption = null, formattedCaption = null,

View file

@ -8,6 +8,8 @@
<string name="emoji_picker_category_people">"Усмешкі &amp; Удзельнікі"</string> <string name="emoji_picker_category_people">"Усмешкі &amp; Удзельнікі"</string>
<string name="emoji_picker_category_places">"Падарожжы &amp; Месцы"</string> <string name="emoji_picker_category_places">"Падарожжы &amp; Месцы"</string>
<string name="emoji_picker_category_symbols">"Сімвалы"</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">"Заблакіраваць карыстальніка"</string>
<string name="screen_report_content_block_user_hint">"Адзначце, ці хочаце вы схаваць усе бягучыя і будучыя паведамленні ад гэтага карыстальніка"</string> <string name="screen_report_content_block_user_hint">"Адзначце, ці хочаце вы схаваць усе бягучыя і будучыя паведамленні ад гэтага карыстальніка"</string>
<string name="screen_report_content_explanation">"Гэтае паведамленне будзе перададзена адміністратару вашага хатняга сервера. Яны не змогуць прачытаць зашыфраваныя паведамленні."</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_people">"Smajlíci a lidé"</string>
<string name="emoji_picker_category_places">"Cestování a místa"</string> <string name="emoji_picker_category_places">"Cestování a místa"</string>
<string name="emoji_picker_category_symbols">"Symboly"</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">"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_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> <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_people">"Wynebau Hapus a Phobl"</string>
<string name="emoji_picker_category_places">"Teithio a Llefydd"</string> <string name="emoji_picker_category_places">"Teithio a Llefydd"</string>
<string name="emoji_picker_category_symbols">"Symbolau"</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">"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_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> <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_people">"Smileys og mennesker"</string>
<string name="emoji_picker_category_places">"Rejser og steder"</string> <string name="emoji_picker_category_places">"Rejser og steder"</string>
<string name="emoji_picker_category_symbols">"Symboler"</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">"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_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> <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_people">"Smileys &amp; Menschen"</string>
<string name="emoji_picker_category_places">"Reisen &amp; Orte"</string> <string name="emoji_picker_category_places">"Reisen &amp; Orte"</string>
<string name="emoji_picker_category_symbols">"Symbole"</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">"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_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> <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_people">"Φατσούλες &amp; Άνθρωποι"</string>
<string name="emoji_picker_category_places">"Ταξίδια &amp; Μέρη"</string> <string name="emoji_picker_category_places">"Ταξίδια &amp; Μέρη"</string>
<string name="emoji_picker_category_symbols">"Σύμβολα"</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">"Αποκλεισμός χρήστη"</string>
<string name="screen_report_content_block_user_hint">"Επέλεξε εάν θες να αποκρύψεις όλα τα τρέχοντα και μελλοντικά μηνύματα από αυτόν τον χρήστη"</string> <string name="screen_report_content_block_user_hint">"Επέλεξε εάν θες να αποκρύψεις όλα τα τρέχοντα και μελλοντικά μηνύματα από αυτόν τον χρήστη"</string>
<string name="screen_report_content_explanation">"Αυτό το μήνυμα θα αναφερθεί στον διαχειριστή του οικιακού διακομιστή σας. Δεν θα μπορεί να διαβάσει κρυπτογραφημένα μηνύματα."</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_people">"Emojis y personas"</string>
<string name="emoji_picker_category_places">"Viajes y lugares"</string> <string name="emoji_picker_category_places">"Viajes y lugares"</string>
<string name="emoji_picker_category_symbols">"Símbolos"</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">"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_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> <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_people">"Emotikonid ja inimesed"</string>
<string name="emoji_picker_category_places">"Reisimine ja kohad"</string> <string name="emoji_picker_category_places">"Reisimine ja kohad"</string>
<string name="emoji_picker_category_symbols">"Sümbolid"</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">"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_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> <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_people">"Irribartxoak eta jendea"</string>
<string name="emoji_picker_category_places">"Bidaiak eta tokiak"</string> <string name="emoji_picker_category_places">"Bidaiak eta tokiak"</string>
<string name="emoji_picker_category_symbols">"Ikurrak"</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_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_explanation">"Mezua zure zerbitzariko administratzaileari jakinaraziko zaio. Ezingo dute zifratutako mezurik irakurri."</string>
<string name="screen_report_content_hint">"Edukia salatzeko arrazoia"</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_people">"شکلک‌ها و افراد"</string>
<string name="emoji_picker_category_places">"سفر و مکان‌ها"</string> <string name="emoji_picker_category_places">"سفر و مکان‌ها"</string>
<string name="emoji_picker_category_symbols">"نمادها"</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">"انسداد کاربر"</string>
<string name="screen_report_content_block_user_hint">"اگر می‌خواهید همه پیام‌های فعلی و آینده را از این کاربر را پنهان کنید، علامت بزنید"</string> <string name="screen_report_content_block_user_hint">"اگر می‌خواهید همه پیام‌های فعلی و آینده را از این کاربر را پنهان کنید، علامت بزنید"</string>
<string name="screen_report_content_explanation">"این پیام به مدیر کارساز خانگی شما گزارش خواهد شد. آنها قادر به خواندن پیام های رمزگذاری شده نخواهند بود."</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_people">"Hymiöt ja ihmiset"</string>
<string name="emoji_picker_category_places">"Matkustaminen ja paikat"</string> <string name="emoji_picker_category_places">"Matkustaminen ja paikat"</string>
<string name="emoji_picker_category_symbols">"Symbolit"</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">"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_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> <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_people">"Émoticônes et personnes"</string>
<string name="emoji_picker_category_places">"Voyages &amp; lieux"</string> <string name="emoji_picker_category_places">"Voyages &amp; lieux"</string>
<string name="emoji_picker_category_symbols">"Symboles"</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">"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_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> <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_people">"Mosolyok és emberek"</string>
<string name="emoji_picker_category_places">"Utazás és helyek"</string> <string name="emoji_picker_category_places">"Utazás és helyek"</string>
<string name="emoji_picker_category_symbols">"Szimbólumok"</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">"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_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> <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_people">"Senyuman &amp; Orang"</string>
<string name="emoji_picker_category_places">"Wisata &amp; Tempat"</string> <string name="emoji_picker_category_places">"Wisata &amp; Tempat"</string>
<string name="emoji_picker_category_symbols">"Simbol"</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">"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_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> <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_people">"Faccine &amp; Persone"</string>
<string name="emoji_picker_category_places">"Viaggi &amp; Luoghi"</string> <string name="emoji_picker_category_places">"Viaggi &amp; Luoghi"</string>
<string name="emoji_picker_category_symbols">"Simboli"</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">"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_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> <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_people">"ღიმილები &amp; ხალხი"</string>
<string name="emoji_picker_category_places">"მოგზაურობა და ადგილები"</string> <string name="emoji_picker_category_places">"მოგზაურობა და ადგილები"</string>
<string name="emoji_picker_category_symbols">"სიმბოლოები"</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">"მომხმარებლის დაბლოკვა"</string>
<string name="screen_report_content_block_user_hint">"შეამოწმეთ, გსურთ თუ არა ამ მომხმარებლის ყველა მიმდინარე და მომავალი შეტყობინების დამალვა"</string> <string name="screen_report_content_block_user_hint">"შეამოწმეთ, გსურთ თუ არა ამ მომხმარებლის ყველა მიმდინარე და მომავალი შეტყობინების დამალვა"</string>
<string name="screen_report_content_explanation">"ეს შეტყობინება გაგზავნილი იქნება თქვენი სახლის სერვერის ადმინისტრატორისადმი. მას არ ექნება დაშიფვრული შეტყობინებების წაკითხვის შესაძლებლობა."</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_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_places">"Kelionės ir Vietovės"</string>
<string name="emoji_picker_category_symbols">"Simboliai"</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">"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_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> <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_people">"Smilefjes og mennesker"</string>
<string name="emoji_picker_category_places">"Reising og steder"</string> <string name="emoji_picker_category_places">"Reising og steder"</string>
<string name="emoji_picker_category_symbols">"Symboler"</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">"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_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> <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_people">"Smileys &amp; Mensen"</string>
<string name="emoji_picker_category_places">"Reizen &amp; Locaties"</string> <string name="emoji_picker_category_places">"Reizen &amp; Locaties"</string>
<string name="emoji_picker_category_symbols">"Symbolen"</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">"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_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> <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_people">"Buźki i osoby"</string>
<string name="emoji_picker_category_places">"Podróż i miejsca"</string> <string name="emoji_picker_category_places">"Podróż i miejsca"</string>
<string name="emoji_picker_category_symbols">"Symbole"</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">"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_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> <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_people">"Sorrisos &amp; Pessoas"</string>
<string name="emoji_picker_category_places">"Viagens &amp; Lugares"</string> <string name="emoji_picker_category_places">"Viagens &amp; Lugares"</string>
<string name="emoji_picker_category_symbols">"Símbolos"</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">"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_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> <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_people">"Caras e Pessoas"</string>
<string name="emoji_picker_category_places">"Viagens e Lugares"</string> <string name="emoji_picker_category_places">"Viagens e Lugares"</string>
<string name="emoji_picker_category_symbols">"Símbolos"</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">"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_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> <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_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_places">"Călătorii &amp; Locuri"</string>
<string name="emoji_picker_category_symbols">"Simboluri"</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">"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_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> <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_people">"Улыбки и люди"</string>
<string name="emoji_picker_category_places">"Путешествия и места"</string> <string name="emoji_picker_category_places">"Путешествия и места"</string>
<string name="emoji_picker_category_symbols">"Символы"</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">"Заблокировать пользователя"</string>
<string name="screen_report_content_block_user_hint">"Отметьте, хотите ли вы скрыть все текущие и будущие сообщения от этого пользователя"</string> <string name="screen_report_content_block_user_hint">"Отметьте, хотите ли вы скрыть все текущие и будущие сообщения от этого пользователя"</string>
<string name="screen_report_content_explanation">"Это сообщение будет передано администратору вашего домашнего сервера. Они не смогут прочитать зашифрованные сообщения."</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_people">"Smajlíky a ľudia"</string>
<string name="emoji_picker_category_places">"Cestovanie a miesta"</string> <string name="emoji_picker_category_places">"Cestovanie a miesta"</string>
<string name="emoji_picker_category_symbols">"Symboly"</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">"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_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> <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_people">"Smileys &amp; personer"</string>
<string name="emoji_picker_category_places">"Resor &amp; platser"</string> <string name="emoji_picker_category_places">"Resor &amp; platser"</string>
<string name="emoji_picker_category_symbols">"Symboler"</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">"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_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> <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_people">"İfadeler ve İnsanlar"</string>
<string name="emoji_picker_category_places">"Seyahat ve Yerler"</string> <string name="emoji_picker_category_places">"Seyahat ve Yerler"</string>
<string name="emoji_picker_category_symbols">"Simgeler"</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">"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_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> <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_people">"Смайлики та люди"</string>
<string name="emoji_picker_category_places">"Подорожі та місця"</string> <string name="emoji_picker_category_places">"Подорожі та місця"</string>
<string name="emoji_picker_category_symbols">"Символи"</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">"Заблокувати користувача"</string>
<string name="screen_report_content_block_user_hint">"Перевірте, чи хочете ви приховати всі поточні та майбутні повідомлення від цього користувача"</string> <string name="screen_report_content_block_user_hint">"Перевірте, чи хочете ви приховати всі поточні та майбутні повідомлення від цього користувача"</string>
<string name="screen_report_content_explanation">"Це повідомлення буде надіслано адміністраторам вашого домашнього сервера. Вони не зможуть прочитати зашифровані повідомлення."</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_people">"مسکراہٹیں و لوگ"</string>
<string name="emoji_picker_category_places">"سفر و مقامات"</string> <string name="emoji_picker_category_places">"سفر و مقامات"</string>
<string name="emoji_picker_category_symbols">"علامتیں"</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">"صارف کو مسدود کریں"</string>
<string name="screen_report_content_block_user_hint">"پڑتال کریں کہ کیا آپ اس صارف سے تمام موجودہ اور مستقبلی پیغامات چھپانا چاہتے ہیں۔"</string> <string name="screen_report_content_block_user_hint">"پڑتال کریں کہ کیا آپ اس صارف سے تمام موجودہ اور مستقبلی پیغامات چھپانا چاہتے ہیں۔"</string>
<string name="screen_report_content_explanation">"اس پیغام کی اطلاع آپکے منزلی خادم کے منتظم کو دی جائیگی۔ وہ کوئی مرموزکردہ پیغامات نہیں پڑھ سکیں گے۔"</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_people">"Smayllar va odamlar"</string>
<string name="emoji_picker_category_places">"Sayohat va Joylar"</string> <string name="emoji_picker_category_places">"Sayohat va Joylar"</string>
<string name="emoji_picker_category_symbols">"Belgilar"</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">"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_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> <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_people">"表情與人物"</string>
<string name="emoji_picker_category_places">"旅行與景點"</string> <string name="emoji_picker_category_places">"旅行與景點"</string>
<string name="emoji_picker_category_symbols">"標誌"</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">"封鎖使用者"</string>
<string name="screen_report_content_block_user_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</string> <string name="screen_report_content_block_user_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</string>
<string name="screen_report_content_explanation">"此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。"</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_people">"表情和人物"</string>
<string name="emoji_picker_category_places">"旅行和地点"</string> <string name="emoji_picker_category_places">"旅行和地点"</string>
<string name="emoji_picker_category_symbols">"符号"</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">"封禁用户"</string>
<string name="screen_report_content_block_user_hint">"请确认是否要隐藏该用户当前和未来的所有信息"</string> <string name="screen_report_content_block_user_hint">"请确认是否要隐藏该用户当前和未来的所有信息"</string>
<string name="screen_report_content_explanation">"此消息将举报给您的服务器管理员。他们无法读取任何加密消息。"</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_people">"Smileys &amp; People"</string>
<string name="emoji_picker_category_places">"Travel &amp; Places"</string> <string name="emoji_picker_category_places">"Travel &amp; Places"</string>
<string name="emoji_picker_category_symbols">"Symbols"</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">"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_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> <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( val mediaMessage = aMessageEvent(
content = TimelineItemImageContent( content = TimelineItemImageContent(
filename = "image.jpg", filename = "image.jpg",
fileSize = 4 * 1024 * 1024L,
caption = null, caption = null,
formattedCaption = null, formattedCaption = null,
isEdited = false, isEdited = false,
@ -365,6 +366,7 @@ class MessagesPresenterTest {
val mediaMessage = aMessageEvent( val mediaMessage = aMessageEvent(
content = TimelineItemVideoContent( content = TimelineItemVideoContent(
filename = "video.mp4", filename = "video.mp4",
fileSize = 50 * 1024 * 1024L,
caption = null, caption = null,
formattedCaption = null, formattedCaption = null,
isEdited = false, isEdited = false,
@ -406,6 +408,7 @@ class MessagesPresenterTest {
val mediaMessage = aMessageEvent( val mediaMessage = aMessageEvent(
content = TimelineItemFileContent( content = TimelineItemFileContent(
filename = "file.pdf", filename = "file.pdf",
fileSize = 10 * 1024 * 1024L,
caption = null, caption = null,
isEdited = false, isEdited = false,
formattedCaption = null, 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.AttachmentsPreviewPresenter
import io.element.android.features.messages.impl.attachments.preview.OnDoneListener 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.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.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.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService 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.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline 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.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor 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.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia 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.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem 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.fake.FakeTemporaryUriDeleter
import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaError 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.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -64,6 +73,7 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import java.io.File import java.io.File
@Suppress("LargeClass")
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class AttachmentsPreviewPresenterTest { class AttachmentsPreviewPresenterTest {
@get:Rule @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( private fun TestScope.createAttachmentsPreviewPresenter(
localMedia: LocalMedia = aLocalMedia( localMedia: LocalMedia = aLocalMedia(
uri = mockMediaUrl, uri = mockMediaUrl,
@ -560,11 +675,27 @@ class AttachmentsPreviewPresenterTest {
mediaUploadOnSendQueueEnabled: Boolean = true, mediaUploadOnSendQueueEnabled: Boolean = true,
allowCaption: Boolean = true, allowCaption: Boolean = true,
showCaptionCompatibilityWarning: 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 { ): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter( return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia), attachment = aMediaAttachment(localMedia),
onDoneListener = onDoneListener, onDoneListener = onDoneListener,
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()), mediaSender = MediaSender(mediaPreProcessor, room, {
MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD)
}),
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
temporaryUriDeleter = temporaryUriDeleter, temporaryUriDeleter = temporaryUriDeleter,
featureFlagService = FakeFeatureFlagService( featureFlagService = FakeFeatureFlagService(
@ -576,6 +707,7 @@ class AttachmentsPreviewPresenterTest {
), ),
sessionCoroutineScope = this, sessionCoroutineScope = this,
dispatchers = testCoroutineDispatchers(), 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.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider 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.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo 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.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory 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.SessionPreferencesStore
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
@ -1542,6 +1545,7 @@ class MessageComposerPresenterTest {
textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(), textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(),
isRichTextEditorEnabled: Boolean = true, isRichTextEditorEnabled: Boolean = true,
draftService: ComposerDraftService = FakeComposerDraftService(), draftService: ComposerDraftService = FakeComposerDraftService(),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
) = MessageComposerPresenter( ) = MessageComposerPresenter(
navigator = navigator, navigator = navigator,
sessionCoroutineScope = this, sessionCoroutineScope = this,
@ -1550,7 +1554,11 @@ class MessageComposerPresenterTest {
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
localMediaFactory = localMediaFactory, localMediaFactory = localMediaFactory,
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()), mediaSender = MediaSender(
preProcessor = mediaPreProcessor,
room = room,
mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) }
),
snackbarDispatcher = snackbarDispatcher, snackbarDispatcher = snackbarDispatcher,
analyticsService = analyticsService, analyticsService = analyticsService,
locationService = locationService, locationService = locationService,
@ -1565,6 +1573,7 @@ class MessageComposerPresenterTest {
mentionSpanProvider = mentionSpanProvider, mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper, pillificationHelper = textPillificationHelper,
suggestionsProcessor = SuggestionsProcessor(), suggestionsProcessor = SuggestionsProcessor(),
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
).apply { ).apply {
isTesting = true isTesting = true
showTextFormatting = isRichTextEditorEnabled showTextFormatting = isRichTextEditorEnabled

View file

@ -231,6 +231,7 @@ class TimelineItemContentMessageFactoryTest {
) )
val expected = TimelineItemVideoContent( val expected = TimelineItemVideoContent(
filename = "filename", filename = "filename",
fileSize = 0L,
caption = null, caption = null,
formattedCaption = null, formattedCaption = null,
isEdited = false, isEdited = false,
@ -283,6 +284,7 @@ class TimelineItemContentMessageFactoryTest {
) )
val expected = TimelineItemVideoContent( val expected = TimelineItemVideoContent(
filename = "body.mp4", filename = "body.mp4",
fileSize = 555L,
caption = "body.mp4 caption", caption = "body.mp4 caption",
formattedCaption = SpannedString("formatted"), formattedCaption = SpannedString("formatted"),
isEdited = true, isEdited = true,
@ -312,6 +314,7 @@ class TimelineItemContentMessageFactoryTest {
) )
val expected = TimelineItemAudioContent( val expected = TimelineItemAudioContent(
filename = "filename", filename = "filename",
fileSize = 0L,
caption = null, caption = null,
formattedCaption = null, formattedCaption = null,
isEdited = false, isEdited = false,
@ -347,6 +350,7 @@ class TimelineItemContentMessageFactoryTest {
) )
val expected = TimelineItemAudioContent( val expected = TimelineItemAudioContent(
filename = "body.mp3", filename = "body.mp3",
fileSize = 123L,
caption = null, caption = null,
formattedCaption = null, formattedCaption = null,
isEdited = true, isEdited = true,
@ -369,6 +373,7 @@ class TimelineItemContentMessageFactoryTest {
) )
val expected = TimelineItemVoiceContent( val expected = TimelineItemVoiceContent(
filename = "filename", filename = "filename",
fileSize = 0L,
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
caption = null, caption = null,
formattedCaption = null, formattedCaption = null,
@ -411,6 +416,7 @@ class TimelineItemContentMessageFactoryTest {
val expected = TimelineItemVoiceContent( val expected = TimelineItemVoiceContent(
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
filename = "body.ogg", filename = "body.ogg",
fileSize = 123L,
caption = null, caption = null,
formattedCaption = null, formattedCaption = null,
isEdited = true, isEdited = true,
@ -440,6 +446,7 @@ class TimelineItemContentMessageFactoryTest {
) )
val expected = TimelineItemAudioContent( val expected = TimelineItemAudioContent(
filename = "filename", filename = "filename",
fileSize = 0L,
caption = null, caption = null,
formattedCaption = null, formattedCaption = null,
isEdited = false, isEdited = false,
@ -462,6 +469,7 @@ class TimelineItemContentMessageFactoryTest {
) )
val expected = TimelineItemImageContent( val expected = TimelineItemImageContent(
filename = "filename", filename = "filename",
fileSize = 0L,
caption = "body", caption = "body",
formattedCaption = null, formattedCaption = null,
isEdited = false, isEdited = false,
@ -492,6 +500,7 @@ class TimelineItemContentMessageFactoryTest {
) )
val expected = TimelineItemStickerContent( val expected = TimelineItemStickerContent(
filename = "filename", filename = "filename",
fileSize = 8_192L,
caption = null, caption = null,
formattedCaption = null, formattedCaption = null,
isEdited = false, isEdited = false,
@ -540,6 +549,7 @@ class TimelineItemContentMessageFactoryTest {
) )
val expected = TimelineItemImageContent( val expected = TimelineItemImageContent(
filename = "body.jpg", filename = "body.jpg",
fileSize = 888L,
caption = "body.jpg caption", caption = "body.jpg caption",
formattedCaption = SpannedString("formatted"), formattedCaption = SpannedString("formatted"),
isEdited = true, isEdited = true,
@ -568,6 +578,7 @@ class TimelineItemContentMessageFactoryTest {
) )
val expected = TimelineItemFileContent( val expected = TimelineItemFileContent(
filename = "filename", filename = "filename",
fileSize = 0L,
caption = null, caption = null,
formattedCaption = null, formattedCaption = null,
isEdited = false, isEdited = false,
@ -609,6 +620,7 @@ class TimelineItemContentMessageFactoryTest {
) )
val expected = TimelineItemFileContent( val expected = TimelineItemFileContent(
filename = "body.pdf", filename = "body.pdf",
fileSize = 123L,
caption = null, caption = null,
formattedCaption = null, formattedCaption = null,
isEdited = true, 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.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer 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.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.aPermissionsState import io.element.android.libraries.permissions.api.aPermissionsState
import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory 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.MessageComposerMode
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
@ -72,7 +73,11 @@ class VoiceMessageComposerPresenterTest {
}, },
) )
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() } 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() private val messageComposerContext = FakeMessageComposerContext()
companion object { companion object {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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