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:
parent
ffe183c952
commit
a170d80cb3
174 changed files with 2152 additions and 340 deletions
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaupload.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Provides the maximum upload size allowed by the Matrix server.
|
||||
*/
|
||||
class MaxUploadSizeProvider @Inject constructor(
|
||||
private val matrixClient: MatrixClient,
|
||||
) {
|
||||
suspend fun getMaxUploadSize(): Result<Long> {
|
||||
return matrixClient.getMaxFileUploadSize()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaupload.api
|
||||
|
||||
import io.element.android.libraries.androidutils.media.VideoCompressorHelper
|
||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
|
||||
data class MediaOptimizationConfig(
|
||||
val compressImages: Boolean,
|
||||
val videoCompressionPreset: VideoCompressionPreset,
|
||||
)
|
||||
|
||||
fun VideoCompressionPreset.compressorHelper(): VideoCompressorHelper = when (this) {
|
||||
VideoCompressionPreset.STANDARD -> VideoCompressorHelper(1280)
|
||||
VideoCompressionPreset.HIGH -> VideoCompressorHelper(1920)
|
||||
VideoCompressionPreset.LOW -> VideoCompressorHelper(640)
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaupload.api
|
||||
|
||||
fun interface MediaOptimizationConfigProvider {
|
||||
suspend fun get(): MediaOptimizationConfig
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ interface MediaPreProcessor {
|
|||
uri: Uri,
|
||||
mimeType: String,
|
||||
deleteOriginal: Boolean,
|
||||
compressIfPossible: Boolean,
|
||||
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||
): Result<MediaUploadInfo>
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -14,17 +14,15 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback
|
|||
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
|
||||
class MediaSender @Inject constructor(
|
||||
private val preProcessor: MediaPreProcessor,
|
||||
private val room: JoinedRoom,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||
) {
|
||||
private val ongoingUploadJobs = ConcurrentHashMap<Job.Key, MediaUploadHandler>()
|
||||
val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty()
|
||||
|
|
@ -32,14 +30,14 @@ class MediaSender @Inject constructor(
|
|||
suspend fun preProcessMedia(
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||
): Result<MediaUploadInfo> {
|
||||
val compressIfPossible = sessionPreferencesStore.doesCompressMedia().first()
|
||||
return preProcessor
|
||||
.process(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
deleteOriginal = false,
|
||||
compressIfPossible = compressIfPossible,
|
||||
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -67,14 +65,14 @@ class MediaSender @Inject constructor(
|
|||
formattedCaption: String? = null,
|
||||
progressCallback: ProgressCallback? = null,
|
||||
inReplyToEventId: EventId? = null,
|
||||
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||
): Result<Unit> {
|
||||
val compressIfPossible = sessionPreferencesStore.doesCompressMedia().first()
|
||||
return preProcessor
|
||||
.process(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
deleteOriginal = false,
|
||||
compressIfPossible = compressIfPossible,
|
||||
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||
)
|
||||
.flatMapCatching { info ->
|
||||
room.liveTimeline.sendMedia(
|
||||
|
|
@ -100,7 +98,7 @@ class MediaSender @Inject constructor(
|
|||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
deleteOriginal = true,
|
||||
compressIfPossible = false,
|
||||
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
|
||||
)
|
||||
.flatMapCatching { info ->
|
||||
val audioInfo = (info as MediaUploadInfo.Audio).audioInfo
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@ import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
|||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -34,6 +33,11 @@ import java.io.File
|
|||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MediaSenderTest {
|
||||
private val mediaOptimizationConfig = MediaOptimizationConfig(
|
||||
compressImages = true,
|
||||
videoCompressionPreset = VideoCompressionPreset.STANDARD,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `given an attachment when sending it the preprocessor always runs`() = runTest {
|
||||
val preProcessor = FakeMediaPreProcessor()
|
||||
|
|
@ -57,7 +61,7 @@ class MediaSenderTest {
|
|||
)
|
||||
|
||||
val uri = Uri.parse("content://image.jpg")
|
||||
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
|
||||
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
|
||||
|
||||
assertThat(preProcessor.processCallCount).isEqualTo(1)
|
||||
}
|
||||
|
|
@ -76,7 +80,7 @@ class MediaSenderTest {
|
|||
val sender = createMediaSender(room = room)
|
||||
|
||||
val uri = Uri.parse("content://image.jpg")
|
||||
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
|
||||
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -87,7 +91,7 @@ class MediaSenderTest {
|
|||
val sender = createMediaSender(preProcessor)
|
||||
|
||||
val uri = Uri.parse("content://image.jpg")
|
||||
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
|
||||
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
|
||||
|
||||
assertThat(result.exceptionOrNull()).isNotNull()
|
||||
}
|
||||
|
|
@ -112,7 +116,7 @@ class MediaSenderTest {
|
|||
)
|
||||
|
||||
val uri = Uri.parse("content://image.jpg")
|
||||
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
|
||||
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
|
||||
|
||||
assertThat(result.exceptionOrNull()).isNotNull()
|
||||
}
|
||||
|
|
@ -132,7 +136,7 @@ class MediaSenderTest {
|
|||
val sender = createMediaSender(room = room)
|
||||
val sendJob = launch {
|
||||
val uri = Uri.parse("content://image.jpg")
|
||||
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
|
||||
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
|
||||
}
|
||||
// Wait until several internal tasks run and the file is being uploaded
|
||||
advanceTimeBy(3L)
|
||||
|
|
@ -154,10 +158,10 @@ class MediaSenderTest {
|
|||
private fun createMediaSender(
|
||||
preProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
|
||||
room: JoinedRoom = FakeJoinedRoom(),
|
||||
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||
mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = MediaOptimizationConfigProvider { mediaOptimizationConfig },
|
||||
) = MediaSender(
|
||||
preProcessor = preProcessor,
|
||||
room = room,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue