From a33d717aa0965e8ed9f1ff2c85f4998329914c7f Mon Sep 17 00:00:00 2001 From: cizra Date: Wed, 20 May 2026 15:19:08 +0000 Subject: [PATCH] Don't compress images sent through the Files attachment picker (#6755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Don't compress images sent through the Files attachment picker Images and videos picked through the "Attachment" picker are now uploaded without re-encoding, regardless of the "Optimize media quality" setting. The gallery and camera pickers keep the existing behaviour, matching what Element Web/Desktop and most other messengers do. Fixes #6365 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Make sure we select the right video preset for sending as file Wait for the video size estimations to be calculated before preprocessing the video file --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jorge Martín --- .../messages/impl/attachments/Attachment.kt | 9 +- .../preview/AttachmentsPreviewPresenter.kt | 49 +++++++++- ...faultMediaOptimizationSelectorPresenter.kt | 38 ++++---- .../MediaOptimizationSelectorPresenter.kt | 1 + .../video/VideoCompressionPresetSelector.kt | 31 +++++++ .../MessageComposerPresenter.kt | 5 +- .../AttachmentsPreviewPresenterTest.kt | 83 ++++++++++++++++- ...tMediaOptimizationSelectorPresenterTest.kt | 69 ++++++++++++++ .../VideoCompressionPresetSelectorTest.kt | 92 +++++++++++++++++++ .../impl/fixtures/MediaAttachmentFixtures.kt | 3 +- ...diaOptimizationSelectorPresenterFactory.kt | 2 +- .../mediaupload/test/FakeMediaPreProcessor.kt | 5 + 12 files changed, 357 insertions(+), 30 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/VideoCompressionPresetSelector.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/VideoCompressionPresetSelectorTest.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/Attachment.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/Attachment.kt index d989b34ab3..73fa55228d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/Attachment.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/Attachment.kt @@ -16,5 +16,12 @@ import kotlinx.parcelize.Parcelize @Immutable sealed interface Attachment : Parcelable { @Parcelize - data class Media(val localMedia: LocalMedia) : Attachment + data class Media( + val localMedia: LocalMedia, + // When true, the media was picked through the "Files" picker and should be + // uploaded without image recompression; videos still use the highest available + // / best-fit preset rather than an additional size-reduction optimization pass. + // See https://github.com/element-hq/element-x-android/issues/6365 + val sendAsFile: Boolean = false, + ) : Attachment } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index 8e92e53f6a..fc7f3034a6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -23,6 +23,8 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter +import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState +import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.hash.hash @@ -61,6 +63,7 @@ class AttachmentsPreviewPresenter( private val permalinkBuilder: PermalinkBuilder, private val temporaryUriDeleter: TemporaryUriDeleter, private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory, + private val videoCompressionPresetSelector: VideoCompressionPresetSelector, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val dispatchers: CoroutineDispatchers, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, @@ -96,7 +99,10 @@ class AttachmentsPreviewPresenter( val mediaAttachment = attachment as Attachment.Media val mediaOptimizationSelectorPresenter = remember { - mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia) + mediaOptimizationSelectorPresenterFactory.create( + localMedia = mediaAttachment.localMedia, + sendAsFile = mediaAttachment.sendAsFile, + ) } val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present()) @@ -104,14 +110,25 @@ class AttachmentsPreviewPresenter( var displayFileTooLargeError by remember { mutableStateOf(false) } - LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews) { + LaunchedEffect( + mediaOptimizationSelectorState.displayMediaSelectorViews, + mediaOptimizationSelectorState.videoSizeEstimations, + ) { // 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) { - preprocessMediaJob = preProcessAttachment( + if (mediaOptimizationSelectorState.displayMediaSelectorViews == false && preprocessMediaJob == null) { + if (mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() && mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() == null) { + Timber.d("Waiting for video size estimations to be able to select the best video compression preset before pre-processing the media") + return@LaunchedEffect + } + val config = getAutoPreprocessMediaOptimizationConfig( + mediaAttachment = mediaAttachment, + mediaOptimizationSelectorState = mediaOptimizationSelectorState, + ) ?: return@LaunchedEffect + preprocessMediaJob = coroutineScope.preProcessAttachment( attachment = attachment, - mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), + mediaOptimizationConfig = config, displayProgress = false, sendActionState = sendActionState, ) @@ -233,6 +250,28 @@ class AttachmentsPreviewPresenter( ) } + private suspend fun getAutoPreprocessMediaOptimizationConfig( + mediaAttachment: Attachment.Media, + mediaOptimizationSelectorState: MediaOptimizationSelectorState, + ): MediaOptimizationConfig? { + return if (mediaAttachment.sendAsFile) { + // If we're sending the media as a file, we can skip image compression and we should select the highest video compression preset that still fits + // the upload limit (if the estimations are available) + val videoCompressionPreset = videoCompressionPresetSelector.selectBestVideoPreset( + expectedVideoPreset = VideoCompressionPreset.HIGH, + videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations, + ).dataOrNull() ?: VideoCompressionPreset.HIGH + + MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = videoCompressionPreset, + ) + } else { + // Otherwise, we just rely on the user preferences for media optimization + mediaOptimizationConfigProvider.get() + } + } + private fun CoroutineScope.preProcessAttachment( attachment: Attachment, mediaOptimizationConfig: MediaOptimizationConfig, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt index c81c306f90..abc0264b2f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt @@ -37,9 +37,11 @@ import kotlin.math.roundToLong @AssistedInject class DefaultMediaOptimizationSelectorPresenter( @Assisted private val localMedia: LocalMedia, + @Assisted private val sendAsFile: Boolean, private val maxUploadSizeProvider: MaxUploadSizeProvider, private val featureFlagService: FeatureFlagService, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, + private val videoCompressionPresetSelector: VideoCompressionPresetSelector, mediaExtractorFactory: VideoMetadataExtractor.Factory, ) : MediaOptimizationSelectorPresenter { @ContributesBinding(SessionScope::class) @@ -47,6 +49,7 @@ class DefaultMediaOptimizationSelectorPresenter( interface Factory : MediaOptimizationSelectorPresenter.Factory { override fun create( localMedia: LocalMedia, + sendAsFile: Boolean, ): DefaultMediaOptimizationSelectorPresenter } @@ -55,7 +58,9 @@ class DefaultMediaOptimizationSelectorPresenter( @Composable override fun present(): MediaOptimizationSelectorState { val displayMediaSelectorViews by produceState(null) { - value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality) + // When sending as a raw file, never show the optimization selector: images skip + // recompression, while videos use the highest available best-fit preset. + value = !sendAsFile && featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality) } var displayVideoPresetSelectorDialog by remember { mutableStateOf(false) } @@ -123,12 +128,23 @@ class DefaultMediaOptimizationSelectorPresenter( var selectedVideoOptimizationPreset by remember { mutableStateOf>(AsyncData.Loading()) } LaunchedEffect(videoSizeEstimations.dataOrNull()) { + if (sendAsFile) { + // Send-as-file path: pin to no image compression, and pick the highest-quality + // video preset that still fits the upload limit (we have no true "do not re-encode + // video" path in the pre-processor right now). + selectedImageOptimization = AsyncData.Success(false) + selectedVideoOptimizationPreset = videoCompressionPresetSelector.selectBestVideoPreset( + expectedVideoPreset = VideoCompressionPreset.HIGH, + videoSizeEstimations = videoSizeEstimations, + ) + return@LaunchedEffect + } val mediaOptimizationConfig = mediaOptimizationConfigProvider.get() selectedImageOptimization = AsyncData.Success(mediaOptimizationConfig.compressImages) // 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 = mediaOptimizationConfig.videoCompressionPreset, + selectedVideoOptimizationPreset = videoCompressionPresetSelector.selectBestVideoPreset( + expectedVideoPreset = mediaOptimizationConfig.videoCompressionPreset, videoSizeEstimations = videoSizeEstimations, ) } @@ -176,20 +192,4 @@ class DefaultMediaOptimizationSelectorPresenter( eventSink = ::handleEvent, ) } - - private fun findBestVideoPreset( - defaultVideoPreset: VideoCompressionPreset, - videoSizeEstimations: AsyncData>, - ): AsyncData { - 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") - ) - } - } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorPresenter.kt index 80cdfd9467..f1e17ef0a6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorPresenter.kt @@ -15,6 +15,7 @@ fun interface MediaOptimizationSelectorPresenter : Presenter>, + ): AsyncData { + val estimations = videoSizeEstimations.dataOrNull() ?: return AsyncData.Loading() + val bestEstimation = estimations.find { it.preset.ordinal >= expectedVideoPreset.ordinal && it.canUpload }?.preset + return if (bestEstimation != null) { + AsyncData.Success(bestEstimation) + } else { + AsyncData.Failure( + IllegalStateException("No suitable video preset found for expected preset: $expectedVideoPreset") + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index b42cda7dfc..e226318345 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -179,7 +179,7 @@ class MessageComposerPresenter( handlePickedMedia(uri, mimeType) } val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType -> - handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream) + handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true) } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> handlePickedMedia(uri, MimeTypes.Jpeg) @@ -605,6 +605,7 @@ class MessageComposerPresenter( private fun handlePickedMedia( uri: Uri?, mimeType: String? = null, + sendAsFile: Boolean = false, ) { uri ?: return val localMedia = localMediaFactory.createFromUri( @@ -613,7 +614,7 @@ class MessageComposerPresenter( name = null, formattedFileSize = null ) - val mediaAttachment = Attachment.Media(localMedia) + val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile) val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index 384b78471d..9a9dc08834 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.attachments.preview.Attachments import io.element.android.features.messages.impl.attachments.preview.OnDoneListener import io.element.android.features.messages.impl.attachments.preview.SendActionState import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState +import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation import io.element.android.features.messages.impl.fixtures.aMediaAttachment import io.element.android.features.messages.test.attachments.video.FakeMediaOptimizationSelectorPresenterFactory @@ -45,6 +46,7 @@ import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfig 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.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 @@ -548,10 +550,87 @@ class AttachmentsPreviewPresenterTest { } } + @Test + fun `present - sendAsFile attachment is pre-processed without image compression`() = runTest { + // Even though the user has enabled "Optimize media quality" globally, picking the file + // through the Files picker (sendAsFile = true) must skip compression. Regression test + // for https://github.com/element-hq/element-x-android/issues/6365 + val mediaPreProcessor = FakeMediaPreProcessor() + val presenter = createAttachmentsPreviewPresenter( + localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()), + sendAsFile = true, + mediaPreProcessor = mediaPreProcessor, + // Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path. + displayMediaQualitySelectorViews = false, + mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider( + config = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ) + ), + ) + + presenter.test { + consumeItemsUntilPredicate { mediaPreProcessor.processCallCount > 0 } + assertThat(mediaPreProcessor.lastMediaOptimizationConfig).isEqualTo( + MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.HIGH, + ) + ) + } + } + + @Test + fun `present - sendAsFile video is pre-processed with best fitting preset`() = runTest { + val mediaPreProcessor = FakeMediaPreProcessor() + val presenter = createAttachmentsPreviewPresenter( + localMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()), + sendAsFile = true, + mediaPreProcessor = mediaPreProcessor, + // Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path. + displayMediaQualitySelectorViews = false, + mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory { + MediaOptimizationSelectorState( + maxUploadSize = AsyncData.Success(250_000_000L), + videoSizeEstimations = AsyncData.Success( + persistentListOf( + VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 513_216_000L, canUpload = false), + VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 228_096_000L, canUpload = true), + VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 57_024_000L, canUpload = true), + ) + ), + isImageOptimizationEnabled = false, + selectedVideoPreset = VideoCompressionPreset.STANDARD, + displayMediaSelectorViews = false, + displayVideoPresetSelectorDialog = false, + eventSink = {}, + ) + }, + mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider( + config = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.LOW, + ) + ), + ) + + presenter.test { + consumeItemsUntilPredicate { mediaPreProcessor.processCallCount > 0 } + assertThat(mediaPreProcessor.lastMediaOptimizationConfig).isEqualTo( + MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ) + ) + } + } + private fun TestScope.createAttachmentsPreviewPresenter( localMedia: LocalMedia = aLocalMedia( uri = mockMediaUrl, ), + sendAsFile: Boolean = false, room: JoinedRoom = FakeJoinedRoom(), timelineMode: Timeline.Mode = Timeline.Mode.Live, permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), @@ -573,9 +652,10 @@ class AttachmentsPreviewPresenterTest { } ), mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), + videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(), ): AttachmentsPreviewPresenter { return AttachmentsPreviewPresenter( - attachment = aMediaAttachment(localMedia), + attachment = aMediaAttachment(localMedia, sendAsFile = sendAsFile), onDoneListener = onDoneListener, mediaSenderFactory = MediaSenderFactory { timelineMode -> DefaultMediaSender( @@ -592,6 +672,7 @@ class AttachmentsPreviewPresenterTest { sessionCoroutineScope = this, dispatchers = testCoroutineDispatchers(), mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory, + videoCompressionPresetSelector = videoCompressionPresetSelector, timelineMode = timelineMode, inReplyToEventId = null, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt index 106fff7375..e06f4ad4cd 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt @@ -210,19 +210,88 @@ class DefaultMediaOptimizationSelectorPresenterTest { } } + @Test + fun `present - sendAsFile hides selector views and disables image compression for images`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter( + localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()), + // Even with the feature flag on, sendAsFile must hide the selector. + featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)), + // And it must override the user's "optimize images" preference. + mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), + sendAsFile = true, + ) + presenter.test { + // Initial loading state + skipItems(1) + awaitItem().run { + assertThat(displayMediaSelectorViews).isFalse() + assertThat(isImageOptimizationEnabled).isFalse() + } + } + } + + @Test + fun `present - sendAsFile picks HIGH video preset when the video fits the upload limit`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter( + // Plenty of room: even HIGH preset will fit. + maxUploadSizeProvider = MaxUploadSizeProvider { Result.success(Long.MAX_VALUE) }, + mediaExtractorFactory = FakeVideoMetadataExtractorFactory( + FakeVideoMetadataExtractor( + sizeResult = Result.success(Size(1920, 1080)), + duration = Result.success(10.minutes) + ) + ), + sendAsFile = true, + ) + presenter.test { + // Initial loading state, then the one with size estimations loaded. + skipItems(1) + awaitItem().run { + assertThat(displayMediaSelectorViews).isFalse() + assertThat(selectedVideoPreset).isEqualTo(VideoCompressionPreset.HIGH) + } + } + } + + @Test + fun `present - sendAsFile picks lower video preset when HIGH exceeds the upload limit`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter( + maxUploadSizeProvider = MaxUploadSizeProvider { Result.success(250_000_000L) }, + mediaExtractorFactory = FakeVideoMetadataExtractorFactory( + FakeVideoMetadataExtractor( + sizeResult = Result.success(Size(1920, 1080)), + duration = Result.success(10.minutes) + ) + ), + sendAsFile = true, + ) + presenter.test { + // Initial loading state, then the one with size estimations loaded. + skipItems(1) + awaitItem().run { + assertThat(displayMediaSelectorViews).isFalse() + assertThat(selectedVideoPreset).isEqualTo(VideoCompressionPreset.STANDARD) + } + } + } + private fun createDefaultMediaOptimizationSelectorPresenter( localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()), maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) }, featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)), mediaExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(), mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), + videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(), + sendAsFile: Boolean = false, ): DefaultMediaOptimizationSelectorPresenter { return DefaultMediaOptimizationSelectorPresenter( localMedia = localMedia, + sendAsFile = sendAsFile, maxUploadSizeProvider = maxUploadSizeProvider, featureFlagService = featureFlagService, mediaExtractorFactory = mediaExtractorFactory, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + videoCompressionPresetSelector = videoCompressionPresetSelector, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/VideoCompressionPresetSelectorTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/VideoCompressionPresetSelectorTest.kt new file mode 100644 index 0000000000..d3864794c2 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/VideoCompressionPresetSelectorTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.video + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test + +class VideoCompressionPresetSelectorTest { + private val selector = VideoCompressionPresetSelector() + + @Test + fun `selectBestVideoPreset - returns expected preset when it can upload`() { + val result = selector.selectBestVideoPreset( + expectedVideoPreset = VideoCompressionPreset.HIGH, + videoSizeEstimations = AsyncData.Success( + persistentListOf( + VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 100, canUpload = true), + VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 50, canUpload = true), + VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 25, canUpload = true), + ) + ) + ) + + assertThat(result.dataOrNull()).isEqualTo(VideoCompressionPreset.HIGH) + } + + @Test + fun `selectBestVideoPreset - falls back to the highest fitting preset`() { + val result = selector.selectBestVideoPreset( + expectedVideoPreset = VideoCompressionPreset.HIGH, + videoSizeEstimations = AsyncData.Success( + persistentListOf( + VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 100, canUpload = false), + VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 50, canUpload = true), + VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 25, canUpload = true), + ) + ) + ) + + assertThat(result.dataOrNull()).isEqualTo(VideoCompressionPreset.STANDARD) + } + + @Test + fun `selectBestVideoPreset - starts from the expected preset`() { + val result = selector.selectBestVideoPreset( + expectedVideoPreset = VideoCompressionPreset.STANDARD, + videoSizeEstimations = AsyncData.Success( + persistentListOf( + VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 100, canUpload = true), + VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 50, canUpload = true), + VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 25, canUpload = true), + ) + ) + ) + + assertThat(result.dataOrNull()).isEqualTo(VideoCompressionPreset.STANDARD) + } + + @Test + fun `selectBestVideoPreset - returns failure when no preset can upload`() { + val result = selector.selectBestVideoPreset( + expectedVideoPreset = VideoCompressionPreset.HIGH, + videoSizeEstimations = AsyncData.Success( + persistentListOf( + VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 100, canUpload = false), + VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 50, canUpload = false), + VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 25, canUpload = false), + ) + ) + ) + + assertThat(result).isInstanceOf(AsyncData.Failure::class.java) + } + + @Test + fun `selectBestVideoPreset - returns loading while estimations are missing`() { + val result = selector.selectBestVideoPreset( + expectedVideoPreset = VideoCompressionPreset.HIGH, + videoSizeEstimations = AsyncData.Loading(), + ) + + assertThat(result).isInstanceOf(AsyncData.Loading::class.java) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MediaAttachmentFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MediaAttachmentFixtures.kt index 77207d6b52..1dde33714f 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MediaAttachmentFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MediaAttachmentFixtures.kt @@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.fixtures import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.libraries.mediaviewer.api.local.LocalMedia -fun aMediaAttachment(localMedia: LocalMedia) = Attachment.Media( +fun aMediaAttachment(localMedia: LocalMedia, sendAsFile: Boolean = false) = Attachment.Media( localMedia = localMedia, + sendAsFile = sendAsFile, ) diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeMediaOptimizationSelectorPresenterFactory.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeMediaOptimizationSelectorPresenterFactory.kt index 02a6918ad8..fff3ede5d3 100644 --- a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeMediaOptimizationSelectorPresenterFactory.kt +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeMediaOptimizationSelectorPresenterFactory.kt @@ -26,7 +26,7 @@ class FakeMediaOptimizationSelectorPresenterFactory( ) } ) : MediaOptimizationSelectorPresenter.Factory { - override fun create(localMedia: LocalMedia): MediaOptimizationSelectorPresenter { + override fun create(localMedia: LocalMedia, sendAsFile: Boolean): MediaOptimizationSelectorPresenter { return fakePresenter } } diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt index c07ebb6ec9..3750060eef 100644 --- a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt @@ -31,6 +31,10 @@ class FakeMediaPreProcessor( var cleanUpCallCount = 0 private set + /** The [MediaOptimizationConfig] passed to the most recent [process] call, or `null` if it was never called. */ + var lastMediaOptimizationConfig: MediaOptimizationConfig? = null + private set + private var result: Result = Result.success( MediaUploadInfo.AnyFile( File("test"), @@ -51,6 +55,7 @@ class FakeMediaPreProcessor( ): Result = simulateLongTask { processLatch?.await() processCallCount++ + lastMediaOptimizationConfig = mediaOptimizationConfig result }