Don't compress images sent through the Files attachment picker (#6755)
* 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 <jorgem@element.io>
This commit is contained in:
parent
df11c32d87
commit
aec0f596f0
12 changed files with 357 additions and 30 deletions
|
|
@ -16,5 +16,12 @@ import kotlinx.parcelize.Parcelize
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface Attachment : Parcelable {
|
sealed interface Attachment : Parcelable {
|
||||||
@Parcelize
|
@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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import dev.zacsweers.metro.AssistedFactory
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.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.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.TemporaryUriDeleter
|
||||||
import io.element.android.libraries.androidutils.file.safeDelete
|
import io.element.android.libraries.androidutils.file.safeDelete
|
||||||
import io.element.android.libraries.androidutils.hash.hash
|
import io.element.android.libraries.androidutils.hash.hash
|
||||||
|
|
@ -61,6 +63,7 @@ class AttachmentsPreviewPresenter(
|
||||||
private val permalinkBuilder: PermalinkBuilder,
|
private val permalinkBuilder: PermalinkBuilder,
|
||||||
private val temporaryUriDeleter: TemporaryUriDeleter,
|
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||||
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
|
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
|
||||||
|
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
|
||||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||||
|
|
@ -96,7 +99,10 @@ class AttachmentsPreviewPresenter(
|
||||||
|
|
||||||
val mediaAttachment = attachment as Attachment.Media
|
val mediaAttachment = attachment as Attachment.Media
|
||||||
val mediaOptimizationSelectorPresenter = remember {
|
val mediaOptimizationSelectorPresenter = remember {
|
||||||
mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia)
|
mediaOptimizationSelectorPresenterFactory.create(
|
||||||
|
localMedia = mediaAttachment.localMedia,
|
||||||
|
sendAsFile = mediaAttachment.sendAsFile,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present())
|
val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present())
|
||||||
|
|
||||||
|
|
@ -104,14 +110,25 @@ class AttachmentsPreviewPresenter(
|
||||||
|
|
||||||
var displayFileTooLargeError by remember { mutableStateOf(false) }
|
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
|
// 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
|
// to prepare it for sending. This is done to avoid blocking the UI thread when the
|
||||||
// user clicks on the send button.
|
// user clicks on the send button.
|
||||||
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false) {
|
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false && preprocessMediaJob == null) {
|
||||||
preprocessMediaJob = preProcessAttachment(
|
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,
|
attachment = attachment,
|
||||||
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
|
mediaOptimizationConfig = config,
|
||||||
displayProgress = false,
|
displayProgress = false,
|
||||||
sendActionState = sendActionState,
|
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(
|
private fun CoroutineScope.preProcessAttachment(
|
||||||
attachment: Attachment,
|
attachment: Attachment,
|
||||||
mediaOptimizationConfig: MediaOptimizationConfig,
|
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,11 @@ import kotlin.math.roundToLong
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class DefaultMediaOptimizationSelectorPresenter(
|
class DefaultMediaOptimizationSelectorPresenter(
|
||||||
@Assisted private val localMedia: LocalMedia,
|
@Assisted private val localMedia: LocalMedia,
|
||||||
|
@Assisted private val sendAsFile: Boolean,
|
||||||
private val maxUploadSizeProvider: MaxUploadSizeProvider,
|
private val maxUploadSizeProvider: MaxUploadSizeProvider,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||||
|
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
|
||||||
mediaExtractorFactory: VideoMetadataExtractor.Factory,
|
mediaExtractorFactory: VideoMetadataExtractor.Factory,
|
||||||
) : MediaOptimizationSelectorPresenter {
|
) : MediaOptimizationSelectorPresenter {
|
||||||
@ContributesBinding(SessionScope::class)
|
@ContributesBinding(SessionScope::class)
|
||||||
|
|
@ -47,6 +49,7 @@ class DefaultMediaOptimizationSelectorPresenter(
|
||||||
interface Factory : MediaOptimizationSelectorPresenter.Factory {
|
interface Factory : MediaOptimizationSelectorPresenter.Factory {
|
||||||
override fun create(
|
override fun create(
|
||||||
localMedia: LocalMedia,
|
localMedia: LocalMedia,
|
||||||
|
sendAsFile: Boolean,
|
||||||
): DefaultMediaOptimizationSelectorPresenter
|
): DefaultMediaOptimizationSelectorPresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,7 +58,9 @@ class DefaultMediaOptimizationSelectorPresenter(
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): MediaOptimizationSelectorState {
|
override fun present(): MediaOptimizationSelectorState {
|
||||||
val displayMediaSelectorViews by produceState<Boolean?>(null) {
|
val displayMediaSelectorViews by produceState<Boolean?>(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) }
|
var displayVideoPresetSelectorDialog by remember { mutableStateOf(false) }
|
||||||
|
|
@ -123,12 +128,23 @@ class DefaultMediaOptimizationSelectorPresenter(
|
||||||
var selectedVideoOptimizationPreset by remember { mutableStateOf<AsyncData<VideoCompressionPreset>>(AsyncData.Loading()) }
|
var selectedVideoOptimizationPreset by remember { mutableStateOf<AsyncData<VideoCompressionPreset>>(AsyncData.Loading()) }
|
||||||
|
|
||||||
LaunchedEffect(videoSizeEstimations.dataOrNull()) {
|
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()
|
val mediaOptimizationConfig = mediaOptimizationConfigProvider.get()
|
||||||
selectedImageOptimization = AsyncData.Success(mediaOptimizationConfig.compressImages)
|
selectedImageOptimization = AsyncData.Success(mediaOptimizationConfig.compressImages)
|
||||||
// Find the best video preset based on the default preset and the video size estimations
|
// 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
|
// 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(
|
selectedVideoOptimizationPreset = videoCompressionPresetSelector.selectBestVideoPreset(
|
||||||
defaultVideoPreset = mediaOptimizationConfig.videoCompressionPreset,
|
expectedVideoPreset = mediaOptimizationConfig.videoCompressionPreset,
|
||||||
videoSizeEstimations = videoSizeEstimations,
|
videoSizeEstimations = videoSizeEstimations,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -176,20 +192,4 @@ class DefaultMediaOptimizationSelectorPresenter(
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ fun interface MediaOptimizationSelectorPresenter : Presenter<MediaOptimizationSe
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(
|
fun create(
|
||||||
localMedia: LocalMedia,
|
localMedia: LocalMedia,
|
||||||
|
sendAsFile: Boolean,
|
||||||
): MediaOptimizationSelectorPresenter
|
): MediaOptimizationSelectorPresenter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* 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 dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class VideoCompressionPresetSelector {
|
||||||
|
fun selectBestVideoPreset(
|
||||||
|
expectedVideoPreset: VideoCompressionPreset,
|
||||||
|
videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
|
||||||
|
): AsyncData<VideoCompressionPreset> {
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -179,7 +179,7 @@ class MessageComposerPresenter(
|
||||||
handlePickedMedia(uri, mimeType)
|
handlePickedMedia(uri, mimeType)
|
||||||
}
|
}
|
||||||
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { 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 ->
|
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
|
||||||
handlePickedMedia(uri, MimeTypes.Jpeg)
|
handlePickedMedia(uri, MimeTypes.Jpeg)
|
||||||
|
|
@ -605,6 +605,7 @@ class MessageComposerPresenter(
|
||||||
private fun handlePickedMedia(
|
private fun handlePickedMedia(
|
||||||
uri: Uri?,
|
uri: Uri?,
|
||||||
mimeType: String? = null,
|
mimeType: String? = null,
|
||||||
|
sendAsFile: Boolean = false,
|
||||||
) {
|
) {
|
||||||
uri ?: return
|
uri ?: return
|
||||||
val localMedia = localMediaFactory.createFromUri(
|
val localMedia = localMediaFactory.createFromUri(
|
||||||
|
|
@ -613,7 +614,7 @@ class MessageComposerPresenter(
|
||||||
name = null,
|
name = null,
|
||||||
formattedFileSize = null
|
formattedFileSize = null
|
||||||
)
|
)
|
||||||
val mediaAttachment = Attachment.Media(localMedia)
|
val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile)
|
||||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||||
navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId)
|
navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.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.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.mediaupload.test.FakeMediaPreProcessor
|
||||||
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
|
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.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.api.store.VideoCompressionPreset
|
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(
|
private fun TestScope.createAttachmentsPreviewPresenter(
|
||||||
localMedia: LocalMedia = aLocalMedia(
|
localMedia: LocalMedia = aLocalMedia(
|
||||||
uri = mockMediaUrl,
|
uri = mockMediaUrl,
|
||||||
),
|
),
|
||||||
|
sendAsFile: Boolean = false,
|
||||||
room: JoinedRoom = FakeJoinedRoom(),
|
room: JoinedRoom = FakeJoinedRoom(),
|
||||||
timelineMode: Timeline.Mode = Timeline.Mode.Live,
|
timelineMode: Timeline.Mode = Timeline.Mode.Live,
|
||||||
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
||||||
|
|
@ -573,9 +652,10 @@ class AttachmentsPreviewPresenterTest {
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||||
|
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
|
||||||
): AttachmentsPreviewPresenter {
|
): AttachmentsPreviewPresenter {
|
||||||
return AttachmentsPreviewPresenter(
|
return AttachmentsPreviewPresenter(
|
||||||
attachment = aMediaAttachment(localMedia),
|
attachment = aMediaAttachment(localMedia, sendAsFile = sendAsFile),
|
||||||
onDoneListener = onDoneListener,
|
onDoneListener = onDoneListener,
|
||||||
mediaSenderFactory = MediaSenderFactory { timelineMode ->
|
mediaSenderFactory = MediaSenderFactory { timelineMode ->
|
||||||
DefaultMediaSender(
|
DefaultMediaSender(
|
||||||
|
|
@ -592,6 +672,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
sessionCoroutineScope = this,
|
sessionCoroutineScope = this,
|
||||||
dispatchers = testCoroutineDispatchers(),
|
dispatchers = testCoroutineDispatchers(),
|
||||||
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
|
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
|
||||||
|
videoCompressionPresetSelector = videoCompressionPresetSelector,
|
||||||
timelineMode = timelineMode,
|
timelineMode = timelineMode,
|
||||||
inReplyToEventId = null,
|
inReplyToEventId = null,
|
||||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
private fun createDefaultMediaOptimizationSelectorPresenter(
|
||||||
localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
|
localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
|
||||||
maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) },
|
maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) },
|
||||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)),
|
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)),
|
||||||
mediaExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(),
|
mediaExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(),
|
||||||
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||||
|
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
|
||||||
|
sendAsFile: Boolean = false,
|
||||||
): DefaultMediaOptimizationSelectorPresenter {
|
): DefaultMediaOptimizationSelectorPresenter {
|
||||||
return DefaultMediaOptimizationSelectorPresenter(
|
return DefaultMediaOptimizationSelectorPresenter(
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
|
sendAsFile = sendAsFile,
|
||||||
maxUploadSizeProvider = maxUploadSizeProvider,
|
maxUploadSizeProvider = maxUploadSizeProvider,
|
||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
mediaExtractorFactory = mediaExtractorFactory,
|
mediaExtractorFactory = mediaExtractorFactory,
|
||||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||||
|
videoCompressionPresetSelector = videoCompressionPresetSelector,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
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,
|
localMedia = localMedia,
|
||||||
|
sendAsFile = sendAsFile,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ class FakeMediaOptimizationSelectorPresenterFactory(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) : MediaOptimizationSelectorPresenter.Factory {
|
) : MediaOptimizationSelectorPresenter.Factory {
|
||||||
override fun create(localMedia: LocalMedia): MediaOptimizationSelectorPresenter {
|
override fun create(localMedia: LocalMedia, sendAsFile: Boolean): MediaOptimizationSelectorPresenter {
|
||||||
return fakePresenter
|
return fakePresenter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ class FakeMediaPreProcessor(
|
||||||
var cleanUpCallCount = 0
|
var cleanUpCallCount = 0
|
||||||
private set
|
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<MediaUploadInfo> = Result.success(
|
private var result: Result<MediaUploadInfo> = Result.success(
|
||||||
MediaUploadInfo.AnyFile(
|
MediaUploadInfo.AnyFile(
|
||||||
File("test"),
|
File("test"),
|
||||||
|
|
@ -51,6 +55,7 @@ class FakeMediaPreProcessor(
|
||||||
): Result<MediaUploadInfo> = simulateLongTask {
|
): Result<MediaUploadInfo> = simulateLongTask {
|
||||||
processLatch?.await()
|
processLatch?.await()
|
||||||
processCallCount++
|
processCallCount++
|
||||||
|
lastMediaOptimizationConfig = mediaOptimizationConfig
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue