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

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

View file

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

View file

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

View file

@ -7,62 +7,37 @@
package io.element.android.libraries.mediaupload.impl
import android.util.Size
import androidx.annotation.OptIn
import androidx.media3.common.util.Size
import androidx.media3.common.util.UnstableApi
import androidx.media3.transformer.VideoEncoderSettings
import io.element.android.libraries.androidutils.media.VideoCompressorHelper
import io.element.android.libraries.mediaupload.api.compressorHelper
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlin.math.min
import kotlin.math.roundToInt
@OptIn(UnstableApi::class)
internal object VideoCompressorConfigFactory {
// Major dimension of 720p
private const val MAX_COMPRESSED_PIXEL_SIZE = 1280
// Major dimension of 1080p
private const val MAX_PIXEL_SIZE = 1920
private const val DEFAULT_FRAME_RATE = 30
fun create(
metadata: VideoFileMetadata?,
shouldBeCompressed: Boolean,
preset: VideoCompressionPreset,
): VideoCompressorConfig {
val width = metadata?.width?.takeIf { it >= 0 } ?: Int.MAX_VALUE
val height = metadata?.height?.takeIf { it >= 0 } ?: Int.MAX_VALUE
val originalBitrate = metadata?.bitrate?.takeIf { it >= 0 }
val originalFrameRate = metadata?.frameRate?.takeIf { it >= 0 } ?: DEFAULT_FRAME_RATE
// We only create a resizer if needed
val resizer = when {
shouldBeCompressed && (width > MAX_COMPRESSED_PIXEL_SIZE || height > MAX_COMPRESSED_PIXEL_SIZE) -> VideoResizer(MAX_COMPRESSED_PIXEL_SIZE)
width > MAX_PIXEL_SIZE || height > MAX_PIXEL_SIZE -> VideoResizer(MAX_PIXEL_SIZE)
else -> null
}
val resizer = preset.compressorHelper()
// If we are resizing, we also want to reduce the frame rate to the default value (30fps)
val newFrameRate = if (resizer is VideoResizer) {
min(originalFrameRate, DEFAULT_FRAME_RATE)
} else {
originalFrameRate
}
val newFrameRate = min(originalFrameRate, DEFAULT_FRAME_RATE)
// If we need to resize the video, we also want to recalculate the bitrate
val newBitrate = if (resizer is VideoResizer) {
val maxSize = resizer.getOutputSize(Size(width, height))
val pixelsPerFrame = maxSize.width * maxSize.height
val frameRate = newFrameRate
// Apparently, 0.1 bits per pixel is a sweet spot for video compression
val bitsPerPixel = 0.1f
(pixelsPerFrame * bitsPerPixel * frameRate).toLong()
} else {
originalBitrate
}
val newBitrate = resizer.calculateOptimalBitrate(Size(width, height), newFrameRate)
return VideoCompressorConfig(
resizer = resizer,
newBitRate = newBitrate?.toInt() ?: VideoEncoderSettings.NO_VALUE,
videoCompressorHelper = resizer,
newBitRate = newBitrate.toInt(),
newFrameRate = newFrameRate,
)
}
@ -70,28 +45,7 @@ internal object VideoCompressorConfigFactory {
@OptIn(UnstableApi::class)
internal data class VideoCompressorConfig(
val resizer: VideoResizer?,
val videoCompressorHelper: VideoCompressorHelper,
val newBitRate: Int,
val newFrameRate: Int,
)
@OptIn(UnstableApi::class)
internal class VideoResizer(
val maxSize: Int,
) {
fun getOutputSize(inputSize: Size): Size {
val resultMajor = min(inputSize.major(), maxSize)
val aspectRatio = inputSize.width.toFloat() / inputSize.height.toFloat()
return if (inputSize.width > inputSize.height) {
Size(resultMajor, (resultMajor / aspectRatio).roundToInt())
} else {
Size((resultMajor * aspectRatio).roundToInt(), resultMajor)
}
}
}
@OptIn(UnstableApi::class)
internal fun Size.major(): Int = if (width > height) width else height
@OptIn(UnstableApi::class)
internal fun Size.minor(): Int = if (width < height) width else height

View file

@ -20,8 +20,10 @@ 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.ThumbnailInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -41,7 +43,7 @@ import kotlin.time.Duration
class AndroidMediaPreProcessorTest {
private suspend fun TestScope.process(
asset: Asset,
compressIfPossible: Boolean,
mediaOptimizationConfig: MediaOptimizationConfig,
sdkIntVersion: Int = Build.VERSION_CODES.P,
deleteOriginal: Boolean = false,
): MediaUploadInfo {
@ -57,7 +59,7 @@ class AndroidMediaPreProcessorTest {
uri = file.toUri(),
mimeType = asset.mimeType,
deleteOriginal = deleteOriginal,
compressIfPossible = compressIfPossible,
mediaOptimizationConfig = mediaOptimizationConfig,
)
val data = result.getOrThrow()
assertThat(data.file.path).endsWith(asset.filename)
@ -70,7 +72,10 @@ class AndroidMediaPreProcessorTest {
fun `test processing png`() = runTest {
val mediaUploadInfo = process(
asset = assetImagePng,
compressIfPossible = true,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.thumbnailFile).isNotNull()
@ -91,7 +96,10 @@ class AndroidMediaPreProcessorTest {
fun `test processing png api Q`() = runTest {
val mediaUploadInfo = process(
asset = assetImagePng,
compressIfPossible = true,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
sdkIntVersion = Build.VERSION_CODES.Q,
)
val info = mediaUploadInfo as MediaUploadInfo.Image
@ -114,7 +122,10 @@ class AndroidMediaPreProcessorTest {
fun `test processing png no compression`() = runTest {
val mediaUploadInfo = process(
asset = assetImagePng,
compressIfPossible = false,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.thumbnailFile).isNotNull()
@ -136,7 +147,10 @@ class AndroidMediaPreProcessorTest {
fun `test processing png and delete`() = runTest {
val mediaUploadInfo = process(
asset = assetImagePng,
compressIfPossible = false,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
deleteOriginal = true,
)
val info = mediaUploadInfo as MediaUploadInfo.Image
@ -161,7 +175,10 @@ class AndroidMediaPreProcessorTest {
fun `test processing jpeg`() = runTest {
val mediaUploadInfo = process(
asset = assetImageJpeg,
compressIfPossible = true,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.thumbnailFile).isNotNull()
@ -182,7 +199,10 @@ class AndroidMediaPreProcessorTest {
fun `test processing jpeg api Q`() = runTest {
val mediaUploadInfo = process(
asset = assetImageJpeg,
compressIfPossible = true,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
sdkIntVersion = Build.VERSION_CODES.Q,
)
val info = mediaUploadInfo as MediaUploadInfo.Image
@ -205,7 +225,10 @@ class AndroidMediaPreProcessorTest {
fun `test processing jpeg no compression`() = runTest {
val mediaUploadInfo = process(
asset = assetImageJpeg,
compressIfPossible = false,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.thumbnailFile).isNotNull()
@ -227,7 +250,10 @@ class AndroidMediaPreProcessorTest {
fun `test processing jpeg and delete`() = runTest {
val mediaUploadInfo = process(
asset = assetImageJpeg,
compressIfPossible = false,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
deleteOriginal = true,
)
val info = mediaUploadInfo as MediaUploadInfo.Image
@ -252,7 +278,10 @@ class AndroidMediaPreProcessorTest {
fun `test processing gif`() = runTest {
val mediaUploadInfo = process(
asset = assetAnimatedGif,
compressIfPossible = true,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.thumbnailFile).isNotNull()
@ -273,7 +302,10 @@ class AndroidMediaPreProcessorTest {
fun `test processing file`() = runTest {
val mediaUploadInfo = process(
asset = assetText,
compressIfPossible = true,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
)
val info = mediaUploadInfo as MediaUploadInfo.AnyFile
assertThat(info.fileInfo).isEqualTo(
@ -291,7 +323,10 @@ class AndroidMediaPreProcessorTest {
fun `test processing video`() = runTest {
val mediaUploadInfo = process(
asset = assetVideo,
compressIfPossible = true,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
)
val info = mediaUploadInfo as MediaUploadInfo.Video
assertThat(info.thumbnailFile).isNotNull()
@ -315,7 +350,10 @@ class AndroidMediaPreProcessorTest {
fun `test processing video no compression`() = runTest {
val mediaUploadInfo = process(
asset = assetVideo,
compressIfPossible = false,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.HIGH,
),
)
val info = mediaUploadInfo as MediaUploadInfo.Video
// Computing thumbnailFile is failing with Robolectric
@ -341,7 +379,10 @@ class AndroidMediaPreProcessorTest {
fun `test processing audio`() = runTest {
val mediaUploadInfo = process(
asset = assetAudio,
compressIfPossible = true,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
)
val info = mediaUploadInfo as MediaUploadInfo.Audio
assertThat(info.audioInfo).isEqualTo(
@ -363,7 +404,10 @@ class AndroidMediaPreProcessorTest {
uri = file.toUri(),
mimeType = MimeTypes.PlainText,
deleteOriginal = false,
compressIfPossible = true,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
)
assertThat(result.isFailure).isTrue()
val failure = result.exceptionOrNull()

View file

@ -9,6 +9,7 @@ package io.element.android.libraries.mediaupload.impl
import androidx.media3.transformer.VideoEncoderSettings
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@ -20,16 +21,16 @@ class VideoCompressorConfigFactoryTest {
fun `if we don't have metadata the video will be resized`() {
// Given
val metadata = null
val shouldBeCompressed = false
val preset = VideoCompressionPreset.STANDARD
// When
val videoCompressorConfig = VideoCompressorConfigFactory.create(
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
preset = preset,
)
// Then
assertThat(videoCompressorConfig.resizer).isNotNull()
assertThat(videoCompressorConfig.videoCompressorHelper).isNotNull()
assertThat(videoCompressorConfig.newFrameRate).isEqualTo(30)
assertThat(videoCompressorConfig.newBitRate).isNotEqualTo(VideoEncoderSettings.NO_VALUE)
}
@ -38,71 +39,71 @@ class VideoCompressorConfigFactoryTest {
fun `if the video should be compressed and is larger than 720p it will be resized`() {
// Given
val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 1_000_000, frameRate = 50, rotation = 0)
val shouldBeCompressed = true
val preset = VideoCompressionPreset.STANDARD
// When
val videoCompressorConfig = VideoCompressorConfigFactory.create(
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
preset = preset,
)
// Then
assertIsResized(videoCompressorConfig)
assertIsResized(videoCompressorConfig, metadata.width)
}
@Test
fun `if the video should be compressed and is smaller or equal to 720p it will not be resized`() {
// Given
val metadata = VideoFileMetadata(width = 1280, height = 720, bitrate = 1_000_000, frameRate = 50, rotation = 0)
val shouldBeCompressed = true
val preset = VideoCompressionPreset.STANDARD
// When
val videoCompressorConfig = VideoCompressorConfigFactory.create(
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
preset = preset,
)
// Then
assertIsNotResized(videoCompressorConfig)
assertIsNotResized(videoCompressorConfig, 1280)
}
@Test
fun `if the video should not be compressed and is larger than 1080p it will be resized`() {
// Given
val metadata = VideoFileMetadata(width = 2560, height = 1440, bitrate = 1_000_000, frameRate = 50, rotation = 0)
val shouldBeCompressed = false
val preset = VideoCompressionPreset.HIGH
// When
val videoCompressorConfig = VideoCompressorConfigFactory.create(
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
preset = preset,
)
// Then
assertIsResized(videoCompressorConfig)
assertIsResized(videoCompressorConfig, metadata.width)
}
@Test
fun `if the video should not be compressed and is smaller or equal than 1080p it will not be resized`() {
// Given
val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 1_000_000, frameRate = 50, rotation = 0)
val shouldBeCompressed = false
val preset = VideoCompressionPreset.HIGH
// When
val videoCompressorConfig = VideoCompressorConfigFactory.create(
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
preset = preset,
)
// Then
assertIsNotResized(videoCompressorConfig)
assertIsNotResized(videoCompressorConfig, 1920)
}
private inline fun assertIsResized(videoCompressorConfig: VideoCompressorConfig) {
assertThat(videoCompressorConfig.resizer).isNotNull()
private inline fun assertIsResized(videoCompressorConfig: VideoCompressorConfig, referenceSize: Int) {
assertThat(videoCompressorConfig.videoCompressorHelper.maxSize).isNotEqualTo(referenceSize)
}
private inline fun assertIsNotResized(videoCompressorConfig: VideoCompressorConfig) {
assertThat(videoCompressorConfig.resizer).isNull()
private inline fun assertIsNotResized(videoCompressorConfig: VideoCompressorConfig, referenceSize: Int) {
assertThat(videoCompressorConfig.videoCompressorHelper.maxSize).isEqualTo(referenceSize)
}
}