Skip to content

Commit adc61b3

Browse files
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 <[email protected]>
1 parent f6de3ca commit adc61b3

File tree

174 files changed

+2151
-339
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

174 files changed

+2151
-339
lines changed

appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ import io.element.android.features.ftue.api.state.FtueService
5252
import io.element.android.features.ftue.api.state.FtueState
5353
import io.element.android.features.home.api.HomeEntryPoint
5454
import io.element.android.features.logout.api.LogoutEntryPoint
55+
import io.element.android.features.networkmonitor.api.NetworkMonitor
56+
import io.element.android.features.networkmonitor.api.NetworkStatus
5557
import io.element.android.features.preferences.api.PreferencesEntryPoint
5658
import io.element.android.features.roomdirectory.api.RoomDescription
5759
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
@@ -125,6 +127,7 @@ class LoggedInFlowNode @AssistedInject constructor(
125127
private val logoutEntryPoint: LogoutEntryPoint,
126128
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
127129
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
130+
private val networkMonitor: NetworkMonitor,
128131
snackbarDispatcher: SnackbarDispatcher,
129132
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
130133
backstack = BackStack(
@@ -192,6 +195,12 @@ class LoggedInFlowNode @AssistedInject constructor(
192195
matrixClient.sessionVerificationService().setListener(verificationListener)
193196
mediaPreviewConfigMigration()
194197

198+
sessionCoroutineScope.launch {
199+
// Wait for the network to be connected before pre-fetching the max file upload size
200+
networkMonitor.connectivity.first { networkStatus -> networkStatus == NetworkStatus.Connected }
201+
matrixClient.getMaxFileUploadSize()
202+
}
203+
195204
ftueService.state
196205
.onEach { ftueState ->
197206
when (ftueState) {

features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
3737
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
3838
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEffect
3939
import io.element.android.libraries.mediapickers.api.PickerProvider
40+
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
4041
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
4142
import io.element.android.libraries.permissions.api.PermissionsEvents
4243
import io.element.android.libraries.permissions.api.PermissionsPresenter
@@ -57,6 +58,7 @@ class ConfigureRoomPresenter @Inject constructor(
5758
permissionsPresenterFactory: PermissionsPresenter.Factory,
5859
private val featureFlagService: FeatureFlagService,
5960
private val roomAliasHelper: RoomAliasHelper,
61+
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
6062
) : Presenter<ConfigureRoomState> {
6163
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
6264
private var pendingPermissionRequest = false
@@ -201,7 +203,7 @@ class ConfigureRoomPresenter @Inject constructor(
201203
uri = avatarUri,
202204
mimeType = MimeTypes.Jpeg,
203205
deleteOriginal = false,
204-
compressIfPossible = false,
206+
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
205207
).getOrThrow()
206208
val byteArray = preprocessed.file.readBytes()
207209
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow()

features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureBaseRoomPresenterTest.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import io.element.android.libraries.mediapickers.api.PickerProvider
3535
import io.element.android.libraries.mediapickers.test.FakePickerProvider
3636
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
3737
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
38+
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
3839
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
3940
import io.element.android.libraries.permissions.api.PermissionsPresenter
4041
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
@@ -411,6 +412,7 @@ class ConfigureBaseRoomPresenterTest {
411412
analyticsService: AnalyticsService = FakeAnalyticsService(),
412413
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
413414
isKnockFeatureEnabled: Boolean = true,
415+
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
414416
) = ConfigureRoomPresenter(
415417
dataStore = createRoomDataStore,
416418
matrixClient = matrixClient,
@@ -421,6 +423,7 @@ class ConfigureBaseRoomPresenterTest {
421423
roomAliasHelper = roomAliasHelper,
422424
featureFlagService = FakeFeatureFlagService(
423425
mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled)
424-
)
426+
),
427+
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
425428
)
426429
}

features/messages/api/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,7 @@ android {
1717
dependencies {
1818
implementation(projects.libraries.architecture)
1919
implementation(projects.libraries.matrix.api)
20+
implementation(projects.libraries.mediaviewer.api)
21+
implementation(projects.libraries.preferences.api)
2022
api(projects.libraries.textcomposer.impl)
2123
}

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,7 @@ class MessagesFlowNode @AssistedInject constructor(
462462
eventId = event.eventId,
463463
mediaInfo = MediaInfo(
464464
filename = content.filename,
465+
fileSize = content.fileSize,
465466
caption = content.caption,
466467
mimeType = content.mimeType,
467468
formattedFileSize = content.formattedFileSize,

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,26 @@ import dagger.assisted.Assisted
2222
import dagger.assisted.AssistedFactory
2323
import dagger.assisted.AssistedInject
2424
import io.element.android.features.messages.impl.attachments.Attachment
25+
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter
2526
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
2627
import io.element.android.libraries.androidutils.file.safeDelete
2728
import io.element.android.libraries.architecture.Presenter
2829
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
2930
import io.element.android.libraries.core.coroutine.firstInstanceOf
3031
import io.element.android.libraries.core.extensions.runCatchingExceptions
32+
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
33+
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
3134
import io.element.android.libraries.di.annotations.SessionCoroutineScope
3235
import io.element.android.libraries.featureflag.api.FeatureFlagService
3336
import io.element.android.libraries.featureflag.api.FeatureFlags
3437
import io.element.android.libraries.matrix.api.core.EventId
3538
import io.element.android.libraries.matrix.api.core.ProgressCallback
3639
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
40+
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
3741
import io.element.android.libraries.mediaupload.api.MediaSender
3842
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
3943
import io.element.android.libraries.mediaupload.api.allFiles
44+
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
4045
import io.element.android.libraries.textcomposer.model.TextEditorState
4146
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
4247
import kotlinx.coroutines.CancellationException
@@ -54,6 +59,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
5459
private val permalinkBuilder: PermalinkBuilder,
5560
private val temporaryUriDeleter: TemporaryUriDeleter,
5661
private val featureFlagService: FeatureFlagService,
62+
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
5763
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
5864
private val dispatchers: CoroutineDispatchers,
5965
) : Presenter<AttachmentsPreviewState> {
@@ -89,21 +95,80 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
8995

9096
var useSendQueue by remember { mutableStateOf(false) }
9197
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
98+
99+
val mediaAttachment = attachment as Attachment.Media
100+
val mediaOptimizationSelectorPresenter = remember {
101+
mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia)
102+
}
103+
val mediaOptimizationSelectorState = mediaOptimizationSelectorPresenter.present()
104+
105+
val observableSendState = snapshotFlow { sendActionState.value }
106+
107+
var displayFileTooLargeError by remember { mutableStateOf(false) }
108+
92109
LaunchedEffect(Unit) {
93110
useSendQueue = featureFlagService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
111+
}
94112

95-
preprocessMediaJob = preProcessAttachment(
96-
attachment,
97-
sendActionState
98-
)
113+
LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews) {
114+
// If the media optimization selector is not displayed, we can pre-process the media
115+
// to prepare it for sending. This is done to avoid blocking the UI thread when the
116+
// user clicks on the send button.
117+
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false) {
118+
val mediaOptimizationConfig = MediaOptimizationConfig(
119+
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true,
120+
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
121+
)
122+
preprocessMediaJob = preProcessAttachment(
123+
attachment = attachment,
124+
mediaOptimizationConfig = mediaOptimizationConfig,
125+
displayProgress = false,
126+
sendActionState = sendActionState,
127+
)
128+
}
99129
}
100130

101-
val observableSendState = snapshotFlow { sendActionState.value }
131+
val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull()
132+
LaunchedEffect(maxUploadSize) {
133+
// Check file upload size if the media won't be processed for upload
134+
val isImageFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeImage()
135+
val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo()
136+
if (maxUploadSize != null && !(isImageFile || isVideoFile)) {
137+
// If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed.
138+
val fileSize = mediaAttachment.localMedia.info.fileSize ?: 0L
139+
if (maxUploadSize < fileSize) {
140+
displayFileTooLargeError = true
141+
}
142+
}
143+
}
144+
145+
val videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull()
146+
LaunchedEffect(videoSizeEstimations) {
147+
if (videoSizeEstimations != null) {
148+
// Check if the video size estimations are too large for the max upload size
149+
displayFileTooLargeError = videoSizeEstimations.none { it.canUpload }
150+
}
151+
}
102152

103153
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
104154
when (attachmentsPreviewEvents) {
105155
is AttachmentsPreviewEvents.SendAttachment -> {
106156
ongoingSendAttachmentJob.value = coroutineScope.launch {
157+
// If the media optimization selector is displayed, we need to wait for the user to select the options
158+
// before we can pre-process the media.
159+
if (mediaOptimizationSelectorState.displayMediaSelectorViews == true) {
160+
val config = MediaOptimizationConfig(
161+
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true,
162+
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
163+
)
164+
preprocessMediaJob = preProcessAttachment(
165+
attachment = attachment,
166+
mediaOptimizationConfig = config,
167+
displayProgress = true,
168+
sendActionState = sendActionState,
169+
)
170+
}
171+
107172
// If the processing was hidden before, make it visible now
108173
if (sendActionState.value is SendActionState.Sending.Processing) {
109174
sendActionState.value = SendActionState.Sending.Processing(displayProgress = true)
@@ -138,6 +203,8 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
138203
}
139204
}
140205
AttachmentsPreviewEvents.CancelAndDismiss -> {
206+
displayFileTooLargeError = false
207+
141208
// Cancel media preprocessing and sending
142209
preprocessMediaJob?.cancel()
143210
// If we couldn't send the pre-processed media, remove it
@@ -173,18 +240,24 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
173240
textEditorState = textEditorState,
174241
allowCaption = allowCaption,
175242
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
243+
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
244+
displayFileTooLargeError = displayFileTooLargeError,
176245
eventSink = ::handleEvents
177246
)
178247
}
179248

180249
private fun CoroutineScope.preProcessAttachment(
181250
attachment: Attachment,
251+
mediaOptimizationConfig: MediaOptimizationConfig,
252+
displayProgress: Boolean,
182253
sendActionState: MutableState<SendActionState>,
183254
) = launch(dispatchers.io) {
184255
when (attachment) {
185256
is Attachment.Media -> {
186257
preProcessMedia(
187258
mediaAttachment = attachment,
259+
mediaOptimizationConfig = mediaOptimizationConfig,
260+
displayProgress = displayProgress,
188261
sendActionState = sendActionState,
189262
)
190263
}
@@ -193,12 +266,15 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
193266

194267
private suspend fun preProcessMedia(
195268
mediaAttachment: Attachment.Media,
269+
mediaOptimizationConfig: MediaOptimizationConfig,
270+
displayProgress: Boolean,
196271
sendActionState: MutableState<SendActionState>,
197272
) {
198-
sendActionState.value = SendActionState.Sending.Processing(displayProgress = false)
273+
sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress)
199274
mediaSender.preProcessMedia(
200275
uri = mediaAttachment.localMedia.uri,
201276
mimeType = mediaAttachment.localMedia.info.mimeType,
277+
mediaOptimizationConfig = mediaOptimizationConfig,
202278
).fold(
203279
onSuccess = { mediaUploadInfo ->
204280
sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfo)

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.attachments.preview
99

1010
import androidx.compose.runtime.Immutable
1111
import io.element.android.features.messages.impl.attachments.Attachment
12+
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
1213
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
1314
import io.element.android.libraries.textcomposer.model.TextEditorState
1415

@@ -18,6 +19,8 @@ data class AttachmentsPreviewState(
1819
val textEditorState: TextEditorState,
1920
val allowCaption: Boolean,
2021
val showCaptionCompatibilityWarning: Boolean,
22+
val mediaOptimizationSelectorState: MediaOptimizationSelectorState,
23+
val displayFileTooLargeError: Boolean,
2124
val eventSink: (AttachmentsPreviewEvents) -> Unit
2225
)
2326

0 commit comments

Comments
 (0)