From a170d80cb3d3e3704a56c5aad21c1571fec6b7dd Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 11 Aug 2025 17:22:46 +0200 Subject: [PATCH] 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/appnav/LoggedInFlowNode.kt | 9 + .../configureroom/ConfigureRoomPresenter.kt | 4 +- .../ConfigureBaseRoomPresenterTest.kt | 5 +- features/messages/api/build.gradle.kts | 2 + .../messages/impl/MessagesFlowNode.kt | 1 + .../preview/AttachmentsPreviewPresenter.kt | 90 ++++++- .../preview/AttachmentsPreviewState.kt | 3 + .../AttachmentsPreviewStateProvider.kt | 58 ++++ .../preview/AttachmentsPreviewView.kt | 226 +++++++++++++++- .../preview/error/ErrorFormatter.kt | 6 +- ...faultMediaOptimizationSelectorPresenter.kt | 192 ++++++++++++++ .../video/MediaOptimizationSelectorEvent.kt | 17 ++ .../MediaOptimizationSelectorPresenter.kt | 19 ++ .../video/MediaOptimizationSelectorState.kt | 28 ++ .../video/VideoMetadataExtractor.kt | 68 +++++ .../MessageComposerPresenter.kt | 3 + .../TimelineItemContentMessageFactory.kt | 7 + .../TimelineItemContentStickerFactory.kt | 1 + .../model/event/TimelineItemAudioContent.kt | 1 + .../event/TimelineItemAudioContentProvider.kt | 1 + .../model/event/TimelineItemEventContent.kt | 1 + .../model/event/TimelineItemFileContent.kt | 1 + .../event/TimelineItemFileContentProvider.kt | 1 + .../model/event/TimelineItemImageContent.kt | 1 + .../event/TimelineItemImageContentProvider.kt | 1 + .../model/event/TimelineItemStickerContent.kt | 1 + .../TimelineItemStickerContentProvider.kt | 1 + .../model/event/TimelineItemVideoContent.kt | 1 + .../event/TimelineItemVideoContentProvider.kt | 1 + .../model/event/TimelineItemVoiceContent.kt | 1 + .../event/TimelineItemVoiceContentProvider.kt | 1 + .../src/main/res/values-be/translations.xml | 2 + .../src/main/res/values-cs/translations.xml | 6 + .../src/main/res/values-cy/translations.xml | 3 + .../src/main/res/values-da/translations.xml | 6 + .../src/main/res/values-de/translations.xml | 3 + .../src/main/res/values-el/translations.xml | 3 + .../src/main/res/values-es/translations.xml | 3 + .../src/main/res/values-et/translations.xml | 6 + .../src/main/res/values-eu/translations.xml | 1 + .../src/main/res/values-fa/translations.xml | 2 + .../src/main/res/values-fi/translations.xml | 6 + .../src/main/res/values-fr/translations.xml | 6 + .../src/main/res/values-hu/translations.xml | 6 + .../src/main/res/values-in/translations.xml | 3 + .../src/main/res/values-it/translations.xml | 3 + .../src/main/res/values-ka/translations.xml | 2 + .../src/main/res/values-lt/translations.xml | 2 + .../src/main/res/values-nb/translations.xml | 3 + .../src/main/res/values-nl/translations.xml | 2 + .../src/main/res/values-pl/translations.xml | 6 + .../main/res/values-pt-rBR/translations.xml | 3 + .../src/main/res/values-pt/translations.xml | 6 + .../src/main/res/values-ro/translations.xml | 2 + .../src/main/res/values-ru/translations.xml | 3 + .../src/main/res/values-sk/translations.xml | 6 + .../src/main/res/values-sv/translations.xml | 3 + .../src/main/res/values-tr/translations.xml | 3 + .../src/main/res/values-uk/translations.xml | 6 + .../src/main/res/values-ur/translations.xml | 2 + .../src/main/res/values-uz/translations.xml | 2 + .../main/res/values-zh-rTW/translations.xml | 3 + .../src/main/res/values-zh/translations.xml | 3 + .../impl/src/main/res/values/localazy.xml | 9 + .../messages/impl/MessagesPresenterTest.kt | 3 + .../AttachmentsPreviewPresenterTest.kt | 136 +++++++++- ...tMediaOptimizationSelectorPresenterTest.kt | 249 ++++++++++++++++++ .../MessageComposerPresenterTest.kt | 11 +- .../TimelineItemContentMessageFactoryTest.kt | 12 + .../VoiceMessageComposerPresenterTest.kt | 9 +- features/messages/test/build.gradle.kts | 4 +- ...diaOptimizationSelectorPresenterFactory.kt | 31 +++ .../video/FakeVideoMetadataExtractor.kt | 31 +++ .../impl/advanced/AdvancedSettingsEvents.kt | 3 + .../advanced/AdvancedSettingsPresenter.kt | 42 ++- .../impl/advanced/AdvancedSettingsState.kt | 16 +- .../advanced/AdvancedSettingsStateProvider.kt | 11 +- .../impl/advanced/AdvancedSettingsView.kt | 179 +++++++++++-- .../editprofile/EditUserProfilePresenter.kt | 4 +- .../advanced/AdvancedSettingsPresenterTest.kt | 97 ++++++- .../impl/advanced/AdvancedSettingsViewTest.kt | 2 +- .../EditUserProfilePresenterTest.kt | 3 + .../impl/edit/RoomDetailsEditPresenter.kt | 4 +- .../impl/edit/RoomDetailsEditPresenterTest.kt | 3 + .../features/share/impl/SharePresenter.kt | 7 +- .../features/share/impl/SharePresenterTest.kt | 5 +- .../media/VideoCompressorHelper.kt | 46 ++++ .../libraries/featureflag/api/FeatureFlags.kt | 8 + .../libraries/matrix/api/MatrixClient.kt | 5 + .../libraries/matrix/impl/RustMatrixClient.kt | 4 + .../libraries/matrix/test/FakeMatrixClient.kt | 5 + .../mediaupload/api/MaxUploadSizeProvider.kt | 22 ++ .../api/MediaOptimizationConfig.kt | 22 ++ .../api/MediaOptimizationConfigProvider.kt | 12 + .../mediaupload/api/MediaPreProcessor.kt | 2 +- .../libraries/mediaupload/api/MediaSender.kt | 14 +- .../mediaupload/api/MediaSenderTest.kt | 22 +- .../impl/AndroidMediaPreProcessor.kt | 12 +- .../DefaultMediaOptimizationConfigProvider.kt | 26 ++ .../mediaupload/impl/VideoCompressor.kt | 9 +- .../mediaupload/impl/VideoCompressorConfig.kt | 68 +---- .../impl/AndroidMediaPreProcessorTest.kt | 76 ++++-- .../impl/VideoCompressorConfigFactoryTest.kt | 39 +-- .../FakeMediaOptimizationConfigProvider.kt | 21 ++ .../mediaupload/test/FakeMediaPreProcessor.kt | 3 +- .../libraries/mediaviewer/api/MediaInfo.kt | 8 + .../impl/DefaultMediaViewerEntryPoint.kt | 1 + .../impl/datasource/EventItemFactory.kt | 6 + .../impl/local/AndroidLocalMediaFactory.kt | 6 +- .../datasource/DefaultEventItemFactoryTest.kt | 6 + .../TimelineMediaGalleryDataSourceTest.kt | 1 + .../local/AndroidLocalMediaFactoryTest.kt | 2 + .../mediaviewer/test/FakeLocalMediaFactory.kt | 8 +- .../api/store/SessionPreferencesStore.kt | 7 +- .../api/store/VideoCompressionPreset.kt | 22 ++ .../store/DefaultSessionPreferencesStore.kt | 14 +- .../test/InMemorySessionPreferencesStore.kt | 15 +- .../textcomposer/CaptionWarningBottomSheet.kt | 2 +- .../impl/src/main/res/values/localazy.xml | 1 + .../src/main/res/values-be/translations.xml | 2 - .../src/main/res/values-cs/translations.xml | 6 - .../src/main/res/values-cy/translations.xml | 3 - .../src/main/res/values-da/translations.xml | 6 - .../src/main/res/values-de/translations.xml | 3 - .../src/main/res/values-el/translations.xml | 3 - .../src/main/res/values-es/translations.xml | 3 - .../src/main/res/values-et/translations.xml | 6 - .../src/main/res/values-eu/translations.xml | 1 - .../src/main/res/values-fa/translations.xml | 2 - .../src/main/res/values-fi/translations.xml | 6 - .../src/main/res/values-fr/translations.xml | 6 - .../src/main/res/values-hu/translations.xml | 9 - .../src/main/res/values-in/translations.xml | 3 - .../src/main/res/values-it/translations.xml | 3 - .../src/main/res/values-ka/translations.xml | 2 - .../src/main/res/values-lt/translations.xml | 2 - .../src/main/res/values-nb/translations.xml | 3 - .../src/main/res/values-nl/translations.xml | 2 - .../src/main/res/values-pl/translations.xml | 6 - .../main/res/values-pt-rBR/translations.xml | 3 - .../src/main/res/values-pt/translations.xml | 6 - .../src/main/res/values-ro/translations.xml | 2 - .../src/main/res/values-ru/translations.xml | 3 - .../src/main/res/values-sk/translations.xml | 9 - .../src/main/res/values-sv/translations.xml | 3 - .../src/main/res/values-tr/translations.xml | 3 - .../src/main/res/values-uk/translations.xml | 6 - .../src/main/res/values-ur/translations.xml | 2 - .../src/main/res/values-uz/translations.xml | 2 - .../main/res/values-zh-rTW/translations.xml | 3 - .../src/main/res/values-zh/translations.xml | 3 - .../src/main/res/values/localazy.xml | 9 - libraries/ui-utils/build.gradle.kts | 3 + .../ui/utils/formatter/FIleSizeFormatter.kt | 24 ++ .../version/LocalSdkIntVersionProvider.kt | 14 + ...tachments.preview_AttachmentsView_0_en.png | 4 +- ...achments.preview_AttachmentsView_10_en.png | 3 + ...tachments.preview_AttachmentsView_1_en.png | 4 +- ...tachments.preview_AttachmentsView_2_en.png | 4 +- ...tachments.preview_AttachmentsView_3_en.png | 4 +- ...tachments.preview_AttachmentsView_4_en.png | 4 +- ...tachments.preview_AttachmentsView_5_en.png | 4 +- ...tachments.preview_AttachmentsView_6_en.png | 4 +- ...tachments.preview_AttachmentsView_7_en.png | 4 +- ...tachments.preview_AttachmentsView_8_en.png | 3 + ...tachments.preview_AttachmentsView_9_en.png | 3 + ...ew_VideoQualitySelectorDialog_Day_0_en.png | 3 + ..._VideoQualitySelectorDialog_Night_0_en.png | 3 + ...advanced_AdvancedSettingsViewDark_8_en.png | 3 + ...dvanced_AdvancedSettingsViewLight_8_en.png | 3 + ...ed_VideoQualitySelectorDialog_Day_0_en.png | 3 + ..._VideoQualitySelectorDialog_Night_0_en.png | 3 + tools/detekt/detekt.yml | 1 + tools/localazy/config.json | 6 +- 174 files changed, 2152 insertions(+), 340 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorEvent.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorPresenter.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorState.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/VideoMetadataExtractor.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt create mode 100644 features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeMediaOptimizationSelectorPresenterFactory.kt create mode 100644 features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeVideoMetadataExtractor.kt create mode 100644 libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/VideoCompressorHelper.kt create mode 100644 libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MaxUploadSizeProvider.kt create mode 100644 libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfig.kt create mode 100644 libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt create mode 100644 libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProvider.kt create mode 100644 libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaOptimizationConfigProvider.kt create mode 100644 libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/VideoCompressionPreset.kt create mode 100644 libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/formatter/FIleSizeFormatter.kt create mode 100644 libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/version/LocalSdkIntVersionProvider.kt create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_10_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_9_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_VideoQualitySelectorDialog_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_VideoQualitySelectorDialog_Night_0_en.png diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 043dc75a92..fe24786534 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -52,6 +52,8 @@ import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.logout.api.LogoutEntryPoint +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint @@ -125,6 +127,7 @@ class LoggedInFlowNode @AssistedInject constructor( private val logoutEntryPoint: LogoutEntryPoint, private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint, private val mediaPreviewConfigMigration: MediaPreviewConfigMigration, + private val networkMonitor: NetworkMonitor, snackbarDispatcher: SnackbarDispatcher, ) : BaseFlowNode( backstack = BackStack( @@ -192,6 +195,12 @@ class LoggedInFlowNode @AssistedInject constructor( matrixClient.sessionVerificationService().setListener(verificationListener) mediaPreviewConfigMigration() + sessionCoroutineScope.launch { + // Wait for the network to be connected before pre-fetching the max file upload size + networkMonitor.connectivity.first { networkStatus -> networkStatus == NetworkStatus.Connected } + matrixClient.getMaxFileUploadSize() + } + ftueService.state .onEach { ftueState -> when (ftueState) { diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt index 0025a64df3..a3e8f67edf 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt @@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEffect import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsPresenter @@ -57,6 +58,7 @@ class ConfigureRoomPresenter @Inject constructor( permissionsPresenterFactory: PermissionsPresenter.Factory, private val featureFlagService: FeatureFlagService, private val roomAliasHelper: RoomAliasHelper, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, ) : Presenter { private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) private var pendingPermissionRequest = false @@ -201,7 +203,7 @@ class ConfigureRoomPresenter @Inject constructor( uri = avatarUri, mimeType = MimeTypes.Jpeg, deleteOriginal = false, - compressIfPossible = false, + mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), ).getOrThrow() val byteArray = preprocessed.file.readBytes() return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow() diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureBaseRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureBaseRoomPresenterTest.kt index a0a4783093..8c8ba7ca58 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureBaseRoomPresenterTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureBaseRoomPresenterTest.kt @@ -35,6 +35,7 @@ import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter @@ -411,6 +412,7 @@ class ConfigureBaseRoomPresenterTest { analyticsService: AnalyticsService = FakeAnalyticsService(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), isKnockFeatureEnabled: Boolean = true, + mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), ) = ConfigureRoomPresenter( dataStore = createRoomDataStore, matrixClient = matrixClient, @@ -421,6 +423,7 @@ class ConfigureBaseRoomPresenterTest { roomAliasHelper = roomAliasHelper, featureFlagService = FakeFeatureFlagService( mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled) - ) + ), + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, ) } diff --git a/features/messages/api/build.gradle.kts b/features/messages/api/build.gradle.kts index aa715dcae4..7eefee11cc 100644 --- a/features/messages/api/build.gradle.kts +++ b/features/messages/api/build.gradle.kts @@ -17,5 +17,7 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.mediaviewer.api) + implementation(projects.libraries.preferences.api) api(projects.libraries.textcomposer.impl) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 8f97b6bc19..a0df0615eb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -462,6 +462,7 @@ class MessagesFlowNode @AssistedInject constructor( eventId = event.eventId, mediaInfo = MediaInfo( filename = content.filename, + fileSize = content.fileSize, caption = content.caption, mimeType = content.mimeType, formattedFileSize = content.formattedFileSize, 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 bfae4c20d1..6af3033345 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 @@ -22,21 +22,26 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.firstInstanceOf import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.allFiles +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState import kotlinx.coroutines.CancellationException @@ -54,6 +59,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( private val permalinkBuilder: PermalinkBuilder, private val temporaryUriDeleter: TemporaryUriDeleter, private val featureFlagService: FeatureFlagService, + private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val dispatchers: CoroutineDispatchers, ) : Presenter { @@ -89,21 +95,80 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( var useSendQueue by remember { mutableStateOf(false) } var preprocessMediaJob by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { - useSendQueue = featureFlagService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue) - preprocessMediaJob = preProcessAttachment( - attachment, - sendActionState - ) + val mediaAttachment = attachment as Attachment.Media + val mediaOptimizationSelectorPresenter = remember { + mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia) } + val mediaOptimizationSelectorState = mediaOptimizationSelectorPresenter.present() val observableSendState = snapshotFlow { sendActionState.value } + var displayFileTooLargeError by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + useSendQueue = featureFlagService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue) + } + + LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews) { + // 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) { + val mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true, + videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD, + ) + preprocessMediaJob = preProcessAttachment( + attachment = attachment, + mediaOptimizationConfig = mediaOptimizationConfig, + displayProgress = false, + sendActionState = sendActionState, + ) + } + } + + val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull() + LaunchedEffect(maxUploadSize) { + // Check file upload size if the media won't be processed for upload + val isImageFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeImage() + val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() + if (maxUploadSize != null && !(isImageFile || isVideoFile)) { + // If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed. + val fileSize = mediaAttachment.localMedia.info.fileSize ?: 0L + if (maxUploadSize < fileSize) { + displayFileTooLargeError = true + } + } + } + + val videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() + LaunchedEffect(videoSizeEstimations) { + if (videoSizeEstimations != null) { + // Check if the video size estimations are too large for the max upload size + displayFileTooLargeError = videoSizeEstimations.none { it.canUpload } + } + } + fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) { when (attachmentsPreviewEvents) { is AttachmentsPreviewEvents.SendAttachment -> { ongoingSendAttachmentJob.value = coroutineScope.launch { + // If the media optimization selector is displayed, we need to wait for the user to select the options + // before we can pre-process the media. + if (mediaOptimizationSelectorState.displayMediaSelectorViews == true) { + val config = MediaOptimizationConfig( + compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true, + videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD, + ) + preprocessMediaJob = preProcessAttachment( + attachment = attachment, + mediaOptimizationConfig = config, + displayProgress = true, + sendActionState = sendActionState, + ) + } + // If the processing was hidden before, make it visible now if (sendActionState.value is SendActionState.Sending.Processing) { sendActionState.value = SendActionState.Sending.Processing(displayProgress = true) @@ -138,6 +203,8 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( } } AttachmentsPreviewEvents.CancelAndDismiss -> { + displayFileTooLargeError = false + // Cancel media preprocessing and sending preprocessMediaJob?.cancel() // If we couldn't send the pre-processed media, remove it @@ -173,18 +240,24 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( textEditorState = textEditorState, allowCaption = allowCaption, showCaptionCompatibilityWarning = showCaptionCompatibilityWarning, + mediaOptimizationSelectorState = mediaOptimizationSelectorState, + displayFileTooLargeError = displayFileTooLargeError, eventSink = ::handleEvents ) } private fun CoroutineScope.preProcessAttachment( attachment: Attachment, + mediaOptimizationConfig: MediaOptimizationConfig, + displayProgress: Boolean, sendActionState: MutableState, ) = launch(dispatchers.io) { when (attachment) { is Attachment.Media -> { preProcessMedia( mediaAttachment = attachment, + mediaOptimizationConfig = mediaOptimizationConfig, + displayProgress = displayProgress, sendActionState = sendActionState, ) } @@ -193,12 +266,15 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( private suspend fun preProcessMedia( mediaAttachment: Attachment.Media, + mediaOptimizationConfig: MediaOptimizationConfig, + displayProgress: Boolean, sendActionState: MutableState, ) { - sendActionState.value = SendActionState.Sending.Processing(displayProgress = false) + sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress) mediaSender.preProcessMedia( uri = mediaAttachment.localMedia.uri, mimeType = mediaAttachment.localMedia.info.mimeType, + mediaOptimizationConfig = mediaOptimizationConfig, ).fold( onSuccess = { mediaUploadInfo -> sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfo) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt index 90438e14c3..a935dd3afe 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.attachments.preview import androidx.compose.runtime.Immutable import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.textcomposer.model.TextEditorState @@ -18,6 +19,8 @@ data class AttachmentsPreviewState( val textEditorState: TextEditorState, val allowCaption: Boolean, val showCaptionCompatibilityWarning: Boolean, + val mediaOptimizationSelectorState: MediaOptimizationSelectorState, + val displayFileTooLargeError: Boolean, val eventSink: (AttachmentsPreviewEvents) -> Unit ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt index e7dac45cd8..b1985482f6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt @@ -10,14 +10,21 @@ package io.element.android.features.messages.impl.attachments.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.core.net.toUri import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState +import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo import io.element.android.libraries.mediaviewer.api.anImageMediaInfo import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import java.io.File open class AttachmentsPreviewStateProvider : PreviewParameterProvider { @@ -36,6 +43,21 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider> = AsyncData.Success(persistentListOf()), + isImageOptimizationEnabled: Boolean = true, + selectedVideoPreset: VideoCompressionPreset = VideoCompressionPreset.STANDARD, + displayMediaSelectorViews: Boolean = true, + displayVideoPresetSelectorDialog: Boolean = false, +) = MediaOptimizationSelectorState( + maxUploadSize = AsyncData.Success(maxUploadSize), + videoSizeEstimations = videoSizeEstimations, + isImageOptimizationEnabled = isImageOptimizationEnabled, + selectedVideoPreset = selectedVideoPreset, + displayMediaSelectorViews = displayMediaSelectorViews, + displayVideoPresetSelectorDialog = displayVideoPresetSelectorDialog, + eventSink = {}, +) + +internal fun aVideoSizeEstimationList(): AsyncData> = AsyncData.Success( + persistentListOf( + VideoUploadEstimation( + preset = VideoCompressionPreset.HIGH, + sizeInBytes = 8_200_000L, + canUpload = false, + ), + VideoUploadEstimation( + preset = VideoCompressionPreset.STANDARD, + sizeInBytes = 4_200_000L, + canUpload = true, + ), + ) +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index 0ea99a9c35..86d742d03d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -21,31 +22,56 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError +import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorEvent +import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState +import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialogType import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.AlertDialog +import io.element.android.libraries.designsystem.components.dialogs.ListDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.modifiers.niceClickable +import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Switch +import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.textcomposer.TextComposer import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.VoiceMessageState import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.ui.utils.formatter.rememberFileSizeFormatter import io.element.android.wysiwyg.display.TextDisplay +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -109,7 +135,7 @@ private fun AttachmentSendStateView( if (sendActionState.displayProgress) { ProgressDialog( type = ProgressDialogType.Indeterminate, - text = stringResource(id = CommonStrings.common_sending), + text = stringResource(CommonStrings.common_preparing), showCancelButton = true, onDismissRequest = onDismissClick, ) @@ -157,6 +183,26 @@ private fun AttachmentPreviewContent( } } } + val mimeType = (state.attachment as? Attachment.Media)?.localMedia?.info?.mimeType + if (mimeType?.isMimeTypeImage() == true) { + ImageOptimizationSelector(state.mediaOptimizationSelectorState) + } else if (mimeType?.isMimeTypeVideo() == true) { + VideoPresetSelector(state = state.mediaOptimizationSelectorState) + } + + val sizeFormatter = rememberFileSizeFormatter() + if (state.displayFileTooLargeError) { + val maxFileUploadSize = state.mediaOptimizationSelectorState.maxUploadSize.dataOrNull() + if (maxFileUploadSize != null) { + val content = stringResource(CommonStrings.dialog_file_too_large_to_upload_subtitle, sizeFormatter.format(maxFileUploadSize, true)) + AlertDialog( + title = stringResource(CommonStrings.dialog_file_too_large_to_upload_title), + content = content, + onDismiss = { state.eventSink(AttachmentsPreviewEvents.CancelAndDismiss) }, + ) + } + } + AttachmentsPreviewBottomActions( state = state, onSendClick = onSendClick, @@ -169,6 +215,144 @@ private fun AttachmentPreviewContent( } } +@Composable +private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) { + if (state.displayMediaSelectorViews == true) { + Row( + modifier = Modifier.fillMaxWidth() + .niceClickable { + state.isImageOptimizationEnabled?.let { value -> + state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(!value)) + } + } + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + Text( + modifier = Modifier.weight(1f).align(Alignment.CenterVertically), + text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title), + style = ElementTheme.materialTypography.bodyLarge, + ) + Switch( + modifier = Modifier.height(32.dp), + checked = state.isImageOptimizationEnabled.orFalse(), + onCheckedChange = { value -> state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(value)) }, + ) + } + } +} + +@Composable +private fun VideoPresetSelector( + state: MediaOptimizationSelectorState, +) { + val videoPresets = state.videoSizeEstimations.dataOrNull() + var selectedPreset by remember(state.selectedVideoPreset) { mutableStateOf(state.selectedVideoPreset) } + + val displayDialog = state.displayVideoPresetSelectorDialog + + val sizeFormatter = rememberFileSizeFormatter() + + if (state.displayMediaSelectorViews == true && videoPresets != null && state.selectedVideoPreset != null) { + Column( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp) + .niceClickable { state.eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) } + ) { + val estimation = videoPresets.find { it.preset == selectedPreset } + val estimationMb = estimation?.sizeInBytes?.let { sizeFormatter.format(it, true) } + val title = buildString { + append(state.selectedVideoPreset.title()) + if (estimationMb != null) { + append(" ($estimationMb)") + } + } + Text(text = title, style = ElementTheme.typography.fontBodyLgMedium) + Text( + text = stringResource(R.string.screen_media_upload_preview_change_video_quality_prompt), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textSecondary, + ) + } + } + + if (displayDialog) { + VideoQualitySelectorDialog( + selectedPreset = selectedPreset ?: VideoCompressionPreset.STANDARD, + videoSizeEstimations = videoPresets ?: persistentListOf(), + maxFileUploadSize = state.maxUploadSize.dataOrNull(), + onSubmit = { preset -> + selectedPreset = preset + state.eventSink(MediaOptimizationSelectorEvent.SelectVideoPreset(preset)) + }, + onDismiss = { state.eventSink(MediaOptimizationSelectorEvent.DismissVideoPresetSelectorDialog) } + ) + } +} + +@Composable +private fun VideoQualitySelectorDialog( + selectedPreset: VideoCompressionPreset, + videoSizeEstimations: ImmutableList, + maxFileUploadSize: Long?, + onSubmit: (VideoCompressionPreset) -> Unit, + onDismiss: () -> Unit, +) { + val sizeFormatter = rememberFileSizeFormatter() + + var localSelectedPreset by remember(selectedPreset) { mutableStateOf(selectedPreset) } + val subtitlePartNoFileSize = stringResource(CommonStrings.dialog_video_quality_selector_subtitle_no_file_size) + val subtitlePartWithFileSize = stringResource(CommonStrings.dialog_video_quality_selector_subtitle_file_size) + val subtitle = remember(maxFileUploadSize) { + buildString { + append(subtitlePartNoFileSize) + if (maxFileUploadSize != null) { + append(String.format(subtitlePartWithFileSize, sizeFormatter.format(maxFileUploadSize, true))) + } + } + } + ListDialog( + title = stringResource(CommonStrings.dialog_video_quality_selector_title), + subtitle = subtitle, + onSubmit = { onSubmit(localSelectedPreset) }, + onDismissRequest = onDismiss, + applyPaddingToContents = false, + ) { + for (videoEstimation in videoSizeEstimations) { + val preset = videoEstimation.preset + val isSelected = preset == localSelectedPreset + item( + key = preset, + contentType = preset, + ) { + val estimationMb = sizeFormatter.format(videoEstimation.sizeInBytes, true) + val title = "${preset.title()} ($estimationMb)" + ListItem( + headlineContent = { + Text( + text = title, + style = ElementTheme.typography.fontBodyLgMedium, + ) + }, + supportingContent = { + Text( + text = preset.subtitle(), + style = ElementTheme.materialTypography.bodyMedium, + color = ElementTheme.colors.textSecondary, + ) + }, + leadingContent = ListItemContent.RadioButton( + selected = isSelected, + ), + onClick = { + localSelectedPreset = preset + }, + enabled = videoEstimation.canUpload, + ) + } + } + } +} + @Composable private fun AttachmentsPreviewBottomActions( state: AttachmentsPreviewState, @@ -221,3 +405,43 @@ internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewS } ) } + +@PreviewsDayNight +@Composable +internal fun VideoQualitySelectorDialogPreview() { + ElementPreview { + VideoQualitySelectorDialog( + selectedPreset = VideoCompressionPreset.STANDARD, + videoSizeEstimations = persistentListOf( + VideoUploadEstimation(VideoCompressionPreset.HIGH, 2_000_000, canUpload = false), + VideoUploadEstimation(VideoCompressionPreset.STANDARD, 1_000_000, canUpload = true), + VideoUploadEstimation(VideoCompressionPreset.LOW, 500_000, canUpload = true) + ), + maxFileUploadSize = 1_500_000, + onSubmit = {}, + onDismiss = {}, + ) + } +} + +@Composable +fun VideoCompressionPreset.title(): String { + return stringResource( + when (this) { + VideoCompressionPreset.STANDARD -> CommonStrings.common_video_quality_standard + VideoCompressionPreset.HIGH -> CommonStrings.common_video_quality_high + VideoCompressionPreset.LOW -> CommonStrings.common_video_quality_low + } + ) +} + +@Composable +fun VideoCompressionPreset.subtitle(): String { + return stringResource( + when (this) { + VideoCompressionPreset.STANDARD -> CommonStrings.common_video_quality_standard_description + VideoCompressionPreset.HIGH -> CommonStrings.common_video_quality_high_description + VideoCompressionPreset.LOW -> CommonStrings.common_video_quality_low_description + } + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/error/ErrorFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/error/ErrorFormatter.kt index ad057ca3b4..51507667ba 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/error/ErrorFormatter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/error/ErrorFormatter.kt @@ -7,15 +7,15 @@ package io.element.android.features.messages.impl.attachments.preview.error +import io.element.android.features.messages.impl.R import io.element.android.libraries.mediaupload.api.MediaPreProcessor -import io.element.android.libraries.ui.strings.CommonStrings fun sendAttachmentError( throwable: Throwable ): Int { return if (throwable is MediaPreProcessor.Failure) { - CommonStrings.screen_media_upload_preview_error_failed_processing + R.string.screen_media_upload_preview_error_failed_processing } else { - CommonStrings.screen_media_upload_preview_error_failed_sending + R.string.screen_media_upload_preview_error_failed_sending } } 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 new file mode 100644 index 0000000000..b14b17dc01 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt @@ -0,0 +1,192 @@ +/* + * 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.features.messages.impl.attachments.video + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.squareup.anvil.annotations.ContributesBinding +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider +import io.element.android.libraries.mediaupload.api.compressorHelper +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.first +import timber.log.Timber +import kotlin.math.roundToLong + +class DefaultMediaOptimizationSelectorPresenter @AssistedInject constructor( + @Assisted private val localMedia: LocalMedia, + private val maxUploadSizeProvider: MaxUploadSizeProvider, + private val sessionPreferencesStore: SessionPreferencesStore, + private val featureFlagService: FeatureFlagService, + mediaExtractorFactory: VideoMetadataExtractor.Factory, +) : MediaOptimizationSelectorPresenter { + @ContributesBinding(SessionScope::class) + @AssistedFactory + interface Factory : MediaOptimizationSelectorPresenter.Factory { + override fun create( + localMedia: LocalMedia, + ): DefaultMediaOptimizationSelectorPresenter + } + + private val mediaExtractor = mediaExtractorFactory.create(localMedia.uri) + + @Composable + override fun present(): MediaOptimizationSelectorState { + val displayMediaSelectorViews by produceState(null) { + value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality) + } + + var displayVideoPresetSelectorDialog by remember { mutableStateOf(false) } + + val maxUploadSize by produceState(AsyncData.Loading()) { + maxUploadSizeProvider.getMaxUploadSize().fold( + onSuccess = { value = AsyncData.Success(it) }, + onFailure = { + Timber.e(it, "Failed to retrieve max upload size for video optimization selector") + value = AsyncData.Success((100 * 1024 * 1024).toLong()) // Default to 100 MB if we can't retrieve the max upload size + } + ) + } + + val mediaMimeType = localMedia.info.mimeType + + val videoSizeEstimations by produceState>>( + initialValue = AsyncData.Loading(), + key1 = maxUploadSize, + ) { + if (maxUploadSize !is AsyncData.Success) { + return@produceState + } + + if (!mediaMimeType.isMimeTypeVideo()) { + value = AsyncData.Uninitialized + return@produceState + } + + val (videoDimensions, duration) = mediaExtractor.use { + val size = it.getSize() + .getOrElse { exception -> + value = AsyncData.Failure(exception) + return@produceState + } + + val duration = it.getDuration() + .getOrElse { exception -> + value = AsyncData.Failure(exception) + return@produceState + } + size to duration + } + + val sizeEstimations = VideoCompressionPreset.entries + .map { preset -> + val bitRate = preset.compressorHelper().calculateOptimalBitrate(videoDimensions, 30) + val calculatedSize = (bitRate * duration / 8 * 1.1).roundToLong() // Adding 10% overhead for safety + VideoUploadEstimation( + preset = preset, + sizeInBytes = calculatedSize, + canUpload = calculatedSize <= (maxUploadSize as AsyncData.Success).data + ) + } + .toPersistentList() + .also { sizes -> + Timber.d(sizes.joinToString("\n") { "Calculated size for ${it.preset}: ${it.sizeInBytes} MB. Max upload size: $maxUploadSize" }) + } + + value = AsyncData.Success(sizeEstimations) + } + + var selectedImageOptimization by remember { mutableStateOf>(AsyncData.Loading()) } + var selectedVideoOptimizationPreset by remember { mutableStateOf>(AsyncData.Loading()) } + + LaunchedEffect(videoSizeEstimations.dataOrNull()) { + selectedImageOptimization = AsyncData.Success(sessionPreferencesStore.doesOptimizeImages().first()) + // 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 = sessionPreferencesStore.getVideoCompressionPreset().first(), + videoSizeEstimations = videoSizeEstimations, + ) + } + + fun handleEvent(event: MediaOptimizationSelectorEvent) { + when (event) { + is MediaOptimizationSelectorEvent.SelectImageOptimization -> { + selectedImageOptimization = AsyncData.Success(event.enabled) + } + is MediaOptimizationSelectorEvent.SelectVideoPreset -> { + val estimations = videoSizeEstimations.dataOrNull() + if (estimations != null) { + val preset = estimations.find { it.preset == event.preset } + if (preset == null) { + Timber.e("Selected video preset ${event.preset} is not available in the estimations") + return + } + if (!preset.canUpload) { + Timber.w("Selected video preset ${event.preset} exceeds max upload size") + return + } + } else { + Timber.e("Video size estimations are not available") + return + } + selectedVideoOptimizationPreset = AsyncData.Success(event.preset) + displayVideoPresetSelectorDialog = false + } + is MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog -> { + displayVideoPresetSelectorDialog = true + } + is MediaOptimizationSelectorEvent.DismissVideoPresetSelectorDialog -> { + displayVideoPresetSelectorDialog = false + } + } + } + + return MediaOptimizationSelectorState( + maxUploadSize = maxUploadSize, + videoSizeEstimations = videoSizeEstimations, + isImageOptimizationEnabled = selectedImageOptimization.dataOrNull(), + selectedVideoPreset = selectedVideoOptimizationPreset.dataOrNull(), + displayMediaSelectorViews = displayMediaSelectorViews, + displayVideoPresetSelectorDialog = displayVideoPresetSelectorDialog, + eventSink = { handleEvent(it) }, + ) + } + + 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/MediaOptimizationSelectorEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorEvent.kt new file mode 100644 index 0000000000..3f6beaa750 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorEvent.kt @@ -0,0 +1,17 @@ +/* + * 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.features.messages.impl.attachments.video + +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +sealed interface MediaOptimizationSelectorEvent { + data class SelectImageOptimization(val enabled: Boolean) : MediaOptimizationSelectorEvent + data class SelectVideoPreset(val preset: VideoCompressionPreset) : MediaOptimizationSelectorEvent + data object OpenVideoPresetSelectorDialog : MediaOptimizationSelectorEvent + data object DismissVideoPresetSelectorDialog : MediaOptimizationSelectorEvent +} 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 new file mode 100644 index 0000000000..5efa9964ff --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorPresenter.kt @@ -0,0 +1,19 @@ +/* + * 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.features.messages.impl.attachments.video + +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.mediaviewer.api.local.LocalMedia + +fun interface MediaOptimizationSelectorPresenter : Presenter { + interface Factory { + fun create( + localMedia: LocalMedia, + ): MediaOptimizationSelectorPresenter + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorState.kt new file mode 100644 index 0000000000..24e76f0f0b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorState.kt @@ -0,0 +1,28 @@ +/* + * 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.features.messages.impl.attachments.video + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import kotlinx.collections.immutable.ImmutableList + +data class MediaOptimizationSelectorState( + val maxUploadSize: AsyncData, + val videoSizeEstimations: AsyncData>, + val isImageOptimizationEnabled: Boolean?, + val selectedVideoPreset: VideoCompressionPreset?, + val displayMediaSelectorViews: Boolean?, + val displayVideoPresetSelectorDialog: Boolean, + val eventSink: (MediaOptimizationSelectorEvent) -> Unit +) + +data class VideoUploadEstimation( + val preset: VideoCompressionPreset, + val sizeInBytes: Long, + val canUpload: Boolean, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/VideoMetadataExtractor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/VideoMetadataExtractor.kt new file mode 100644 index 0000000000..e16fd09b3b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/VideoMetadataExtractor.kt @@ -0,0 +1,68 @@ +/* + * 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.features.messages.impl.attachments.video + +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.util.Size +import com.squareup.anvil.annotations.ContributesBinding +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext + +interface VideoMetadataExtractor : AutoCloseable { + fun getSize(): Result + fun getDuration(): Result + interface Factory { + fun create(uri: Uri): VideoMetadataExtractor + } +} + +@ContributesBinding(AppScope::class) +class DefaultVideoMetadataExtractor @AssistedInject constructor( + @ApplicationContext context: Context, + @Assisted private val uri: Uri, +) : VideoMetadataExtractor { + @ContributesBinding(AppScope::class) + @AssistedFactory + interface Factory : VideoMetadataExtractor.Factory { + override fun create(uri: Uri): DefaultVideoMetadataExtractor + } + + private val mediaMetadataRetriever = MediaMetadataRetriever() + + init { + mediaMetadataRetriever.setDataSource(context, uri) + } + + override fun getSize(): Result = runCatchingExceptions { + val width = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() + val height = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() + + @Suppress("ComplexCondition") + if (width != null && width > 0 && height != null && height > 0) { + Size(width, height) + } else { + error("Could not retrieve video size from metadata for $uri") + } + } + + override fun getDuration(): Result = runCatchingExceptions { + mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() + ?.takeIf { it > 0L } + ?: error("Could not retrieve video duration from metadata") + } + + override fun close() { + mediaMetadataRetriever.release() + } +} 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 c998090b76..1301a6ddff 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 @@ -60,6 +60,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTran import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.permissions.api.PermissionsEvents @@ -120,6 +121,7 @@ class MessageComposerPresenter @AssistedInject constructor( private val mentionSpanProvider: MentionSpanProvider, private val pillificationHelper: TextPillificationHelper, private val suggestionsProcessor: SuggestionsProcessor, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, ) : Presenter { @AssistedFactory interface Factory { @@ -519,6 +521,7 @@ class MessageComposerPresenter @AssistedInject constructor( uri = uri, mimeType = mimeType, progressCallback = null, + mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), ).getOrThrow() } .onFailure { cause -> diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index f3b54dae53..c6a6e3c10c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -86,6 +86,7 @@ class TimelineItemContentMessageFactory @Inject constructor( val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height) TimelineItemImageContent( filename = messageType.filename, + fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), isEdited = content.isEdited, @@ -106,6 +107,7 @@ class TimelineItemContentMessageFactory @Inject constructor( val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height) TimelineItemStickerContent( filename = messageType.filename, + fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), isEdited = content.isEdited, @@ -142,6 +144,7 @@ class TimelineItemContentMessageFactory @Inject constructor( val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height) TimelineItemVideoContent( filename = messageType.filename, + fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), isEdited = content.isEdited, @@ -162,6 +165,7 @@ class TimelineItemContentMessageFactory @Inject constructor( is AudioMessageType -> { TimelineItemAudioContent( filename = messageType.filename, + fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), isEdited = content.isEdited, @@ -178,6 +182,7 @@ class TimelineItemContentMessageFactory @Inject constructor( TimelineItemVoiceContent( eventId = eventId, filename = messageType.filename, + fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), isEdited = content.isEdited, @@ -192,6 +197,7 @@ class TimelineItemContentMessageFactory @Inject constructor( false -> { TimelineItemAudioContent( filename = messageType.filename, + fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), isEdited = content.isEdited, @@ -208,6 +214,7 @@ class TimelineItemContentMessageFactory @Inject constructor( val fileExtension = fileExtensionExtractor.extractFromName(messageType.filename) TimelineItemFileContent( filename = messageType.filename, + fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), isEdited = content.isEdited, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt index 84e9687b51..94e75c48ef 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt @@ -34,6 +34,7 @@ class TimelineItemContentStickerFactory @Inject constructor( return TimelineItemStickerContent( filename = content.filename, + fileSize = content.info.size ?: 0L, caption = content.body, formattedCaption = null, isEdited = false, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt index 0c77ff58ce..d543294d07 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt @@ -13,6 +13,7 @@ import kotlin.time.Duration data class TimelineItemAudioContent( override val filename: String, + override val fileSize: Long?, override val caption: String?, override val formattedCaption: CharSequence?, override val isEdited: Boolean, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt index e79129323b..5e9facbde5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt @@ -28,6 +28,7 @@ fun aTimelineItemAudioContent( caption: String? = null, ) = TimelineItemAudioContent( filename = fileName, + fileSize = 100 * 1024L, caption = caption, formattedCaption = null, isEdited = false, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index 6c20d7326f..d011865964 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -26,6 +26,7 @@ sealed interface TimelineItemEventContentWithAttachment : TimelineItemEventContent, TimelineItemEventMutableContent { val filename: String + val fileSize: Long? val caption: String? val formattedCaption: CharSequence? val mediaSource: MediaSource diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt index 5b06ea18d7..c012cfcba7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt @@ -12,6 +12,7 @@ import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAn data class TimelineItemFileContent( override val filename: String, + override val fileSize: Long?, override val caption: String?, override val formattedCaption: CharSequence?, override val isEdited: Boolean, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt index 3fca3d739a..9f29c861da 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt @@ -27,6 +27,7 @@ fun aTimelineItemFileContent( caption: String? = null, ) = TimelineItemFileContent( filename = fileName, + fileSize = 100 * 1024L, caption = caption, formattedCaption = null, isEdited = false, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt index 90cbb85a7e..5b26d28806 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.ui.media.MediaRequestData data class TimelineItemImageContent( override val filename: String, + override val fileSize: Long?, override val caption: String?, override val formattedCaption: CharSequence?, override val isEdited: Boolean, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt index 9d8ec47767..a5527bc74f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt @@ -29,6 +29,7 @@ fun aTimelineItemImageContent( caption: String? = null, ) = TimelineItemImageContent( filename = filename, + fileSize = 4 * 1024 * 1024L, caption = caption, formattedCaption = null, isEdited = false, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt index 230f292cbb..6d7809b258 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt @@ -11,6 +11,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource data class TimelineItemStickerContent( override val filename: String, + override val fileSize: Long?, override val caption: String?, override val formattedCaption: CharSequence?, override val isEdited: Boolean, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt index 52f9fc3d2c..acda873b77 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt @@ -27,6 +27,7 @@ fun aTimelineItemStickerContent( blurhash: String? = A_BLUR_HASH, ) = TimelineItemStickerContent( filename = "a sticker.gif", + fileSize = 4 * 1024 * 1024L, caption = "a body", formattedCaption = null, isEdited = false, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt index f43b81a52f..b8eeedd3bb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt @@ -12,6 +12,7 @@ import kotlin.time.Duration data class TimelineItemVideoContent( override val filename: String, + override val fileSize: Long?, override val caption: String?, override val formattedCaption: CharSequence?, override val isEdited: Boolean, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt index d2255a36ec..a7499dc7dd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt @@ -28,6 +28,7 @@ fun aTimelineItemVideoContent( blurhash: String? = A_BLUR_HASH, ) = TimelineItemVideoContent( filename = "Video.mp4", + fileSize = 14 * 1024 * 1024L, caption = null, formattedCaption = null, isEdited = false, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt index 5040ee36f2..cc4a764c2c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt @@ -15,6 +15,7 @@ import kotlin.time.Duration data class TimelineItemVoiceContent( val eventId: EventId?, override val filename: String, + override val fileSize: Long?, override val caption: String?, override val formattedCaption: CharSequence?, override val isEdited: Boolean, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt index 73c29b2b89..37c9a906bf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt @@ -45,6 +45,7 @@ fun aTimelineItemVoiceContent( waveform: List = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f), ) = TimelineItemVoiceContent( eventId = eventId, + fileSize = 1024 * 1024, filename = filename, caption = caption, formattedCaption = null, diff --git a/features/messages/impl/src/main/res/values-be/translations.xml b/features/messages/impl/src/main/res/values-be/translations.xml index 7ea003cfd7..ff8fd2efad 100644 --- a/features/messages/impl/src/main/res/values-be/translations.xml +++ b/features/messages/impl/src/main/res/values-be/translations.xml @@ -8,6 +8,8 @@ "Усмешкі & Удзельнікі" "Падарожжы & Месцы" "Сімвалы" + "Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз." + "Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз." "Заблакіраваць карыстальніка" "Адзначце, ці хочаце вы схаваць усе бягучыя і будучыя паведамленні ад гэтага карыстальніка" "Гэтае паведамленне будзе перададзена адміністратару вашага хатняга сервера. Яны не змогуць прачытаць зашыфраваныя паведамленні." diff --git a/features/messages/impl/src/main/res/values-cs/translations.xml b/features/messages/impl/src/main/res/values-cs/translations.xml index 3f5f94e595..c3707a2379 100644 --- a/features/messages/impl/src/main/res/values-cs/translations.xml +++ b/features/messages/impl/src/main/res/values-cs/translations.xml @@ -8,6 +8,12 @@ "Smajlíci a lidé" "Cestování a místa" "Symboly" + "Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace." + "Soubor nelze nahrát." + "Nahrání média se nezdařilo, zkuste to prosím znovu." + "Nahrání média se nezdařilo, zkuste to prosím znovu." + "Maximální povolená velikost souboru je %1$s." + "Soubor je pro nahrání příliš velký." "Zablokovat uživatele" "Zaškrtněte, pokud chcete skrýt všechny aktuální a budoucí zprávy od tohoto uživatele" "Tato zpráva bude nahlášena správci vašeho domovského serveru. Nebude si moci přečíst žádné šifrované zprávy." diff --git a/features/messages/impl/src/main/res/values-cy/translations.xml b/features/messages/impl/src/main/res/values-cy/translations.xml index eb277d2f75..16841b55f6 100644 --- a/features/messages/impl/src/main/res/values-cy/translations.xml +++ b/features/messages/impl/src/main/res/values-cy/translations.xml @@ -8,6 +8,9 @@ "Wynebau Hapus a Phobl" "Teithio a Llefydd" "Symbolau" + "Efallai na fydd capsiynau yn weladwy i bobl sy\'n defnyddio apiau hŷn." + "Wedi methu â phrosesu cyfryngau i\'w llwytho, ceisiwch eto." + "Wedi methu llwytho cyfryngau, ceisiwch eto." "Rhwystro defnyddiwr" "Gwiriwch a ydych am guddio\'r holl negeseuon presennol ac yn y dyfodol gan y defnyddiwr hwn" "Bydd y neges hon yn cael ei hadrodd i weinyddwr eich gweinyddwr cartref. Fyddan nhw ddim yn gallu darllen unrhyw negeseuon wedi\'u hamgryptio." diff --git a/features/messages/impl/src/main/res/values-da/translations.xml b/features/messages/impl/src/main/res/values-da/translations.xml index a249db99f3..bd5fc09b9e 100644 --- a/features/messages/impl/src/main/res/values-da/translations.xml +++ b/features/messages/impl/src/main/res/values-da/translations.xml @@ -8,6 +8,12 @@ "Smileys og mennesker" "Rejser og steder" "Symboler" + "Billedtekster er muligvis ikke synlige for personer, der bruger ældre apps." + "Filen kunne ikke uploades." + "Det lykkedes ikke at behandle medier til upload. Prøv venligst igen." + "Upload af medier mislykkedes. Prøv igen." + "Den maksimalt tilladte filstørrelse er %1$s ." + "Filen er for stor til at kunne uploades." "Bloker bruger" "Marker, hvis du vil skjule alle nuværende og fremtidige beskeder fra denne bruger" "Denne meddelelse vil blive indberettet til administratoren af din hjemmeserver. De vil ikke være i stand til at læse nogen krypterede meddelelser." diff --git a/features/messages/impl/src/main/res/values-de/translations.xml b/features/messages/impl/src/main/res/values-de/translations.xml index 4b2d3cff0c..a2bac9fbf2 100644 --- a/features/messages/impl/src/main/res/values-de/translations.xml +++ b/features/messages/impl/src/main/res/values-de/translations.xml @@ -8,6 +8,9 @@ "Smileys & Menschen" "Reisen & Orte" "Symbole" + "Bildunterschriften sind für Nutzer älterer Apps möglicherweise nicht sichtbar." + "Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut." + "Das Hochladen der Medien ist fehlgeschlagen. Bitte versuche es erneut." "Nutzer blockieren" "Prüfen Sie, ob Sie alle aktuellen und zukünftigen Nachrichten dieses Nutzers ausblenden wollen" "Diese Nachricht wird dem Administrator ihres Homeservers gemeldet. Dieser kann allerdings keine verschlüsselten Nachrichten lesen." diff --git a/features/messages/impl/src/main/res/values-el/translations.xml b/features/messages/impl/src/main/res/values-el/translations.xml index 2b64d3ce98..1bee722e63 100644 --- a/features/messages/impl/src/main/res/values-el/translations.xml +++ b/features/messages/impl/src/main/res/values-el/translations.xml @@ -8,6 +8,9 @@ "Φατσούλες & Άνθρωποι" "Ταξίδια & Μέρη" "Σύμβολα" + "Οι λεζάντες ενδέχεται να μην είναι ορατές σε άτομα που χρησιμοποιούν παλαιότερες εφαρμογές." + "Αποτυχία μεταφόρτωσης μέσου, δοκίμασε ξανά." + "Αποτυχία μεταφόρτωσης πολυμέσων, δοκίμασε ξανά." "Αποκλεισμός χρήστη" "Επέλεξε εάν θες να αποκρύψεις όλα τα τρέχοντα και μελλοντικά μηνύματα από αυτόν τον χρήστη" "Αυτό το μήνυμα θα αναφερθεί στον διαχειριστή του οικιακού διακομιστή σας. Δεν θα μπορεί να διαβάσει κρυπτογραφημένα μηνύματα." diff --git a/features/messages/impl/src/main/res/values-es/translations.xml b/features/messages/impl/src/main/res/values-es/translations.xml index 54b15f66a5..e8f639fd21 100644 --- a/features/messages/impl/src/main/res/values-es/translations.xml +++ b/features/messages/impl/src/main/res/values-es/translations.xml @@ -8,6 +8,9 @@ "Emojis y personas" "Viajes y lugares" "Símbolos" + "Es posible que las leyendas no sean visibles para las personas que usan aplicaciones más antiguas." + "Error al procesar el contenido multimedia, por favor inténtalo de nuevo." + "Error al subir el contenido multimedia, por favor inténtalo de nuevo." "Bloquear usuario" "Marca esta casilla si quieres ocultar todos los mensajes actuales y futuros de este usuario" "Se denunciará este mensaje al administrador de tu servidor base. No será capaz de leer ningún mensaje cifrado." diff --git a/features/messages/impl/src/main/res/values-et/translations.xml b/features/messages/impl/src/main/res/values-et/translations.xml index 6d369a083a..f6ed132375 100644 --- a/features/messages/impl/src/main/res/values-et/translations.xml +++ b/features/messages/impl/src/main/res/values-et/translations.xml @@ -8,6 +8,12 @@ "Emotikonid ja inimesed" "Reisimine ja kohad" "Sümbolid" + "Selgitused ja alapealkirjad ei pruugi olla nähtavad vanemate rakenduste kasutajatele." + "Faili üleslaadimine ei õnnestunud." + "Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti." + "Meediafaili üleslaadimine ei õnnestunud. Palun proovi uuesti." + "Maksimaalne lubatud failisuurus on %1$s." + "Fail on üleslaadimiseks liiga suur" "Blokeeri kasutaja" "Vali see eelistus, kui sa soovid peita selle kasutaja kõik senised ja tulevased sõnumid" "Teade selle sõnumi kohta edastatakse sinu koduserveri haldajale. Haldajal ei ole võimalik lugeda krüptitud sõnumite sisu." diff --git a/features/messages/impl/src/main/res/values-eu/translations.xml b/features/messages/impl/src/main/res/values-eu/translations.xml index 34d0a114ee..fdd0b79ab2 100644 --- a/features/messages/impl/src/main/res/values-eu/translations.xml +++ b/features/messages/impl/src/main/res/values-eu/translations.xml @@ -8,6 +8,7 @@ "Irribartxoak eta jendea" "Bidaiak eta tokiak" "Ikurrak" + "Huts egin du multimedia igotzeak, saiatu berriro." "Blokeatu erabiltzailea" "Mezua zure zerbitzariko administratzaileari jakinaraziko zaio. Ezingo dute zifratutako mezurik irakurri." "Edukia salatzeko arrazoia" diff --git a/features/messages/impl/src/main/res/values-fa/translations.xml b/features/messages/impl/src/main/res/values-fa/translations.xml index 589748f59c..205907a3b0 100644 --- a/features/messages/impl/src/main/res/values-fa/translations.xml +++ b/features/messages/impl/src/main/res/values-fa/translations.xml @@ -8,6 +8,8 @@ "شکلک‌ها و افراد" "سفر و مکان‌ها" "نمادها" + "پردازش رسانه برای بارگذاری شکست خورد. لطفاً دوباره تلاش کنید." + "بارگذاری رسانه شکست خورد. لطفاً دوباره تلاش کنید." "انسداد کاربر" "اگر می‌خواهید همه پیام‌های فعلی و آینده را از این کاربر را پنهان کنید، علامت بزنید" "این پیام به مدیر کارساز خانگی شما گزارش خواهد شد. آنها قادر به خواندن پیام های رمزگذاری شده نخواهند بود." diff --git a/features/messages/impl/src/main/res/values-fi/translations.xml b/features/messages/impl/src/main/res/values-fi/translations.xml index 3c09463742..0f4a273f0f 100644 --- a/features/messages/impl/src/main/res/values-fi/translations.xml +++ b/features/messages/impl/src/main/res/values-fi/translations.xml @@ -8,6 +8,12 @@ "Hymiöt ja ihmiset" "Matkustaminen ja paikat" "Symbolit" + "Kuvatekstit eivät välttämättä näy ihmisille, jotka käyttävät vanhempia sovelluksia." + "Tiedostoa ei voitu lähettää." + "Median käsittely epäonnistui, yritä uudelleen." + "Median lähettäminen epäonnistui, yritä uudelleen." + "Suurin sallittu tiedostokoko on %1$s." + "Tiedosto on liian suuri lähetettäväksi" "Estä käyttäjä" "Valitse tämä, jos haluat piilottaa kaikki nykyiset ja tulevat viestit tältä käyttäjältä" "Tämä viesti ilmoitetaan kotipalvelimesi ylläpitäjälle. Ylläpitäjä ei pysty lukemaan salattuja viestejä." diff --git a/features/messages/impl/src/main/res/values-fr/translations.xml b/features/messages/impl/src/main/res/values-fr/translations.xml index 87465ddf36..c5b80c02f6 100644 --- a/features/messages/impl/src/main/res/values-fr/translations.xml +++ b/features/messages/impl/src/main/res/values-fr/translations.xml @@ -8,6 +8,12 @@ "Émoticônes et personnes" "Voyages & lieux" "Symboles" + "Les légendes peuvent ne pas être visibles pour les utilisateurs d’anciennes applications." + "Le fichier n’a pas pu être envoyé." + "Échec du traitement des médias à télécharger, veuillez réessayer." + "Échec du téléchargement du média, veuillez réessayer." + "La taille maximale autorisée pour les fichiers est de %1$s." + "Le fichier est trop volumineux pour être envoyé." "Bloquer l’utilisateur" "Cochez si vous souhaitez masquer tous les messages actuels et futurs de cet utilisateur." "Ce message sera signalé à l’administrateur de votre serveur d’accueil. Il ne pourra lire aucun message chiffré." diff --git a/features/messages/impl/src/main/res/values-hu/translations.xml b/features/messages/impl/src/main/res/values-hu/translations.xml index 9e7de8616d..a37858681d 100644 --- a/features/messages/impl/src/main/res/values-hu/translations.xml +++ b/features/messages/impl/src/main/res/values-hu/translations.xml @@ -8,6 +8,12 @@ "Mosolyok és emberek" "Utazás és helyek" "Szimbólumok" + "Előfordulhat, hogy a feliratok nem láthatók a régebbi alkalmazásokat használók számára." + "A fájl nem tölthető fel." + "Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra." + "Nem sikerült a média feltöltése, próbálja újra." + "A maximálisan megengedett fájlméret: %1$s ." + "A fájl túl nagy a feltöltéshez" "Felhasználó letiltása" "Jelölje be, ha el akarja rejteni az összes jelenlegi és jövőbeli üzenetet ettől a felhasználótól" "Ez az üzenet jelentve lesz a Matrix-kiszolgáló adminisztrátorának. Nem fogja tudni elolvasni a titkosított üzeneteket." diff --git a/features/messages/impl/src/main/res/values-in/translations.xml b/features/messages/impl/src/main/res/values-in/translations.xml index 253e521c88..1be1dba94c 100644 --- a/features/messages/impl/src/main/res/values-in/translations.xml +++ b/features/messages/impl/src/main/res/values-in/translations.xml @@ -8,6 +8,9 @@ "Senyuman & Orang" "Wisata & Tempat" "Simbol" + "Keterangan mungkin tidak terlihat oleh orang yang menggunakan aplikasi lama." + "Gagal memproses media untuk diunggah, silakan coba lagi." + "Gagal mengunggah media, silakan coba lagi." "Blokir pengguna" "Centang jika Anda ingin menyembunyikan semua pesan saat ini dan yang akan datang dari pengguna ini" "Pesan ini akan dilaporkan ke administrator homeserver Anda. Mereka tidak akan dapat membaca pesan terenkripsi apa pun." diff --git a/features/messages/impl/src/main/res/values-it/translations.xml b/features/messages/impl/src/main/res/values-it/translations.xml index 69c6c996a9..f0d7e95766 100644 --- a/features/messages/impl/src/main/res/values-it/translations.xml +++ b/features/messages/impl/src/main/res/values-it/translations.xml @@ -8,6 +8,9 @@ "Faccine & Persone" "Viaggi & Luoghi" "Simboli" + "Le didascalie potrebbero non essere visibili agli utenti di app meno recenti." + "Elaborazione del file multimediale da caricare fallita, riprova." + "Caricamento del file multimediale fallito, riprova." "Blocca utente" "Seleziona se vuoi nascondere tutti i messaggi attuali e futuri di questo utente" "Questo messaggio verrà segnalato all\'amministratore dell\'homeserver. Questi non sarà in grado di leggere i messaggi cifrati." diff --git a/features/messages/impl/src/main/res/values-ka/translations.xml b/features/messages/impl/src/main/res/values-ka/translations.xml index 1c5ad9e8ed..842fa4bf5b 100644 --- a/features/messages/impl/src/main/res/values-ka/translations.xml +++ b/features/messages/impl/src/main/res/values-ka/translations.xml @@ -8,6 +8,8 @@ "ღიმილები & ხალხი" "მოგზაურობა და ადგილები" "სიმბოლოები" + "მედიის ატვირთვა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." + "მედიის ატვირთვა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." "მომხმარებლის დაბლოკვა" "შეამოწმეთ, გსურთ თუ არა ამ მომხმარებლის ყველა მიმდინარე და მომავალი შეტყობინების დამალვა" "ეს შეტყობინება გაგზავნილი იქნება თქვენი სახლის სერვერის ადმინისტრატორისადმი. მას არ ექნება დაშიფვრული შეტყობინებების წაკითხვის შესაძლებლობა." diff --git a/features/messages/impl/src/main/res/values-lt/translations.xml b/features/messages/impl/src/main/res/values-lt/translations.xml index 0b5c33adeb..627d431748 100644 --- a/features/messages/impl/src/main/res/values-lt/translations.xml +++ b/features/messages/impl/src/main/res/values-lt/translations.xml @@ -8,6 +8,8 @@ "Šypsenėlės ir Žmonės" "Kelionės ir Vietovės" "Simboliai" + "Nepavyko apdoroti įkeliamos laikmenos, bandykite dar kartą." + "Nepavyko įkelti laikmenos, pabandykite dar kartą." "Blokuoti vartotoją" "Pažymėkite, jei norite paslėpti visas esamas ir būsimas šio vartotojo žinutes" "Apie šią žinutę bus pranešta Jūsų serverio administracijai. Jie negalės perskaityti jokių užšifruotų žinučių." diff --git a/features/messages/impl/src/main/res/values-nb/translations.xml b/features/messages/impl/src/main/res/values-nb/translations.xml index acb6762a1b..18bf54b1fd 100644 --- a/features/messages/impl/src/main/res/values-nb/translations.xml +++ b/features/messages/impl/src/main/res/values-nb/translations.xml @@ -8,6 +8,9 @@ "Smilefjes og mennesker" "Reising og steder" "Symboler" + "Teksting er kanskje ikke synlig for personer som bruker eldre apper." + "Kunne ikke behandle medier for opplasting, vennligst prøv igjen." + "Opplasting av medier mislyktes, vennligst prøv igjen." "Blokker bruker" "Kryss av for om du vil skjule alle nåværende og fremtidige meldinger fra denne brukeren" "Denne meldingen vil bli rapportert til hjemmeserverens administratorer. De vil ikke kunne lese noen krypterte meldinger." diff --git a/features/messages/impl/src/main/res/values-nl/translations.xml b/features/messages/impl/src/main/res/values-nl/translations.xml index 3cfd37be19..afc25d2a24 100644 --- a/features/messages/impl/src/main/res/values-nl/translations.xml +++ b/features/messages/impl/src/main/res/values-nl/translations.xml @@ -8,6 +8,8 @@ "Smileys & Mensen" "Reizen & Locaties" "Symbolen" + "Het verwerken van media voor uploaden is mislukt. Probeer het opnieuw." + "Het uploaden van media is mislukt. Probeer het opnieuw." "Gebruiker blokkeren" "Vink aan als je alle huidige en toekomstige berichten van deze gebruiker wilt verbergen" "Dit bericht wordt gerapporteerd aan de beheerder van je homeserver. Ze zullen geen versleutelde berichten kunnen lezen." diff --git a/features/messages/impl/src/main/res/values-pl/translations.xml b/features/messages/impl/src/main/res/values-pl/translations.xml index 69005d3c77..b7925c7865 100644 --- a/features/messages/impl/src/main/res/values-pl/translations.xml +++ b/features/messages/impl/src/main/res/values-pl/translations.xml @@ -8,6 +8,12 @@ "Buźki i osoby" "Podróż i miejsca" "Symbole" + "Opis może być niedostępny dla osób korzystających ze starszej wersji aplikacji." + "Nie udało się przesłać pliku." + "Przetwarzanie multimediów do przesłania nie powiodło się, spróbuj ponownie." + "Przesyłanie multimediów nie powiodło się, spróbuj ponownie." + "Maksymalny dozwolony rozmiar pliku to %1$s." + "Plik jest za duży, aby go przesłać." "Zablokuj użytkownika" "Sprawdź, czy chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika." "Ta wiadomość zostanie zgłoszona do administratora Twojego serwera domowego. Nie będzie mógł on przeczytać żadnych zaszyfrowanych wiadomości." diff --git a/features/messages/impl/src/main/res/values-pt-rBR/translations.xml b/features/messages/impl/src/main/res/values-pt-rBR/translations.xml index 9688adc49d..190b50d2ad 100644 --- a/features/messages/impl/src/main/res/values-pt-rBR/translations.xml +++ b/features/messages/impl/src/main/res/values-pt-rBR/translations.xml @@ -8,6 +8,9 @@ "Sorrisos & Pessoas" "Viagens & Lugares" "Símbolos" + "As legendas podem não ser visíveis para pessoas que usam aplicativos mais antigos." + "Falha ao processar mídia para upload. Tente novamente." + "Falha ao enviar mídia. Tente novamente." "Bloquear usuário" "Marque se você deseja ocultar todas as mensagens atuais e futuras desse usuário" "Essa mensagem será reportada ao administrador do seu homeserver. Eles não conseguirão ler nenhuma mensagem criptografada." diff --git a/features/messages/impl/src/main/res/values-pt/translations.xml b/features/messages/impl/src/main/res/values-pt/translations.xml index 4759a6aeb9..4ed5faff55 100644 --- a/features/messages/impl/src/main/res/values-pt/translations.xml +++ b/features/messages/impl/src/main/res/values-pt/translations.xml @@ -8,6 +8,12 @@ "Caras e Pessoas" "Viagens e Lugares" "Símbolos" + "As legendas poderão não ser visíveis em versões mais antigas da aplicação." + "Não foi possível enviar o ficheiro" + "Falha ao processar multimédia para carregamento, por favor tente novamente." + "Falhar ao carregar multimédia, por favor tente novamente." + "O tamanho máximo permitido é %1$s." + "O ficheiro é demasiado grande para enviar" "Bloquear utilizador" "Ativar para ocultar todas as atuais e futuras mensagens deste utilizador" "Esta mensagem será denunciada ao administrador do teu servidor. Porém, não lhe será possível ler quaisquer mensagens cifradas." diff --git a/features/messages/impl/src/main/res/values-ro/translations.xml b/features/messages/impl/src/main/res/values-ro/translations.xml index 9b44893ce2..3ca5002f38 100644 --- a/features/messages/impl/src/main/res/values-ro/translations.xml +++ b/features/messages/impl/src/main/res/values-ro/translations.xml @@ -8,6 +8,8 @@ "Fețe zâmbitoare & Oameni" "Călătorii & Locuri" "Simboluri" + "Procesarea datelor media a eșuat, vă rugăm să încercați din nou." + "Încărcarea fișierelor media a eșuat, încercați din nou." "Blocați utilizatorul" "Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator" "Acest mesaj va fi raportat administratorilor homeserver-ului tau. Ei nu vor putea citi niciun mesaj criptat." diff --git a/features/messages/impl/src/main/res/values-ru/translations.xml b/features/messages/impl/src/main/res/values-ru/translations.xml index b8c3cc237f..61d1efa0b8 100644 --- a/features/messages/impl/src/main/res/values-ru/translations.xml +++ b/features/messages/impl/src/main/res/values-ru/translations.xml @@ -8,6 +8,9 @@ "Улыбки и люди" "Путешествия и места" "Символы" + "Подпись может быть не видна пользователям старых приложений." + "Не удалось обработать медиафайл для загрузки, попробуйте еще раз." + "Не удалось загрузить медиафайлы, попробуйте еще раз." "Заблокировать пользователя" "Отметьте, хотите ли вы скрыть все текущие и будущие сообщения от этого пользователя" "Это сообщение будет передано администратору вашего домашнего сервера. Они не смогут прочитать зашифрованные сообщения." diff --git a/features/messages/impl/src/main/res/values-sk/translations.xml b/features/messages/impl/src/main/res/values-sk/translations.xml index 23c664c5d4..834ad4111a 100644 --- a/features/messages/impl/src/main/res/values-sk/translations.xml +++ b/features/messages/impl/src/main/res/values-sk/translations.xml @@ -8,6 +8,12 @@ "Smajlíky a ľudia" "Cestovanie a miesta" "Symboly" + "Titulky nemusia byť viditeľné pre ľudí používajúcich staršie aplikácie." + "Súbor sa nepodarilo nahrať." + "Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova." + "Nepodarilo sa nahrať médiá, skúste to prosím znova." + "Maximálna povolená veľkosť súboru je %1$s." + "Súbor je príliš veľký na nahratie" "Zablokovať používateľa" "Označte, či chcete skryť všetky aktuálne a budúce správy od tohto používateľa" "Táto správa bude nahlásená správcovi vášho domovského servera. Nebude môcť prečítať žiadne šifrované správy." diff --git a/features/messages/impl/src/main/res/values-sv/translations.xml b/features/messages/impl/src/main/res/values-sv/translations.xml index 3c174f3c00..5ecb88af84 100644 --- a/features/messages/impl/src/main/res/values-sv/translations.xml +++ b/features/messages/impl/src/main/res/values-sv/translations.xml @@ -8,6 +8,9 @@ "Smileys & personer" "Resor & platser" "Symboler" + "Bildtexter kanske inte är synliga för personer som använder äldre appar." + "Misslyckades att bearbeta media för uppladdning, vänligen pröva igen." + "Misslyckades att ladda upp media, vänligen pröva igen." "Blockera användare" "Markera om du vill dölja alla nuvarande och framtida meddelanden från denna användare" "Det här meddelandet kommer att rapporteras till din hemservers administratör. Denne kommer inte att kunna läsa några krypterade meddelanden." diff --git a/features/messages/impl/src/main/res/values-tr/translations.xml b/features/messages/impl/src/main/res/values-tr/translations.xml index de962f72ee..fbd425eee4 100644 --- a/features/messages/impl/src/main/res/values-tr/translations.xml +++ b/features/messages/impl/src/main/res/values-tr/translations.xml @@ -8,6 +8,9 @@ "İfadeler ve İnsanlar" "Seyahat ve Yerler" "Simgeler" + "Açıklamalar, eski uygulamaları kullanan kişiler tarafından görülemeyebilir." + "Medya yüklenemedi, lütfen tekrar deneyin." + "Medya yüklenemedi, lütfen tekrar deneyin." "Kullanıcıyı engelle" "Bu kullanıcıdan gelen mevcut ve gelecekteki tüm mesajları gizlemek isteyip istemediğinizi işaretleyin" "Bu mesaj ana sunucunuzun yöneticisine bildirilecektir. Şifrelenmiş mesajları okuyamayacaklardır." diff --git a/features/messages/impl/src/main/res/values-uk/translations.xml b/features/messages/impl/src/main/res/values-uk/translations.xml index b8f9ec0b68..f9a5fc6652 100644 --- a/features/messages/impl/src/main/res/values-uk/translations.xml +++ b/features/messages/impl/src/main/res/values-uk/translations.xml @@ -8,6 +8,12 @@ "Смайлики та люди" "Подорожі та місця" "Символи" + "Користувачі старих застосунків можуть не бачити підписи." + "Файл не може бути вивантажено." + "Не вдалося обробити медіафайл для завантаження, спробуйте ще раз." + "Не вдалося завантажити медіафайл, спробуйте ще раз." + "Максимально дозволений розмір файлу — %1$s." + "Файл завеликий для вивантаження" "Заблокувати користувача" "Перевірте, чи хочете ви приховати всі поточні та майбутні повідомлення від цього користувача" "Це повідомлення буде надіслано адміністраторам вашого домашнього сервера. Вони не зможуть прочитати зашифровані повідомлення." diff --git a/features/messages/impl/src/main/res/values-ur/translations.xml b/features/messages/impl/src/main/res/values-ur/translations.xml index 2b09c2bf0c..3b743a69e8 100644 --- a/features/messages/impl/src/main/res/values-ur/translations.xml +++ b/features/messages/impl/src/main/res/values-ur/translations.xml @@ -8,6 +8,8 @@ "مسکراہٹیں و لوگ" "سفر و مقامات" "علامتیں" + "وسائط کا معالجہ برائے ترفیع ناکام، برائے مہربانی دوبارہ کوشش کریں۔" + "وسائط رفع کرنے میں ناکام، برائے مہربانی دوبارہ کوشش کریں۔" "صارف کو مسدود کریں" "پڑتال کریں کہ کیا آپ اس صارف سے تمام موجودہ اور مستقبلی پیغامات چھپانا چاہتے ہیں۔" "اس پیغام کی اطلاع آپکے منزلی خادم کے منتظم کو دی جائیگی۔ وہ کوئی مرموزکردہ پیغامات نہیں پڑھ سکیں گے۔" diff --git a/features/messages/impl/src/main/res/values-uz/translations.xml b/features/messages/impl/src/main/res/values-uz/translations.xml index 78681df692..9c1b9e0df7 100644 --- a/features/messages/impl/src/main/res/values-uz/translations.xml +++ b/features/messages/impl/src/main/res/values-uz/translations.xml @@ -8,6 +8,8 @@ "Smayllar va odamlar" "Sayohat va Joylar" "Belgilar" + "Mediani yuklab bo‘lmadi, qayta urinib ko‘ring." + "Media yuklanmadi, qayta urinib ko‘ring." "Foydalanuvchini bloklash" "Ushbu foydalanuvchidan barcha joriy va kelajakdagi xabarlarni yashirishni xohlayotganingizni tekshiring" "Bu xabar uy serveringiz administratoriga xabar qilinadi. Ular hech qanday shifrlangan xabarlarni o\'qiy olmaydi." diff --git a/features/messages/impl/src/main/res/values-zh-rTW/translations.xml b/features/messages/impl/src/main/res/values-zh-rTW/translations.xml index bf87bdf3e1..5e4caa1cc6 100644 --- a/features/messages/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/messages/impl/src/main/res/values-zh-rTW/translations.xml @@ -8,6 +8,9 @@ "表情與人物" "旅行與景點" "標誌" + "使用舊應用程式的使用者可能看不到標題。" + "無法處理要上傳的媒體,請再試一次。" + "無法上傳媒體檔案,請稍後再試。" "封鎖使用者" "檢查您是否要隱藏所有來自此使用者的目前及未來的訊息" "此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。" diff --git a/features/messages/impl/src/main/res/values-zh/translations.xml b/features/messages/impl/src/main/res/values-zh/translations.xml index 79c818a52a..74902e353c 100644 --- a/features/messages/impl/src/main/res/values-zh/translations.xml +++ b/features/messages/impl/src/main/res/values-zh/translations.xml @@ -8,6 +8,9 @@ "表情和人物" "旅行和地点" "符号" + "使用旧版应用程序的用户可能无法看到字幕。" + "处理要上传的媒体失败,请重试。" + "上传媒体失败,请重试。" "封禁用户" "请确认是否要隐藏该用户当前和未来的所有信息" "此消息将举报给您的服务器管理员。他们无法读取任何加密消息。" diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index 2d208bef49..dc9e2a1892 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -8,6 +8,15 @@ "Smileys & People" "Travel & Places" "Symbols" + "Captions might not be visible to people using older apps." + "Tap to change the video upload quality" + "The file could not be uploaded." + "Failed processing media to upload, please try again." + "Failed uploading media, please try again." + "The maximum file size allowed is %1$s." + "The file is too large to upload" + "Optimise image quality" + "Processing…" "Block user" "Check if you want to hide all current and future messages from this user" "This message will be reported to your homeserver’s administrator. They will not be able to read any encrypted messages." diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index f24175c33e..001da88bd4 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -325,6 +325,7 @@ class MessagesPresenterTest { val mediaMessage = aMessageEvent( content = TimelineItemImageContent( filename = "image.jpg", + fileSize = 4 * 1024 * 1024L, caption = null, formattedCaption = null, isEdited = false, @@ -365,6 +366,7 @@ class MessagesPresenterTest { val mediaMessage = aMessageEvent( content = TimelineItemVideoContent( filename = "video.mp4", + fileSize = 50 * 1024 * 1024L, caption = null, formattedCaption = null, isEdited = false, @@ -406,6 +408,7 @@ class MessagesPresenterTest { val mediaMessage = aMessageEvent( content = TimelineItemFileContent( filename = "file.pdf", + fileSize = 10 * 1024 * 1024L, caption = null, isEdited = false, formattedCaption = null, 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 292bc89654..18808d9ddf 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 @@ -18,8 +18,12 @@ import io.element.android.features.messages.impl.attachments.preview.Attachments import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter 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.VideoUploadEstimation import io.element.android.features.messages.impl.fixtures.aMediaAttachment +import io.element.android.features.messages.test.attachments.video.FakeMediaOptimizationSelectorPresenterFactory import io.element.android.libraries.androidutils.file.TemporaryUriDeleter +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService @@ -36,15 +40,19 @@ import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaUploadInfo 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.local.LocalMedia import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia -import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.awaitLastSequentialItem +import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaError @@ -53,6 +61,7 @@ import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -64,6 +73,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import java.io.File +@Suppress("LargeClass") @RunWith(RobolectricTestRunner::class) class AttachmentsPreviewPresenterTest { @get:Rule @@ -548,6 +558,111 @@ class AttachmentsPreviewPresenterTest { } } + @Test + fun `present - file too large will display error`() = runTest { + val onDoneListenerResult = lambdaRecorder {} + + val localMedia = aLocalMedia(uri = Uri.EMPTY, mediaInfo = anApkMediaInfo()) + val maxUploadSize = 999L // Set a max upload size smaller than the file size + + val presenter = createAttachmentsPreviewPresenter( + localMedia = localMedia, + room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendFileLambda = { _, _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + } + ), + mediaUploadOnSendQueueEnabled = true, + onDoneListener = onDoneListenerResult, + mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory { + MediaOptimizationSelectorState( + // Set a max upload size smaller than the file size + maxUploadSize = AsyncData.Success(maxUploadSize), + videoSizeEstimations = AsyncData.Uninitialized, + isImageOptimizationEnabled = null, + selectedVideoPreset = null, + displayMediaSelectorViews = false, + displayVideoPresetSelectorDialog = false, + eventSink = {}, + ) + } + ) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + assertThat(localMedia.info.fileSize).isGreaterThan(maxUploadSize) + + consumeItemsUntilPredicate { it.mediaOptimizationSelectorState.maxUploadSize.isSuccess() } + + assertThat(awaitItem().displayFileTooLargeError).isTrue() + } + } + + @Test + fun `present - video size estimations too large will display error`() = runTest { + val onDoneListenerResult = lambdaRecorder {} + + val localMedia = aLocalMedia(uri = Uri.EMPTY, mediaInfo = aVideoMediaInfo()) + + val presenter = createAttachmentsPreviewPresenter( + localMedia = localMedia, + room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendFileLambda = { _, _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + } + ), + mediaUploadOnSendQueueEnabled = true, + onDoneListener = onDoneListenerResult, + mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory { + MediaOptimizationSelectorState( + // Set a max upload size smaller than the file size + maxUploadSize = AsyncData.Success(Long.MAX_VALUE), + videoSizeEstimations = AsyncData.Success( + persistentListOf( + VideoUploadEstimation( + preset = VideoCompressionPreset.LOW, + // The important field is canUpload, it will normally be based on the sizeInBytes + canUpload = false, + sizeInBytes = 0L, + ), + VideoUploadEstimation( + preset = VideoCompressionPreset.STANDARD, + canUpload = false, + sizeInBytes = 0L, + ), + VideoUploadEstimation( + preset = VideoCompressionPreset.HIGH, + canUpload = false, + sizeInBytes = 0L, + ), + ) + ), + isImageOptimizationEnabled = null, + selectedVideoPreset = null, + displayMediaSelectorViews = false, + displayVideoPresetSelectorDialog = false, + eventSink = {}, + ) + } + ) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + consumeItemsUntilPredicate { + it.mediaOptimizationSelectorState.maxUploadSize.isSuccess() && + it.mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull()?.isNotEmpty() == true + } + + assertThat(awaitItem().displayFileTooLargeError).isTrue() + } + } + private fun TestScope.createAttachmentsPreviewPresenter( localMedia: LocalMedia = aLocalMedia( uri = mockMediaUrl, @@ -560,11 +675,27 @@ class AttachmentsPreviewPresenterTest { mediaUploadOnSendQueueEnabled: Boolean = true, allowCaption: Boolean = true, showCaptionCompatibilityWarning: Boolean = true, + displayMediaQualitySelectorViews: Boolean = false, + mediaOptimizationSelectorPresenterFactory: FakeMediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory( + fakePresenter = { + MediaOptimizationSelectorState( + maxUploadSize = AsyncData.Uninitialized, + videoSizeEstimations = AsyncData.Uninitialized, + isImageOptimizationEnabled = null, + selectedVideoPreset = null, + displayMediaSelectorViews = displayMediaQualitySelectorViews, + displayVideoPresetSelectorDialog = false, + eventSink = {}, + ) + } + ), ): AttachmentsPreviewPresenter { return AttachmentsPreviewPresenter( attachment = aMediaAttachment(localMedia), onDoneListener = onDoneListener, - mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()), + mediaSender = MediaSender(mediaPreProcessor, room, { + MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) + }), permalinkBuilder = permalinkBuilder, temporaryUriDeleter = temporaryUriDeleter, featureFlagService = FakeFeatureFlagService( @@ -576,6 +707,7 @@ class AttachmentsPreviewPresenterTest { ), sessionCoroutineScope = this, dispatchers = testCoroutineDispatchers(), + mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory, ) } 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 new file mode 100644 index 0000000000..b529918ddc --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt @@ -0,0 +1,249 @@ +/* + * 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.features.messages.impl.attachments.video + +import android.net.Uri +import android.util.Size +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.test.attachments.video.FakeVideoMetadataExtractor +import io.element.android.features.messages.test.attachments.video.FakeVideoMetadataExtractorFactory +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider +import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo +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 +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.tests.testutils.WarmUpRule +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DefaultMediaOptimizationSelectorPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val mockMediaUrl: Uri = mockk("localMediaUri") + + @Test + fun `present - initial state`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().run { + // Loading + assertThat(videoSizeEstimations).isInstanceOf(AsyncData.Loading::class.java) + assertThat(maxUploadSize).isInstanceOf(AsyncData.Loading::class.java) + // Not loaded yet + assertThat(isImageOptimizationEnabled).isNull() + assertThat(selectedVideoPreset).isNull() + assertThat(displayMediaSelectorViews).isNull() + assertThat(displayVideoPresetSelectorDialog).isFalse() + } + + // The data will load after the first recomposition + awaitItem().run { + assertThat(videoSizeEstimations).isInstanceOf(AsyncData.Success::class.java) + assertThat(maxUploadSize).isInstanceOf(AsyncData.Success::class.java) + assertThat(isImageOptimizationEnabled).isTrue() + assertThat(selectedVideoPreset).isEqualTo(VideoCompressionPreset.STANDARD) + assertThat(displayMediaSelectorViews).isTrue() + assertThat(displayVideoPresetSelectorDialog).isFalse() + } + } + } + + @Test + fun `present - if media info is not video, the video state won't load`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter( + localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading state + skipItems(1) + + // The data will load after the first recomposition + awaitItem().run { + assertThat(videoSizeEstimations).isInstanceOf(AsyncData.Uninitialized::class.java) + assertThat(selectedVideoPreset).isNull() + } + } + } + + @Test + fun `present - OpenVideoPresetSelectorDialog displays it, DismissVideoPresetSelectorDialog hides it`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading state + val eventSink = awaitItem().eventSink + + assertThat(awaitItem().displayVideoPresetSelectorDialog).isFalse() + + eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) + + assertThat(awaitItem().displayVideoPresetSelectorDialog).isTrue() + + eventSink(MediaOptimizationSelectorEvent.DismissVideoPresetSelectorDialog) + + assertThat(awaitItem().displayVideoPresetSelectorDialog).isFalse() + } + } + + @Test + fun `present - SelectVideoPreset sets it and dismisses the dialog`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading state + val eventSink = awaitItem().eventSink + + assertThat(awaitItem().displayVideoPresetSelectorDialog).isFalse() + + eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) + + assertThat(awaitItem().displayVideoPresetSelectorDialog).isTrue() + + eventSink(MediaOptimizationSelectorEvent.SelectVideoPreset(VideoCompressionPreset.LOW)) + + assertThat(awaitItem().selectedVideoPreset).isEqualTo(VideoCompressionPreset.LOW) + assertThat(awaitItem().displayVideoPresetSelectorDialog).isFalse() + } + } + + @Test + fun `present - SelectVideoPreset won't do anything if there is no metadata`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter( + mediaExtractorFactory = FakeVideoMetadataExtractorFactory(FakeVideoMetadataExtractor(sizeResult = Result.failure(AN_EXCEPTION))), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading state + val eventSink = awaitItem().eventSink + + assertThat(awaitItem().videoSizeEstimations.dataOrNull()).isNull() + + eventSink(MediaOptimizationSelectorEvent.SelectVideoPreset(VideoCompressionPreset.LOW)) + + ensureAllEventsConsumed() + } + } + + @Test + fun `present - SelectVideoPreset won't select the preset if it won't allow to upload the video`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter( + mediaExtractorFactory = FakeVideoMetadataExtractorFactory( + FakeVideoMetadataExtractor( + sizeResult = Result.success(Size(10_000, 10_000)), + duration = Result.success(600L) + ) + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading and loaded states + val eventSink = awaitItem().eventSink + skipItems(1) + + // No video results could be uploaded + awaitItem().run { + val videoSizeEstimations = videoSizeEstimations.dataOrNull() + assertThat(videoSizeEstimations).isNotNull() + assertThat(videoSizeEstimations!!.none { it.canUpload }).isTrue() + } + + eventSink(MediaOptimizationSelectorEvent.SelectVideoPreset(VideoCompressionPreset.HIGH)) + + ensureAllEventsConsumed() + } + } + + @Test + fun `present - SelectImageOptimization sets the new value`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter( + localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading state + val eventSink = awaitItem().eventSink + + assertThat(awaitItem().isImageOptimizationEnabled).isTrue() + + eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(false)) + + assertThat(awaitItem().isImageOptimizationEnabled).isFalse() + } + } + + @Test + fun `present - max upload size will default to 100MB if we can't get it`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter( + maxUploadSizeProvider = MaxUploadSizeProvider(FakeMatrixClient(getMaxUploadSizeResult = { Result.failure(AN_EXCEPTION) })) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading and loaded state + skipItems(1) + assertThat(awaitItem().maxUploadSize.dataOrNull()).isEqualTo(1024 * 1024 * 100) + } + } + + @Test + fun `present - with feature flag disabled won't display the media quality selector views`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter( + featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to false)), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading and loaded state + skipItems(1) + assertThat(awaitItem().displayMediaSelectorViews).isFalse() + } + } + + private fun createDefaultMediaOptimizationSelectorPresenter( + localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()), + maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider( + FakeMatrixClient(getMaxUploadSizeResult = { Result.success(1_000L) }), + ), + sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)), + mediaExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(), + ): DefaultMediaOptimizationSelectorPresenter { + return DefaultMediaOptimizationSelectorPresenter( + localMedia = localMedia, + maxUploadSizeProvider = maxUploadSizeProvider, + sessionPreferencesStore = sessionPreferencesStore, + featureFlagService = featureFlagService, + mediaExtractorFactory = mediaExtractorFactory, + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 1d5318f94f..a677207b13 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -74,15 +74,18 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme @@ -1542,6 +1545,7 @@ class MessageComposerPresenterTest { textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(), isRichTextEditorEnabled: Boolean = true, draftService: ComposerDraftService = FakeComposerDraftService(), + mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), ) = MessageComposerPresenter( navigator = navigator, sessionCoroutineScope = this, @@ -1550,7 +1554,11 @@ class MessageComposerPresenterTest { featureFlagService = featureFlagService, sessionPreferencesStore = sessionPreferencesStore, localMediaFactory = localMediaFactory, - mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()), + mediaSender = MediaSender( + preProcessor = mediaPreProcessor, + room = room, + mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) } + ), snackbarDispatcher = snackbarDispatcher, analyticsService = analyticsService, locationService = locationService, @@ -1565,6 +1573,7 @@ class MessageComposerPresenterTest { mentionSpanProvider = mentionSpanProvider, pillificationHelper = textPillificationHelper, suggestionsProcessor = SuggestionsProcessor(), + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index c6c8c7c1e3..9aaa49f4ca 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -231,6 +231,7 @@ class TimelineItemContentMessageFactoryTest { ) val expected = TimelineItemVideoContent( filename = "filename", + fileSize = 0L, caption = null, formattedCaption = null, isEdited = false, @@ -283,6 +284,7 @@ class TimelineItemContentMessageFactoryTest { ) val expected = TimelineItemVideoContent( filename = "body.mp4", + fileSize = 555L, caption = "body.mp4 caption", formattedCaption = SpannedString("formatted"), isEdited = true, @@ -312,6 +314,7 @@ class TimelineItemContentMessageFactoryTest { ) val expected = TimelineItemAudioContent( filename = "filename", + fileSize = 0L, caption = null, formattedCaption = null, isEdited = false, @@ -347,6 +350,7 @@ class TimelineItemContentMessageFactoryTest { ) val expected = TimelineItemAudioContent( filename = "body.mp3", + fileSize = 123L, caption = null, formattedCaption = null, isEdited = true, @@ -369,6 +373,7 @@ class TimelineItemContentMessageFactoryTest { ) val expected = TimelineItemVoiceContent( filename = "filename", + fileSize = 0L, eventId = AN_EVENT_ID, caption = null, formattedCaption = null, @@ -411,6 +416,7 @@ class TimelineItemContentMessageFactoryTest { val expected = TimelineItemVoiceContent( eventId = AN_EVENT_ID, filename = "body.ogg", + fileSize = 123L, caption = null, formattedCaption = null, isEdited = true, @@ -440,6 +446,7 @@ class TimelineItemContentMessageFactoryTest { ) val expected = TimelineItemAudioContent( filename = "filename", + fileSize = 0L, caption = null, formattedCaption = null, isEdited = false, @@ -462,6 +469,7 @@ class TimelineItemContentMessageFactoryTest { ) val expected = TimelineItemImageContent( filename = "filename", + fileSize = 0L, caption = "body", formattedCaption = null, isEdited = false, @@ -492,6 +500,7 @@ class TimelineItemContentMessageFactoryTest { ) val expected = TimelineItemStickerContent( filename = "filename", + fileSize = 8_192L, caption = null, formattedCaption = null, isEdited = false, @@ -540,6 +549,7 @@ class TimelineItemContentMessageFactoryTest { ) val expected = TimelineItemImageContent( filename = "body.jpg", + fileSize = 888L, caption = "body.jpg caption", formattedCaption = SpannedString("formatted"), isEdited = true, @@ -568,6 +578,7 @@ class TimelineItemContentMessageFactoryTest { ) val expected = TimelineItemFileContent( filename = "filename", + fileSize = 0L, caption = null, formattedCaption = null, isEdited = false, @@ -609,6 +620,7 @@ class TimelineItemContentMessageFactoryTest { ) val expected = TimelineItemFileContent( filename = "body.pdf", + fileSize = 123L, caption = null, formattedCaption = null, isEdited = true, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt index 3eb3210a63..9f9b8526a1 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt @@ -26,13 +26,14 @@ 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.mediaplayer.test.FakeMediaPlayer +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.aPermissionsState import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory -import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent @@ -72,7 +73,11 @@ class VoiceMessageComposerPresenterTest { }, ) private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() } - private val mediaSender = MediaSender(mediaPreProcessor, joinedRoom, InMemorySessionPreferencesStore()) + private val mediaSender = MediaSender( + preProcessor = mediaPreProcessor, + room = joinedRoom, + mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) }, + ) private val messageComposerContext = FakeMessageComposerContext() companion object { diff --git a/features/messages/test/build.gradle.kts b/features/messages/test/build.gradle.kts index 8cd32d58cf..5e98674f4e 100644 --- a/features/messages/test/build.gradle.kts +++ b/features/messages/test/build.gradle.kts @@ -14,6 +14,8 @@ android { } dependencies { - api(projects.features.messages.api) + api(projects.features.messages.impl) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.mediaviewer.api) + implementation(projects.libraries.preferences.api) } 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 new file mode 100644 index 0000000000..780638a205 --- /dev/null +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeMediaOptimizationSelectorPresenterFactory.kt @@ -0,0 +1,31 @@ +/* + * 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.features.messages.test.attachments.video + +import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter +import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.mediaviewer.api.local.LocalMedia + +class FakeMediaOptimizationSelectorPresenterFactory( + private val fakePresenter: MediaOptimizationSelectorPresenter = MediaOptimizationSelectorPresenter { + MediaOptimizationSelectorState( + maxUploadSize = AsyncData.Uninitialized, + videoSizeEstimations = AsyncData.Uninitialized, + isImageOptimizationEnabled = null, + selectedVideoPreset = null, + displayMediaSelectorViews = null, + displayVideoPresetSelectorDialog = false, + eventSink = {}, + ) + } +) : MediaOptimizationSelectorPresenter.Factory { + override fun create(localMedia: LocalMedia): MediaOptimizationSelectorPresenter { + return fakePresenter + } +} diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeVideoMetadataExtractor.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeVideoMetadataExtractor.kt new file mode 100644 index 0000000000..54b27ac675 --- /dev/null +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeVideoMetadataExtractor.kt @@ -0,0 +1,31 @@ +/* + * 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.features.messages.test.attachments.video + +import android.net.Uri +import android.util.Size +import io.element.android.features.messages.impl.attachments.video.VideoMetadataExtractor + +class FakeVideoMetadataExtractor( + private val sizeResult: Result = Result.success(Size(1, 1)), + private val duration: Result = Result.success(1L), +) : VideoMetadataExtractor { + override fun getSize(): Result = sizeResult + + override fun getDuration(): Result = duration + + override fun close() = Unit +} + +class FakeVideoMetadataExtractorFactory( + private val fakeVideoMetadataExtractor: FakeVideoMetadataExtractor = FakeVideoMetadataExtractor(), +) : VideoMetadataExtractor.Factory { + override fun create(uri: Uri): VideoMetadataExtractor { + return fakeVideoMetadataExtractor + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt index fc78ae561a..d685402055 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt @@ -8,11 +8,14 @@ package io.element.android.features.preferences.impl.advanced import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset sealed interface AdvancedSettingsEvents { data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetCompressMedia(val compress: Boolean) : AdvancedSettingsEvents + data class SetCompressImages(val compress: Boolean) : AdvancedSettingsEvents + data class SetVideoUploadQuality(val videoPreset: VideoCompressionPreset) : AdvancedSettingsEvents data class SetTheme(val theme: ThemeOption) : AdvancedSettingsEvents data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : AdvancedSettingsEvents data class SetHideInviteAvatars(val value: Boolean) : AdvancedSettingsEvents diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt index be5f11116c..152e71901c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt @@ -11,15 +11,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import io.element.android.compound.theme.Theme import io.element.android.compound.theme.mapToTheme import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import javax.inject.Inject @@ -29,6 +33,7 @@ class AdvancedSettingsPresenter @Inject constructor( private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val featureFlagService: FeatureFlagService, ) : Presenter { @Composable override fun present(): AdvancedSettingsState { @@ -38,9 +43,6 @@ class AdvancedSettingsPresenter @Inject constructor( val isSharePresenceEnabled by remember { sessionPreferencesStore.isSharePresenceEnabled() }.collectAsState(initial = true) - val doesCompressMedia by remember { - sessionPreferencesStore.doesCompressMedia() - }.collectAsState(initial = true) val theme = remember { appPreferencesStore.getThemeFlow().mapToTheme() }.collectAsState(initial = Theme.System) @@ -57,6 +59,28 @@ class AdvancedSettingsPresenter @Inject constructor( } } + val hasSplitMediaQualityOptions by produceState(null) { + value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality) + } + + val mediaOptimizationState by produceState(null) { + val hasSplitMediaQualityOptionsFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.SelectableMediaQuality) + combine( + hasSplitMediaQualityOptionsFlow, + sessionPreferencesStore.doesOptimizeImages(), + sessionPreferencesStore.getVideoCompressionPreset() + ) { hasSplitOptions, compressImages, videoPreset -> + if (hasSplitMediaQualityOptions == true) { + value = MediaOptimizationState.Split( + compressImages = compressImages, + videoPreset = videoPreset, + ) + } else if (hasSplitMediaQualityOptions == false) { + value = MediaOptimizationState.AllMedia(isEnabled = compressImages) + } + }.collect() + } + fun handleEvents(event: AdvancedSettingsEvents) { when (event) { is AdvancedSettingsEvents.SetDeveloperModeEnabled -> sessionCoroutineScope.launch { @@ -66,7 +90,7 @@ class AdvancedSettingsPresenter @Inject constructor( sessionPreferencesStore.setSharePresence(event.enabled) } is AdvancedSettingsEvents.SetCompressMedia -> sessionCoroutineScope.launch { - sessionPreferencesStore.setCompressMedia(event.compress) + sessionPreferencesStore.setOptimizeImages(event.compress) } is AdvancedSettingsEvents.SetTheme -> sessionCoroutineScope.launch { when (event.theme) { @@ -77,13 +101,19 @@ class AdvancedSettingsPresenter @Inject constructor( } is AdvancedSettingsEvents.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value) is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value) + is AdvancedSettingsEvents.SetCompressImages -> sessionCoroutineScope.launch { + sessionPreferencesStore.setOptimizeImages(event.compress) + } + is AdvancedSettingsEvents.SetVideoUploadQuality -> sessionCoroutineScope.launch { + sessionPreferencesStore.setVideoCompressionPreset(event.videoPreset) + } } } return AdvancedSettingsState( isDeveloperModeEnabled = isDeveloperModeEnabled, isSharePresenceEnabled = isSharePresenceEnabled, - doesCompressMedia = doesCompressMedia, + mediaOptimizationState = mediaOptimizationState, theme = themeOption, mediaPreviewConfigState = mediaPreviewConfigState, eventSink = ::handleEvents, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt index ef41ab26b3..1251723f1c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt @@ -11,17 +11,31 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.res.stringResource import io.element.android.libraries.designsystem.components.preferences.DropdownOption +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.ui.strings.CommonStrings data class AdvancedSettingsState( val isDeveloperModeEnabled: Boolean, val isSharePresenceEnabled: Boolean, - val doesCompressMedia: Boolean, + val mediaOptimizationState: MediaOptimizationState?, val theme: ThemeOption, val mediaPreviewConfigState: MediaPreviewConfigState, val eventSink: (AdvancedSettingsEvents) -> Unit ) +sealed interface MediaOptimizationState { + data class AllMedia(val isEnabled: Boolean) : MediaOptimizationState + data class Split( + val compressImages: Boolean, + val videoPreset: VideoCompressionPreset, + ) : MediaOptimizationState + + val shouldCompressImages: Boolean get() = when (this) { + is AllMedia -> isEnabled + is Split -> compressImages + } +} + enum class ThemeOption : DropdownOption { System { @Composable diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt index 42e63134d1..055edd20d2 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt @@ -10,6 +10,7 @@ package io.element.android.features.preferences.impl.advanced import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset open class AdvancedSettingsStateProvider : PreviewParameterProvider { override val values: Sequence @@ -17,18 +18,22 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider Unit + is MediaOptimizationState.AllMedia -> { + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_title)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_description)) + }, + trailingContent = ListItemContent.Switch( + checked = compressImages ?: false, + ), + onClick = { + val newValue = !(compressImages ?: false) + analyticsService.captureInteraction( + if (newValue) { + Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled + } else { + Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled + } + ) + state.eventSink(AdvancedSettingsEvents.SetCompressMedia(newValue)) } ) - state.eventSink(AdvancedSettingsEvents.SetCompressMedia(newValue)) } - ) + is MediaOptimizationState.Split -> { + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_title)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_description)) + }, + trailingContent = ListItemContent.Switch( + checked = compressImages ?: false, + ), + onClick = { + val newValue = !(compressImages ?: false) + analyticsService.captureInteraction( + if (newValue) { + Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled + } else { + Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled + } + ) + state.eventSink(AdvancedSettingsEvents.SetCompressMedia(newValue)) + } + ) + + var displaySelectorDialog by remember { mutableStateOf(false) } + + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_title)) + }, + supportingContent = { + val description = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_description) + val quality = when (state.mediaOptimizationState.videoPreset) { + VideoCompressionPreset.LOW -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_low) + VideoCompressionPreset.STANDARD -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_standard) + VideoCompressionPreset.HIGH -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_high) + } + val descriptionWithValue = remember(quality) { + String.format(description, quality) + } + Text(text = descriptionWithValue) + }, + onClick = { displaySelectorDialog = true }, + ) + + if (displaySelectorDialog) { + VideoQualitySelectorDialog( + selectedPreset = state.mediaOptimizationState.videoPreset, + onSubmit = { preset -> + state.eventSink(AdvancedSettingsEvents.SetVideoUploadQuality(preset)) + displaySelectorDialog = false + }, + onDismiss = { displaySelectorDialog = false }, + ) + } + } + } + ModerationAndSafety(state) } } +@Composable +private fun VideoQualitySelectorDialog( + selectedPreset: VideoCompressionPreset, + onSubmit: (VideoCompressionPreset) -> Unit, + onDismiss: () -> Unit +) { + val videoPresets = VideoCompressionPreset.entries + var localSelectedPreset by remember { mutableStateOf(selectedPreset) } + ListDialog( + title = stringResource(CommonStrings.dialog_video_quality_selector_title), + subtitle = stringResource(CommonStrings.dialog_default_video_quality_selector_subtitle), + onSubmit = { onSubmit(localSelectedPreset) }, + onDismissRequest = onDismiss, + applyPaddingToContents = false, + ) { + for (preset in videoPresets) { + val isSelected = preset == localSelectedPreset + item( + key = preset, + contentType = preset, + ) { + val title = when (preset) { + VideoCompressionPreset.LOW -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_low) + VideoCompressionPreset.STANDARD -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_standard) + VideoCompressionPreset.HIGH -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_high) + } + val subtitle = when (preset) { + VideoCompressionPreset.LOW -> stringResource(CommonStrings.common_video_quality_low_description) + VideoCompressionPreset.STANDARD -> stringResource(CommonStrings.common_video_quality_standard_description) + VideoCompressionPreset.HIGH -> stringResource(CommonStrings.common_video_quality_high_description) + } + ListItem( + headlineContent = { + Text( + text = title, + style = ElementTheme.typography.fontBodyLgMedium, + ) + }, + supportingContent = { + Text( + text = subtitle, + style = ElementTheme.materialTypography.bodyMedium, + color = ElementTheme.colors.textSecondary, + ) + }, + leadingContent = ListItemContent.RadioButton( + selected = isSelected, + ), + onClick = { + localSelectedPreset = preset + }, + ) + } + } + } +} + @Composable private fun ModerationAndSafety( state: AdvancedSettingsState, @@ -202,3 +331,15 @@ private fun ContentToPreview(state: AdvancedSettingsState) { onBackClick = { } ) } + +@Composable +@PreviewsDayNight +internal fun VideoQualitySelectorDialogPreview() { + ElementPreview { + VideoQualitySelectorDialog( + selectedPreset = VideoCompressionPreset.STANDARD, + onSubmit = { /* no-op */ }, + onDismiss = { /* no-op */ } + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt index fd3f96efac..fc8aa0175c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt @@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsPresenter @@ -46,6 +47,7 @@ class EditUserProfilePresenter @AssistedInject constructor( private val mediaPickerProvider: PickerProvider, private val mediaPreProcessor: MediaPreProcessor, private val temporaryUriDeleter: TemporaryUriDeleter, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, permissionsPresenterFactory: PermissionsPresenter.Factory, ) : Presenter { private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) @@ -175,7 +177,7 @@ class EditUserProfilePresenter @AssistedInject constructor( uri = avatarUri, mimeType = MimeTypes.Jpeg, deleteOriginal = false, - compressIfPossible = false, + mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), ).getOrThrow() matrixClient.uploadAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow() } else { diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt index de84e887d6..9bd90aa28c 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt @@ -12,7 +12,10 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.tests.testutils.WarmUpRule @@ -34,13 +37,19 @@ class AdvancedSettingsPresenterTest { with(awaitItem()) { assertThat(isDeveloperModeEnabled).isFalse() assertThat(isSharePresenceEnabled).isTrue() - assertThat(doesCompressMedia).isTrue() + assertThat(mediaOptimizationState).isNull() assertThat(theme).isEqualTo(ThemeOption.System) assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized) assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Uninitialized) } + + // After the initial state, we expect the media optimization state to be set + with(awaitItem()) { + assertThat(mediaOptimizationState).isInstanceOf(MediaOptimizationState.AllMedia::class.java) + assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() + } } } @@ -50,6 +59,9 @@ class AdvancedSettingsPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { + // Skip until the initial data it loaded + skipItems(1) + with(awaitItem()) { assertThat(isDeveloperModeEnabled).isFalse() eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(true)) @@ -70,6 +82,9 @@ class AdvancedSettingsPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { + // Skip until the initial data it loaded + skipItems(1) + with(awaitItem()) { assertThat(isSharePresenceEnabled).isTrue() eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(false)) @@ -90,16 +105,73 @@ class AdvancedSettingsPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { + // Skip until the initial data it loaded + skipItems(1) + with(awaitItem()) { - assertThat(doesCompressMedia).isTrue() + assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() eventSink(AdvancedSettingsEvents.SetCompressMedia(false)) } with(awaitItem()) { - assertThat(doesCompressMedia).isFalse() + assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isFalse() eventSink(AdvancedSettingsEvents.SetCompressMedia(true)) } with(awaitItem()) { - assertThat(doesCompressMedia).isTrue() + assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() + } + } + } + + @Test + fun `present - compress images off on`() = runTest { + val presenter = createAdvancedSettingsPresenter( + featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip until the initial data it loaded + skipItems(1) + + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue() + eventSink(AdvancedSettingsEvents.SetCompressImages(false)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isFalse() + eventSink(AdvancedSettingsEvents.SetCompressImages(true)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue() + } + } + } + + @Test + fun `present - video upload quality selector`() = runTest { + val presenter = createAdvancedSettingsPresenter( + featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip until the initial data it loaded + skipItems(1) + + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.STANDARD) + eventSink(AdvancedSettingsEvents.SetVideoUploadQuality(VideoCompressionPreset.LOW)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.LOW) + eventSink(AdvancedSettingsEvents.SetVideoUploadQuality(VideoCompressionPreset.HIGH)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.HIGH) } } } @@ -110,6 +182,9 @@ class AdvancedSettingsPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { + // Skip until the initial data it loaded + skipItems(1) + with(awaitItem()) { assertThat(theme).isEqualTo(ThemeOption.System) eventSink(AdvancedSettingsEvents.SetTheme(ThemeOption.Dark)) @@ -135,6 +210,9 @@ class AdvancedSettingsPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { + // Skip until the initial data it loaded + skipItems(1) + with(awaitItem()) { assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(true)) @@ -157,6 +235,9 @@ class AdvancedSettingsPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { + // Skip until the initial data it loaded + skipItems(1) + with(awaitItem()) { assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) @@ -184,6 +265,9 @@ class AdvancedSettingsPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { + // Skip until the initial data it loaded + skipItems(1) + with(awaitItem()) { assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) @@ -201,6 +285,9 @@ class AdvancedSettingsPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { + // Skip until the initial data it loaded + skipItems(1) + with(awaitItem()) { assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Loading) assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Success(Unit)) @@ -212,10 +299,12 @@ class AdvancedSettingsPresenterTest { appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), ) = AdvancedSettingsPresenter( appPreferencesStore = appPreferencesStore, sessionPreferencesStore = sessionPreferencesStore, mediaPreviewConfigStateStore = mediaPreviewConfigStateStore, + featureFlagService = featureFlagService, sessionCoroutineScope = this, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt index d5e90047c7..9e105d4da1 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt @@ -115,7 +115,7 @@ class AdvancedSettingsViewTest { val analyticsService = FakeAnalyticsService() rule.setAdvancedSettingsView( state = aAdvancedSettingsState( - doesCompressMedia = true, + mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true), eventSink = eventsRecorder, ), analyticsService = analyticsService diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt index 01e56e8f59..947be54785 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter @@ -78,6 +79,7 @@ class EditUserProfilePresenterTest { matrixUser: MatrixUser = aMatrixUser(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(), + mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), ): EditUserProfilePresenter { return EditUserProfilePresenter( matrixClient = matrixClient, @@ -86,6 +88,7 @@ class EditUserProfilePresenterTest { mediaPreProcessor = fakeMediaPreProcessor, temporaryUriDeleter = temporaryUriDeleter, permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt index fb530e601a..ae324b4027 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt @@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.ui.room.avatarUrl import io.element.android.libraries.matrix.ui.room.rawName import io.element.android.libraries.matrix.ui.room.topic import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsPresenter @@ -49,6 +50,7 @@ class RoomDetailsEditPresenter @Inject constructor( private val mediaPreProcessor: MediaPreProcessor, private val temporaryUriDeleter: TemporaryUriDeleter, permissionsPresenterFactory: PermissionsPresenter.Factory, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, ) : Presenter { private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) private var pendingPermissionRequest = false @@ -223,7 +225,7 @@ class RoomDetailsEditPresenter @Inject constructor( uri = avatarUri, mimeType = MimeTypes.Jpeg, deleteOriginal = false, - compressIfPossible = false, + mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), ).getOrThrow() room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow() } else { diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenterTest.kt index bb6a9b3027..996cbcfece 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenterTest.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_RAW_NAME import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter @@ -77,6 +78,7 @@ class RoomDetailsEditPresenterTest { room: JoinedRoom, permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(), + mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), ): RoomDetailsEditPresenter { return RoomDetailsEditPresenter( room = room, @@ -84,6 +86,7 @@ class RoomDetailsEditPresenterTest { mediaPreProcessor = fakeMediaPreProcessor, permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), temporaryUriDeleter = temporaryUriDeleter, + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, ) } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt index 6dac12a6e2..ea38b0bc70 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt @@ -22,9 +22,9 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender -import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.services.appnavstate.api.ActiveRoomsHolder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -37,8 +37,8 @@ class SharePresenter @AssistedInject constructor( private val shareIntentHandler: ShareIntentHandler, private val matrixClient: MatrixClient, private val mediaPreProcessor: MediaPreProcessor, - private val sessionPreferencesStore: SessionPreferencesStore, private val activeRoomsHolder: ActiveRoomsHolder, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, ) : Presenter { @AssistedFactory interface Factory { @@ -88,13 +88,14 @@ class SharePresenter @AssistedInject constructor( val mediaSender = MediaSender( preProcessor = mediaPreProcessor, room = room, - sessionPreferencesStore = sessionPreferencesStore, + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, ) filesToShare .map { fileToShare -> val result = mediaSender.sendMedia( uri = fileToShare.uri, mimeType = fileToShare.mimeType, + mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), ) // If the coroutine was cancelled, destroy the room and rethrow the exception val cancellationException = result.exceptionOrNull() as? CancellationException diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt index 203d5888d1..a9346fbf6e 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt @@ -26,8 +26,8 @@ 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.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor -import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -166,6 +166,7 @@ class SharePresenterTest { matrixClient: MatrixClient = FakeMatrixClient(), mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(), activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(), + mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), ): SharePresenter { return SharePresenter( intent = intent, @@ -173,8 +174,8 @@ class SharePresenterTest { shareIntentHandler = shareIntentHandler, matrixClient = matrixClient, mediaPreProcessor = mediaPreProcessor, - sessionPreferencesStore = InMemorySessionPreferencesStore(), activeRoomsHolder = activeRoomsHolder, + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, ) } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/VideoCompressorHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/VideoCompressorHelper.kt new file mode 100644 index 0000000000..a9aa8cf378 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/VideoCompressorHelper.kt @@ -0,0 +1,46 @@ +/* + * 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.androidutils.media + +import android.util.Size +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * Helper class to calculate the resulting output size and optimal bitrate for video compression. + */ +class VideoCompressorHelper( + /** + * The maximum size (in pixels) for the output video. + * The output will maintain the aspect ratio of the input video. + */ + val maxSize: Int, +) { + /** + * Calculates the output size for video compression based on the input size and [maxSize]. + */ + fun getOutputSize(inputSize: Size): Size { + val resultMajor = min(inputSize.major(), maxSize) + val aspectRatio = inputSize.major().toFloat() / inputSize.minor().toFloat() + return Size(resultMajor, (resultMajor / aspectRatio).roundToInt()) + } + + /** + * Calculates the optimal bitrate for video compression based on the input size and frame rate. + */ + fun calculateOptimalBitrate(inputSize: Size, frameRate: Int): Long { + val outputSize = getOutputSize(inputSize) + val pixelsPerFrame = outputSize.width * outputSize.height + // Apparently, 0.1 bits per pixel is a sweet spot for video compression + val bitsPerPixel = 0.1f + return (pixelsPerFrame * bitsPerPixel * frameRate).toLong() / 1000 + } +} + +internal fun Size.major(): Int = if (width > height) width else height +internal fun Size.minor(): Int = if (width < height) width else height diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index c1bd0bdb80..16a6d6839a 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -184,4 +184,12 @@ enum class FeatureFlags( // False so it's displayed in the developer options screen isFinished = false, ), + SelectableMediaQuality( + key = "feature.selectable_media_quality", + title = "Select media quality per upload", + description = "You can select the media quality for each attachment you upload.", + defaultValue = { false }, + // False so it's displayed in the developer options screen + isFinished = false, + ), } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index a23ee2c14e..e8c346cf9e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -171,6 +171,11 @@ interface MatrixClient { * Return true if Livekit Rtc is supported, i.e. if Element Call is available. */ suspend fun isLivekitRtcSupported(): Boolean + + /** + * Returns the maximum file upload size allowed by the Matrix server. + */ + suspend fun getMaxFileUploadSize(): Result } /** diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 83369b4c59..50c311e56a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -692,6 +692,10 @@ class RustMatrixClient( innerClient.isLivekitRtcSupported() } + override suspend fun getMaxFileUploadSize(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { innerClient.getMaxMediaUploadSize().toLong() } + } + private suspend fun File.getCacheSize( includeCryptoDb: Boolean = false, ): Long = withContext(sessionDispatcher) { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index a36704c342..41ceb72200 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -94,6 +94,7 @@ class FakeMatrixClient( private val canReportRoomLambda: () -> Boolean = { false }, private val isLivekitRtcSupportedLambda: () -> Boolean = { false }, override val ignoredUsersFlow: StateFlow> = MutableStateFlow(persistentListOf()), + private val getMaxUploadSizeResult: () -> Result = { lambdaError() }, ) : MatrixClient { var setDisplayNameCalled: Boolean = false private set @@ -343,4 +344,8 @@ class FakeMatrixClient( override suspend fun isLivekitRtcSupported(): Boolean { return isLivekitRtcSupportedLambda() } + + override suspend fun getMaxFileUploadSize(): Result { + return getMaxUploadSizeResult() + } } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MaxUploadSizeProvider.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MaxUploadSizeProvider.kt new file mode 100644 index 0000000000..884d75e325 --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MaxUploadSizeProvider.kt @@ -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 { + return matrixClient.getMaxFileUploadSize() + } +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfig.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfig.kt new file mode 100644 index 0000000000..bfa6e427c9 --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfig.kt @@ -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) +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt new file mode 100644 index 0000000000..eb13022ae3 --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt @@ -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 +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt index 347197bd2d..9f2f91f8c9 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt @@ -19,7 +19,7 @@ interface MediaPreProcessor { uri: Uri, mimeType: String, deleteOriginal: Boolean, - compressIfPossible: Boolean, + mediaOptimizationConfig: MediaOptimizationConfig, ): Result /** diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt index 0039f87963..a010b892a8 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -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() val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty() @@ -32,14 +30,14 @@ class MediaSender @Inject constructor( suspend fun preProcessMedia( uri: Uri, mimeType: String, + mediaOptimizationConfig: MediaOptimizationConfig, ): Result { - 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 { - 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 diff --git a/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt b/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt index fd6110f700..7ed36ce492 100644 --- a/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt +++ b/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt @@ -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, ) } diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt index 8b93c7e938..e1758e9429 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt @@ -32,8 +32,10 @@ import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach @@ -76,7 +78,7 @@ class AndroidMediaPreProcessor @Inject constructor( uri: Uri, mimeType: String, deleteOriginal: Boolean, - compressIfPossible: Boolean, + mediaOptimizationConfig: MediaOptimizationConfig, ): Result = withContext(coroutineDispatchers.computation) { runCatchingExceptions { val result = when { @@ -85,10 +87,10 @@ class AndroidMediaPreProcessor @Inject constructor( processFile(uri, mimeType) } mimeType.isMimeTypeImage() -> { - val shouldBeCompressed = compressIfPossible && mimeType !in notCompressibleImageTypes + val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes processImage(uri, mimeType, shouldBeCompressed) } - mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, compressIfPossible) + mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, mediaOptimizationConfig.videoCompressionPreset) mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType) else -> processFile(uri, mimeType) } @@ -214,9 +216,9 @@ class AndroidMediaPreProcessor @Inject constructor( } } - private suspend fun processVideo(uri: Uri, mimeType: String?, shouldBeCompressed: Boolean): MediaUploadInfo { + private suspend fun processVideo(uri: Uri, mimeType: String?, videoCompressionPreset: VideoCompressionPreset): MediaUploadInfo { val resultFile = runCatchingExceptions { - videoCompressor.compress(uri, shouldBeCompressed) + videoCompressor.compress(uri, videoCompressionPreset) .onEach { if (it is VideoTranscodingEvent.Progress) { Timber.d("Video compression progress: ${it.value}%") diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProvider.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProvider.kt new file mode 100644 index 0000000000..7642599077 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProvider.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class DefaultMediaOptimizationConfigProvider @Inject constructor( + private val sessionPreferencesStore: SessionPreferencesStore, +) : MediaOptimizationConfigProvider { + override suspend fun get(): MediaOptimizationConfig = MediaOptimizationConfig( + compressImages = sessionPreferencesStore.doesOptimizeImages().first(), + videoCompressionPreset = sessionPreferencesStore.getVideoCompressionPreset().first(), + ) +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressor.kt index efa9042759..c21bd301ef 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressor.kt @@ -11,10 +11,10 @@ import android.content.Context import android.media.MediaCodecInfo import android.media.MediaMetadataRetriever import android.net.Uri +import android.util.Size import androidx.annotation.OptIn import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes -import androidx.media3.common.util.Size import androidx.media3.common.util.UnstableApi import androidx.media3.effect.Presentation import androidx.media3.transformer.Composition @@ -31,6 +31,7 @@ import io.element.android.libraries.androidutils.file.createTmpFile import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay @@ -47,12 +48,12 @@ class VideoCompressor @Inject constructor( @ApplicationContext private val context: Context, ) { @OptIn(UnstableApi::class) - fun compress(uri: Uri, shouldBeCompressed: Boolean): Flow = callbackFlow { + fun compress(uri: Uri, videoCompressionPreset: VideoCompressionPreset): Flow = callbackFlow { val metadata = getVideoMetadata(uri) val videoCompressorConfig = VideoCompressorConfigFactory.create( metadata = metadata, - shouldBeCompressed = shouldBeCompressed + preset = videoCompressionPreset, ) val tmpFile = context.createTmpFile(extension = "mp4") @@ -60,7 +61,7 @@ class VideoCompressor @Inject constructor( val width = metadata?.width ?: Int.MAX_VALUE val height = metadata?.height ?: Int.MAX_VALUE - val videoResizeEffect = videoCompressorConfig.resizer?.let { + val videoResizeEffect = videoCompressorConfig.videoCompressorHelper?.let { val outputSize = it.getOutputSize(Size(width, height)) if (metadata?.rotation == 90 || metadata?.rotation == 270) { // If the video is rotated, we need to swap width and height diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfig.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfig.kt index b3b0e66ccf..0337713f2a 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfig.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfig.kt @@ -7,62 +7,37 @@ package io.element.android.libraries.mediaupload.impl +import android.util.Size import androidx.annotation.OptIn -import androidx.media3.common.util.Size import androidx.media3.common.util.UnstableApi -import androidx.media3.transformer.VideoEncoderSettings +import io.element.android.libraries.androidutils.media.VideoCompressorHelper +import io.element.android.libraries.mediaupload.api.compressorHelper +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import kotlin.math.min -import kotlin.math.roundToInt @OptIn(UnstableApi::class) internal object VideoCompressorConfigFactory { - // Major dimension of 720p - private const val MAX_COMPRESSED_PIXEL_SIZE = 1280 - - // Major dimension of 1080p - private const val MAX_PIXEL_SIZE = 1920 - private const val DEFAULT_FRAME_RATE = 30 fun create( metadata: VideoFileMetadata?, - shouldBeCompressed: Boolean, + preset: VideoCompressionPreset, ): VideoCompressorConfig { val width = metadata?.width?.takeIf { it >= 0 } ?: Int.MAX_VALUE val height = metadata?.height?.takeIf { it >= 0 } ?: Int.MAX_VALUE - val originalBitrate = metadata?.bitrate?.takeIf { it >= 0 } val originalFrameRate = metadata?.frameRate?.takeIf { it >= 0 } ?: DEFAULT_FRAME_RATE - // We only create a resizer if needed - val resizer = when { - shouldBeCompressed && (width > MAX_COMPRESSED_PIXEL_SIZE || height > MAX_COMPRESSED_PIXEL_SIZE) -> VideoResizer(MAX_COMPRESSED_PIXEL_SIZE) - width > MAX_PIXEL_SIZE || height > MAX_PIXEL_SIZE -> VideoResizer(MAX_PIXEL_SIZE) - else -> null - } + val resizer = preset.compressorHelper() // If we are resizing, we also want to reduce the frame rate to the default value (30fps) - val newFrameRate = if (resizer is VideoResizer) { - min(originalFrameRate, DEFAULT_FRAME_RATE) - } else { - originalFrameRate - } + val newFrameRate = min(originalFrameRate, DEFAULT_FRAME_RATE) // If we need to resize the video, we also want to recalculate the bitrate - val newBitrate = if (resizer is VideoResizer) { - val maxSize = resizer.getOutputSize(Size(width, height)) - val pixelsPerFrame = maxSize.width * maxSize.height - val frameRate = newFrameRate - // Apparently, 0.1 bits per pixel is a sweet spot for video compression - val bitsPerPixel = 0.1f - - (pixelsPerFrame * bitsPerPixel * frameRate).toLong() - } else { - originalBitrate - } + val newBitrate = resizer.calculateOptimalBitrate(Size(width, height), newFrameRate) return VideoCompressorConfig( - resizer = resizer, - newBitRate = newBitrate?.toInt() ?: VideoEncoderSettings.NO_VALUE, + videoCompressorHelper = resizer, + newBitRate = newBitrate.toInt(), newFrameRate = newFrameRate, ) } @@ -70,28 +45,7 @@ internal object VideoCompressorConfigFactory { @OptIn(UnstableApi::class) internal data class VideoCompressorConfig( - val resizer: VideoResizer?, + val videoCompressorHelper: VideoCompressorHelper, val newBitRate: Int, val newFrameRate: Int, ) - -@OptIn(UnstableApi::class) -internal class VideoResizer( - val maxSize: Int, -) { - fun getOutputSize(inputSize: Size): Size { - val resultMajor = min(inputSize.major(), maxSize) - val aspectRatio = inputSize.width.toFloat() / inputSize.height.toFloat() - return if (inputSize.width > inputSize.height) { - Size(resultMajor, (resultMajor / aspectRatio).roundToInt()) - } else { - Size((resultMajor * aspectRatio).roundToInt(), resultMajor) - } - } -} - -@OptIn(UnstableApi::class) -internal fun Size.major(): Int = if (width > height) width else height - -@OptIn(UnstableApi::class) -internal fun Size.minor(): Int = if (width < height) width else height diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt index 7f2f8b0ff6..3327a28e82 100644 --- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt @@ -20,8 +20,10 @@ import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ThumbnailInfo import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -41,7 +43,7 @@ import kotlin.time.Duration class AndroidMediaPreProcessorTest { private suspend fun TestScope.process( asset: Asset, - compressIfPossible: Boolean, + mediaOptimizationConfig: MediaOptimizationConfig, sdkIntVersion: Int = Build.VERSION_CODES.P, deleteOriginal: Boolean = false, ): MediaUploadInfo { @@ -57,7 +59,7 @@ class AndroidMediaPreProcessorTest { uri = file.toUri(), mimeType = asset.mimeType, deleteOriginal = deleteOriginal, - compressIfPossible = compressIfPossible, + mediaOptimizationConfig = mediaOptimizationConfig, ) val data = result.getOrThrow() assertThat(data.file.path).endsWith(asset.filename) @@ -70,7 +72,10 @@ class AndroidMediaPreProcessorTest { fun `test processing png`() = runTest { val mediaUploadInfo = process( asset = assetImagePng, - compressIfPossible = true, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), ) val info = mediaUploadInfo as MediaUploadInfo.Image assertThat(info.thumbnailFile).isNotNull() @@ -91,7 +96,10 @@ class AndroidMediaPreProcessorTest { fun `test processing png api Q`() = runTest { val mediaUploadInfo = process( asset = assetImagePng, - compressIfPossible = true, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), sdkIntVersion = Build.VERSION_CODES.Q, ) val info = mediaUploadInfo as MediaUploadInfo.Image @@ -114,7 +122,10 @@ class AndroidMediaPreProcessorTest { fun `test processing png no compression`() = runTest { val mediaUploadInfo = process( asset = assetImagePng, - compressIfPossible = false, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), ) val info = mediaUploadInfo as MediaUploadInfo.Image assertThat(info.thumbnailFile).isNotNull() @@ -136,7 +147,10 @@ class AndroidMediaPreProcessorTest { fun `test processing png and delete`() = runTest { val mediaUploadInfo = process( asset = assetImagePng, - compressIfPossible = false, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), deleteOriginal = true, ) val info = mediaUploadInfo as MediaUploadInfo.Image @@ -161,7 +175,10 @@ class AndroidMediaPreProcessorTest { fun `test processing jpeg`() = runTest { val mediaUploadInfo = process( asset = assetImageJpeg, - compressIfPossible = true, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), ) val info = mediaUploadInfo as MediaUploadInfo.Image assertThat(info.thumbnailFile).isNotNull() @@ -182,7 +199,10 @@ class AndroidMediaPreProcessorTest { fun `test processing jpeg api Q`() = runTest { val mediaUploadInfo = process( asset = assetImageJpeg, - compressIfPossible = true, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), sdkIntVersion = Build.VERSION_CODES.Q, ) val info = mediaUploadInfo as MediaUploadInfo.Image @@ -205,7 +225,10 @@ class AndroidMediaPreProcessorTest { fun `test processing jpeg no compression`() = runTest { val mediaUploadInfo = process( asset = assetImageJpeg, - compressIfPossible = false, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), ) val info = mediaUploadInfo as MediaUploadInfo.Image assertThat(info.thumbnailFile).isNotNull() @@ -227,7 +250,10 @@ class AndroidMediaPreProcessorTest { fun `test processing jpeg and delete`() = runTest { val mediaUploadInfo = process( asset = assetImageJpeg, - compressIfPossible = false, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), deleteOriginal = true, ) val info = mediaUploadInfo as MediaUploadInfo.Image @@ -252,7 +278,10 @@ class AndroidMediaPreProcessorTest { fun `test processing gif`() = runTest { val mediaUploadInfo = process( asset = assetAnimatedGif, - compressIfPossible = true, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), ) val info = mediaUploadInfo as MediaUploadInfo.Image assertThat(info.thumbnailFile).isNotNull() @@ -273,7 +302,10 @@ class AndroidMediaPreProcessorTest { fun `test processing file`() = runTest { val mediaUploadInfo = process( asset = assetText, - compressIfPossible = true, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), ) val info = mediaUploadInfo as MediaUploadInfo.AnyFile assertThat(info.fileInfo).isEqualTo( @@ -291,7 +323,10 @@ class AndroidMediaPreProcessorTest { fun `test processing video`() = runTest { val mediaUploadInfo = process( asset = assetVideo, - compressIfPossible = true, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), ) val info = mediaUploadInfo as MediaUploadInfo.Video assertThat(info.thumbnailFile).isNotNull() @@ -315,7 +350,10 @@ class AndroidMediaPreProcessorTest { fun `test processing video no compression`() = runTest { val mediaUploadInfo = process( asset = assetVideo, - compressIfPossible = false, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.HIGH, + ), ) val info = mediaUploadInfo as MediaUploadInfo.Video // Computing thumbnailFile is failing with Robolectric @@ -341,7 +379,10 @@ class AndroidMediaPreProcessorTest { fun `test processing audio`() = runTest { val mediaUploadInfo = process( asset = assetAudio, - compressIfPossible = true, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), ) val info = mediaUploadInfo as MediaUploadInfo.Audio assertThat(info.audioInfo).isEqualTo( @@ -363,7 +404,10 @@ class AndroidMediaPreProcessorTest { uri = file.toUri(), mimeType = MimeTypes.PlainText, deleteOriginal = false, - compressIfPossible = true, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), ) assertThat(result.isFailure).isTrue() val failure = result.exceptionOrNull() diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfigFactoryTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfigFactoryTest.kt index 5050d14a3e..943e89011f 100644 --- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfigFactoryTest.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfigFactoryTest.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.mediaupload.impl import androidx.media3.transformer.VideoEncoderSettings import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -20,16 +21,16 @@ class VideoCompressorConfigFactoryTest { fun `if we don't have metadata the video will be resized`() { // Given val metadata = null - val shouldBeCompressed = false + val preset = VideoCompressionPreset.STANDARD // When val videoCompressorConfig = VideoCompressorConfigFactory.create( metadata = metadata, - shouldBeCompressed = shouldBeCompressed + preset = preset, ) // Then - assertThat(videoCompressorConfig.resizer).isNotNull() + assertThat(videoCompressorConfig.videoCompressorHelper).isNotNull() assertThat(videoCompressorConfig.newFrameRate).isEqualTo(30) assertThat(videoCompressorConfig.newBitRate).isNotEqualTo(VideoEncoderSettings.NO_VALUE) } @@ -38,71 +39,71 @@ class VideoCompressorConfigFactoryTest { fun `if the video should be compressed and is larger than 720p it will be resized`() { // Given val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 1_000_000, frameRate = 50, rotation = 0) - val shouldBeCompressed = true + val preset = VideoCompressionPreset.STANDARD // When val videoCompressorConfig = VideoCompressorConfigFactory.create( metadata = metadata, - shouldBeCompressed = shouldBeCompressed + preset = preset, ) // Then - assertIsResized(videoCompressorConfig) + assertIsResized(videoCompressorConfig, metadata.width) } @Test fun `if the video should be compressed and is smaller or equal to 720p it will not be resized`() { // Given val metadata = VideoFileMetadata(width = 1280, height = 720, bitrate = 1_000_000, frameRate = 50, rotation = 0) - val shouldBeCompressed = true + val preset = VideoCompressionPreset.STANDARD // When val videoCompressorConfig = VideoCompressorConfigFactory.create( metadata = metadata, - shouldBeCompressed = shouldBeCompressed + preset = preset, ) // Then - assertIsNotResized(videoCompressorConfig) + assertIsNotResized(videoCompressorConfig, 1280) } @Test fun `if the video should not be compressed and is larger than 1080p it will be resized`() { // Given val metadata = VideoFileMetadata(width = 2560, height = 1440, bitrate = 1_000_000, frameRate = 50, rotation = 0) - val shouldBeCompressed = false + val preset = VideoCompressionPreset.HIGH // When val videoCompressorConfig = VideoCompressorConfigFactory.create( metadata = metadata, - shouldBeCompressed = shouldBeCompressed + preset = preset, ) // Then - assertIsResized(videoCompressorConfig) + assertIsResized(videoCompressorConfig, metadata.width) } @Test fun `if the video should not be compressed and is smaller or equal than 1080p it will not be resized`() { // Given val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 1_000_000, frameRate = 50, rotation = 0) - val shouldBeCompressed = false + val preset = VideoCompressionPreset.HIGH // When val videoCompressorConfig = VideoCompressorConfigFactory.create( metadata = metadata, - shouldBeCompressed = shouldBeCompressed + preset = preset, ) // Then - assertIsNotResized(videoCompressorConfig) + assertIsNotResized(videoCompressorConfig, 1920) } - private inline fun assertIsResized(videoCompressorConfig: VideoCompressorConfig) { - assertThat(videoCompressorConfig.resizer).isNotNull() + private inline fun assertIsResized(videoCompressorConfig: VideoCompressorConfig, referenceSize: Int) { + assertThat(videoCompressorConfig.videoCompressorHelper.maxSize).isNotEqualTo(referenceSize) } - private inline fun assertIsNotResized(videoCompressorConfig: VideoCompressorConfig) { - assertThat(videoCompressorConfig.resizer).isNull() + private inline fun assertIsNotResized(videoCompressorConfig: VideoCompressorConfig, referenceSize: Int) { + assertThat(videoCompressorConfig.videoCompressorHelper.maxSize).isEqualTo(referenceSize) } } diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaOptimizationConfigProvider.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaOptimizationConfigProvider.kt new file mode 100644 index 0000000000..6e5d73d305 --- /dev/null +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaOptimizationConfigProvider.kt @@ -0,0 +1,21 @@ +/* + * 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.test + +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +class FakeMediaOptimizationConfigProvider( + val config: MediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ) +) : MediaOptimizationConfigProvider { + override suspend fun get(): MediaOptimizationConfig = config +} 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 87b739d786..cda951ff50 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 @@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.tests.testutils.simulateLongTask @@ -45,7 +46,7 @@ class FakeMediaPreProcessor( uri: Uri, mimeType: String, deleteOriginal: Boolean, - compressIfPossible: Boolean + mediaOptimizationConfig: MediaOptimizationConfig, ): Result = simulateLongTask { processLatch?.await() processCallCount++ diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt index f5440a48b6..da7944e5d1 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt @@ -17,6 +17,7 @@ data class MediaInfo( val filename: String, val caption: String?, val mimeType: String, + val fileSize: Long?, val formattedFileSize: String, val fileExtension: String, val senderId: UserId?, @@ -36,6 +37,7 @@ fun anImageMediaInfo( dateSentFull: String? = null, ): MediaInfo = MediaInfo( filename = "an image file.jpg", + fileSize = 4 * 1024 * 1024, caption = caption, mimeType = MimeTypes.Jpeg, formattedFileSize = "4MB", @@ -57,6 +59,7 @@ fun aVideoMediaInfo( duration: String? = null, ): MediaInfo = MediaInfo( filename = "a video file.mp4", + fileSize = 14 * 1024 * 1024, caption = caption, mimeType = MimeTypes.Mp4, formattedFileSize = "14MB", @@ -78,6 +81,7 @@ fun aPdfMediaInfo( dateSentFull: String? = null, ): MediaInfo = MediaInfo( filename = filename, + fileSize = 23 * 1024 * 1024, caption = caption, mimeType = MimeTypes.Pdf, formattedFileSize = "23MB", @@ -98,6 +102,7 @@ fun anApkMediaInfo( dateSentFull: String? = null, ): MediaInfo = MediaInfo( filename = "an apk file.apk", + fileSize = 50 * 1024 * 1024, caption = null, mimeType = MimeTypes.Apk, formattedFileSize = "50MB", @@ -121,6 +126,7 @@ fun anAudioMediaInfo( duration: String? = null, ): MediaInfo = MediaInfo( filename = filename, + fileSize = 7 * 1024 * 1024, caption = caption, mimeType = MimeTypes.Mp3, formattedFileSize = "7MB", @@ -144,6 +150,7 @@ fun aVoiceMediaInfo( duration: String? = null, ): MediaInfo = MediaInfo( filename = filename, + fileSize = 3 * 1024 * 1024, caption = caption, mimeType = MimeTypes.Ogg, formattedFileSize = "3MB", @@ -165,6 +172,7 @@ fun aTxtMediaInfo( dateSentFull: String? = null, ): MediaInfo = MediaInfo( filename = filename, + fileSize = 2 * 1024, caption = caption, mimeType = MimeTypes.PlainText, formattedFileSize = "2kB", diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt index 64cd9093a2..2a137bb387 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt @@ -46,6 +46,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint eventId = null, mediaInfo = MediaInfo( filename = filename, + fileSize = null, caption = null, mimeType = mimeType, formattedFileSize = "", diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt index 705921db8b..ed39faf080 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt @@ -92,6 +92,7 @@ class EventItemFactory @Inject constructor( eventId = currentTimelineItem.eventId, mediaInfo = MediaInfo( filename = type.filename, + fileSize = type.info?.size, caption = type.caption, mimeType = type.info?.mimetype.orEmpty(), formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), @@ -111,6 +112,7 @@ class EventItemFactory @Inject constructor( eventId = currentTimelineItem.eventId, mediaInfo = MediaInfo( filename = type.filename, + fileSize = type.info?.size, caption = type.caption, mimeType = type.info?.mimetype.orEmpty(), formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), @@ -131,6 +133,7 @@ class EventItemFactory @Inject constructor( eventId = currentTimelineItem.eventId, mediaInfo = MediaInfo( filename = type.filename, + fileSize = type.info?.size, caption = type.caption, mimeType = type.info?.mimetype.orEmpty(), formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), @@ -151,6 +154,7 @@ class EventItemFactory @Inject constructor( eventId = currentTimelineItem.eventId, mediaInfo = MediaInfo( filename = type.filename, + fileSize = type.info?.size, caption = type.caption, mimeType = type.info?.mimetype.orEmpty(), formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), @@ -171,6 +175,7 @@ class EventItemFactory @Inject constructor( eventId = currentTimelineItem.eventId, mediaInfo = MediaInfo( filename = type.filename, + fileSize = type.info?.size, caption = type.caption, mimeType = type.info?.mimetype.orEmpty(), formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), @@ -191,6 +196,7 @@ class EventItemFactory @Inject constructor( eventId = currentTimelineItem.eventId, mediaInfo = MediaInfo( filename = type.filename, + fileSize = type.info?.size, caption = type.caption, mimeType = type.info?.mimetype.orEmpty(), formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt index b7ae566ab1..aa4b2ec53e 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt @@ -87,15 +87,17 @@ class AndroidLocalMediaFactory @Inject constructor( ): LocalMedia { val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream val fileName = name ?: context.getFileName(uri) ?: "" - val fileSize = formattedFileSize ?: fileSizeFormatter.format(context.getFileSize(uri)) + val fileSize = context.getFileSize(uri) + val calculatedFormattedFileSize = formattedFileSize ?: fileSizeFormatter.format(fileSize) val fileExtension = fileExtensionExtractor.extractFromName(fileName) return LocalMedia( uri = uri, info = MediaInfo( mimeType = resolvedMimeType, filename = fileName, + fileSize = fileSize, caption = caption, - formattedFileSize = fileSize, + formattedFileSize = calculatedFormattedFileSize, fileExtension = fileExtension, senderId = senderId, senderName = senderName, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt index 16e6057df6..457907793f 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt @@ -155,6 +155,7 @@ class DefaultEventItemFactoryTest { mediaInfo = MediaInfo( mimeType = MimeTypes.Apk, filename = "filename.apk", + fileSize = 123L, caption = "caption", formattedFileSize = "123 Bytes", fileExtension = "apk", @@ -205,6 +206,7 @@ class DefaultEventItemFactoryTest { mediaInfo = MediaInfo( mimeType = MimeTypes.Jpeg, filename = "filename.jpg", + fileSize = 123L, caption = "caption", formattedFileSize = "123 Bytes", fileExtension = "jpg", @@ -252,6 +254,7 @@ class DefaultEventItemFactoryTest { mediaInfo = MediaInfo( mimeType = MimeTypes.Mp3, filename = "filename.mp3", + fileSize = 123L, caption = "caption", formattedFileSize = "123 Bytes", fileExtension = "mp3", @@ -303,6 +306,7 @@ class DefaultEventItemFactoryTest { mediaInfo = MediaInfo( mimeType = MimeTypes.Mp4, filename = "filename.mp4", + fileSize = 123L, caption = "caption", formattedFileSize = "123 Bytes", fileExtension = "mp4", @@ -354,6 +358,7 @@ class DefaultEventItemFactoryTest { mediaInfo = MediaInfo( mimeType = MimeTypes.Ogg, filename = "filename.ogg", + fileSize = 123L, caption = "caption", formattedFileSize = "123 Bytes", fileExtension = "ogg", @@ -404,6 +409,7 @@ class DefaultEventItemFactoryTest { mediaInfo = MediaInfo( mimeType = MimeTypes.Gif, filename = "filename.gif", + fileSize = 123L, caption = "caption", formattedFileSize = "123 Bytes", fileExtension = "gif", diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt index 20208a55dc..7e9f1870f5 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt @@ -232,6 +232,7 @@ class TimelineMediaGalleryDataSourceTest { eventId = AN_EVENT_ID, mediaInfo = MediaInfo( filename = "body.jpg", + fileSize = 888L, caption = "body.jpg caption", mimeType = MimeTypes.Jpeg, formattedFileSize = "888 Bytes", diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt index 48c636297b..e092f5c624 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt @@ -40,6 +40,8 @@ class AndroidLocalMediaFactoryTest { assertThat(result.info).isEqualTo( MediaInfo( filename = "an image file.jpg", + // MediaFile does not provide file size in this test + fileSize = 0L, caption = null, mimeType = MimeTypes.Jpeg, formattedFileSize = "4MB", diff --git a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt index f1ebbd04e9..a9e962bac4 100644 --- a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt +++ b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt @@ -29,10 +29,16 @@ class FakeLocalMediaFactory( return aLocalMedia(uri = localMediaUri, mediaInfo = mediaInfo) } - override fun createFromUri(uri: Uri, mimeType: String?, name: String?, formattedFileSize: String?): LocalMedia { + override fun createFromUri( + uri: Uri, + mimeType: String?, + name: String?, + formattedFileSize: String? + ): LocalMedia { val safeName = name ?: fallbackName val mediaInfo = MediaInfo( filename = safeName, + fileSize = null, caption = null, mimeType = mimeType ?: fallbackMimeType, formattedFileSize = formattedFileSize ?: fallbackFileSize, diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStore.kt index 47fb86af56..4cc16b3056 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStore.kt @@ -28,8 +28,11 @@ interface SessionPreferencesStore { suspend fun setSkipSessionVerification(skip: Boolean) fun isSessionVerificationSkipped(): Flow - suspend fun setCompressMedia(compress: Boolean) - fun doesCompressMedia(): Flow + suspend fun setOptimizeImages(compress: Boolean) + fun doesOptimizeImages(): Flow + + suspend fun setVideoCompressionPreset(preset: VideoCompressionPreset) + fun getVideoCompressionPreset(): Flow suspend fun clear() } diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/VideoCompressionPreset.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/VideoCompressionPreset.kt new file mode 100644 index 0000000000..d68a53a1a5 --- /dev/null +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/VideoCompressionPreset.kt @@ -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.preferences.api.store + +/** + * Video compression presets to use when processing videos before uploading them. + */ +enum class VideoCompressionPreset { + /** High quality compression, suitable for high-resolution videos. */ + HIGH, + + /** Standard quality compression, suitable for most videos. */ + STANDARD, + + /** Low quality compression, suitable for low-resolution videos or when bandwidth is a concern. */ + LOW +} diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStore.kt index 4b9363da0e..3d6cd266b1 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStore.kt @@ -12,12 +12,15 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.hash.hash +import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -41,7 +44,8 @@ class DefaultSessionPreferencesStore( private val sendTypingNotificationsKey = booleanPreferencesKey("sendTypingNotifications") private val renderTypingNotificationsKey = booleanPreferencesKey("renderTypingNotifications") private val skipSessionVerification = booleanPreferencesKey("skipSessionVerification") - private val compressMedia = booleanPreferencesKey("compressMedia") + private val compressImages = booleanPreferencesKey("compressMedia") + private val compressMediaPreset = stringPreferencesKey("compressMediaPreset") private val dataStoreFile = storeFile(context, sessionId) private val store = PreferenceDataStoreFactory.create( @@ -82,8 +86,12 @@ class DefaultSessionPreferencesStore( override suspend fun setSkipSessionVerification(skip: Boolean) = update(skipSessionVerification, skip) override fun isSessionVerificationSkipped(): Flow = get(skipSessionVerification) { false } - override suspend fun setCompressMedia(compress: Boolean) = update(compressMedia, compress) - override fun doesCompressMedia(): Flow = get(compressMedia) { true } + override suspend fun setOptimizeImages(compress: Boolean) = update(compressImages, compress) + override fun doesOptimizeImages(): Flow = get(compressImages) { true } + + override suspend fun setVideoCompressionPreset(preset: VideoCompressionPreset) = update(compressMediaPreset, preset.name) + override fun getVideoCompressionPreset(): Flow = get(compressMediaPreset) { VideoCompressionPreset.STANDARD.name } + .map { tryOrNull { VideoCompressionPreset.valueOf(it) } ?: VideoCompressionPreset.STANDARD } override suspend fun clear() { dataStoreFile.safeDelete() diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemorySessionPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemorySessionPreferencesStore.kt index 15b93152cb..e5e1923f7b 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemorySessionPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemorySessionPreferencesStore.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.preferences.test import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -19,6 +20,7 @@ class InMemorySessionPreferencesStore( isRenderTypingNotificationsEnabled: Boolean = true, isSessionVerificationSkipped: Boolean = false, doesCompressMedia: Boolean = true, + videoCompressionPreset: VideoCompressionPreset = VideoCompressionPreset.STANDARD, ) : SessionPreferencesStore { private val isSharePresenceEnabled = MutableStateFlow(isSharePresenceEnabled) private val isSendPublicReadReceiptsEnabled = MutableStateFlow(isSendPublicReadReceiptsEnabled) @@ -27,6 +29,7 @@ class InMemorySessionPreferencesStore( private val isRenderTypingNotificationsEnabled = MutableStateFlow(isRenderTypingNotificationsEnabled) private val isSessionVerificationSkipped = MutableStateFlow(isSessionVerificationSkipped) private val doesCompressMedia = MutableStateFlow(doesCompressMedia) + private val videoCompressionPreset = MutableStateFlow(videoCompressionPreset) var clearCallCount = 0 private set @@ -68,9 +71,17 @@ class InMemorySessionPreferencesStore( return isSessionVerificationSkipped } - override suspend fun setCompressMedia(compress: Boolean) = doesCompressMedia.emit(compress) + override suspend fun setOptimizeImages(compress: Boolean) = doesCompressMedia.emit(compress) - override fun doesCompressMedia(): Flow = doesCompressMedia + override fun doesOptimizeImages(): Flow = doesCompressMedia + + override suspend fun setVideoCompressionPreset(preset: VideoCompressionPreset) { + videoCompressionPreset.value = preset + } + + override fun getVideoCompressionPreset(): Flow { + return videoCompressionPreset + } override suspend fun clear() { clearCallCount++ diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/CaptionWarningBottomSheet.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/CaptionWarningBottomSheet.kt index edbbfc0a65..4154d7a594 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/CaptionWarningBottomSheet.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/CaptionWarningBottomSheet.kt @@ -48,7 +48,7 @@ fun CaptionWarningBottomSheet( style = BigIcon.Style.AlertSolid, ) Text( - text = stringResource(CommonStrings.screen_media_upload_preview_caption_warning), + text = stringResource(R.string.screen_media_upload_preview_caption_warning), style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textPrimary, textAlign = TextAlign.Center, diff --git a/libraries/textcomposer/impl/src/main/res/values/localazy.xml b/libraries/textcomposer/impl/src/main/res/values/localazy.xml index 89331da7ed..a9e0ca3540 100644 --- a/libraries/textcomposer/impl/src/main/res/values/localazy.xml +++ b/libraries/textcomposer/impl/src/main/res/values/localazy.xml @@ -28,5 +28,6 @@ "Remove link" "Unindent" "Link" + "Captions might not be visible to people using older apps." "Hold to record" diff --git a/libraries/ui-strings/src/main/res/values-be/translations.xml b/libraries/ui-strings/src/main/res/values-be/translations.xml index fbb2595904..b964fff4c0 100644 --- a/libraries/ui-strings/src/main/res/values-be/translations.xml +++ b/libraries/ui-strings/src/main/res/values-be/translations.xml @@ -297,8 +297,6 @@ "%1$s Android" "Паведаміць аб памылцы з дапамогай Rageshake" "Не ўдалося выбраць носьбіт, паўтарыце спробу." - "Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз." - "Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз." "Націсніце на паведамленне і абярыце «%1$s », каб уключыць сюды." "Замацуеце важныя паведамленні, каб іх можна было лёгка знайсці" diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml index 7ab0058946..5a67d92b4f 100644 --- a/libraries/ui-strings/src/main/res/values-cs/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -383,12 +383,6 @@ Opravdu chcete pokračovat?" "Odstranit %1$s" "Nastavení" "Výběr média se nezdařil, zkuste to prosím znovu." - "Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace." - "Soubor nelze nahrát." - "Nahrání média se nezdařilo, zkuste to prosím znovu." - "Nahrání média se nezdařilo, zkuste to prosím znovu." - "Maximální povolená velikost souboru je %1$s." - "Soubor je pro nahrání příliš velký." "Přidržte zprávu a vyberte „%1$s“, kterou chcete zahrnout sem." "Připněte důležité zprávy, aby je bylo možné snadno najít" diff --git a/libraries/ui-strings/src/main/res/values-cy/translations.xml b/libraries/ui-strings/src/main/res/values-cy/translations.xml index ce3328cf48..bf4c9a67c6 100644 --- a/libraries/ui-strings/src/main/res/values-cy/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cy/translations.xml @@ -391,9 +391,6 @@ Ydych chi\'n siŵr eich bod am barhau?" "Tynnu %1$s" "Gosodiadau" "Wedi methu dewis cyfrwng, ceisiwch eto." - "Efallai na fydd capsiynau yn weladwy i bobl sy\'n defnyddio apiau hŷn." - "Wedi methu â phrosesu cyfryngau i\'w llwytho, ceisiwch eto." - "Wedi methu llwytho cyfryngau, ceisiwch eto." "Pwyswch ar neges a dewis “%1$s” i\'w cynnwys yma." "Pinio negeseuon pwysig fel y mae modd eu darganfod yn hawdd" diff --git a/libraries/ui-strings/src/main/res/values-da/translations.xml b/libraries/ui-strings/src/main/res/values-da/translations.xml index 2992b1a654..7a9270ff4a 100644 --- a/libraries/ui-strings/src/main/res/values-da/translations.xml +++ b/libraries/ui-strings/src/main/res/values-da/translations.xml @@ -379,12 +379,6 @@ Er du sikker på, at du vil fortsætte?" "Fjern %1$s" "Indstillinger" "Det lykkedes ikke at vælge medie. Prøv igen." - "Billedtekster er muligvis ikke synlige for personer, der bruger ældre apps." - "Filen kunne ikke uploades." - "Det lykkedes ikke at behandle medier til upload. Prøv venligst igen." - "Upload af medier mislykkedes. Prøv igen." - "Den maksimalt tilladte filstørrelse er %1$s ." - "Filen er for stor til at kunne uploades." "Tryk på en besked og vælg \"%1$s\" for at inkludere den her." "Fastgør vigtige beskeder, så de let kan opdages" diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml index 371117e07d..3bd2cd518a 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -375,9 +375,6 @@ Möchten Sie wirklich fortfahren?" "Entferne %1$s" "Einstellungen" "Medienauswahl fehlgeschlagen, bitte versuche es erneut." - "Bildunterschriften sind für Nutzer älterer Apps möglicherweise nicht sichtbar." - "Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut." - "Das Hochladen der Medien ist fehlgeschlagen. Bitte versuche es erneut." "Drücke auf eine Nachricht und wähle “%1$s”, um sie hier einzufügen." "Fixiere wichtige Nachrichten, so dass sie leicht gefunden werden können" diff --git a/libraries/ui-strings/src/main/res/values-el/translations.xml b/libraries/ui-strings/src/main/res/values-el/translations.xml index 40ce9522f7..5085f404a1 100644 --- a/libraries/ui-strings/src/main/res/values-el/translations.xml +++ b/libraries/ui-strings/src/main/res/values-el/translations.xml @@ -360,9 +360,6 @@ "Αφαίρεση %1$s" "Ρυθμίσεις" "Αποτυχία επιλογής πολυμέσου, δοκίμασε ξανά." - "Οι λεζάντες ενδέχεται να μην είναι ορατές σε άτομα που χρησιμοποιούν παλαιότερες εφαρμογές." - "Αποτυχία μεταφόρτωσης μέσου, δοκίμασε ξανά." - "Αποτυχία μεταφόρτωσης πολυμέσων, δοκίμασε ξανά." "Πάτα σε ένα μήνυμα και επέλεξε «%1$s» για να συμπεριληφθεί εδώ." "Καρφίτσωσε σημαντικά μηνύματα, ώστε να μπορούν να εντοπιστούν εύκολα" diff --git a/libraries/ui-strings/src/main/res/values-es/translations.xml b/libraries/ui-strings/src/main/res/values-es/translations.xml index e959ed2273..4428e0b864 100644 --- a/libraries/ui-strings/src/main/res/values-es/translations.xml +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -346,9 +346,6 @@ Motivo: %1$s." "%1$s Android" "Agitar con fuerza para informar de un error" "Error al seleccionar archivos multimedia, por favor inténtalo de nuevo." - "Es posible que las leyendas no sean visibles para las personas que usan aplicaciones más antiguas." - "Error al procesar el contenido multimedia, por favor inténtalo de nuevo." - "Error al subir el contenido multimedia, por favor inténtalo de nuevo." "Presiona sobre un mensaje y selecciona «%1$s» para incluirlo aquí." "Fija los mensajes importantes para que se puedan descubrir fácilmente" diff --git a/libraries/ui-strings/src/main/res/values-et/translations.xml b/libraries/ui-strings/src/main/res/values-et/translations.xml index 855b6b639d..8c3a72156e 100644 --- a/libraries/ui-strings/src/main/res/values-et/translations.xml +++ b/libraries/ui-strings/src/main/res/values-et/translations.xml @@ -390,12 +390,6 @@ Kas sa oled kindel, et soovid jätkata?" "Kustuta: %1$s" "Seadistused" "Meediafaili valimine ei õnnestunud. Palun proovi uuesti." - "Selgitused ja alapealkirjad ei pruugi olla nähtavad vanemate rakenduste kasutajatele." - "Faili üleslaadimine ei õnnestunud." - "Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti." - "Meediafaili üleslaadimine ei õnnestunud. Palun proovi uuesti." - "Maksimaalne lubatud failisuurus on %1$s." - "Fail on üleslaadimiseks liiga suur" "Siia lisamiseks vajuta sõnumil ja vali „%1$s“." "Et olulisi sõnumeid oleks lihtsam leida, tõsta nad esile" diff --git a/libraries/ui-strings/src/main/res/values-eu/translations.xml b/libraries/ui-strings/src/main/res/values-eu/translations.xml index bbcf59b423..03f2459129 100644 --- a/libraries/ui-strings/src/main/res/values-eu/translations.xml +++ b/libraries/ui-strings/src/main/res/values-eu/translations.xml @@ -325,7 +325,6 @@ Arrazoia: %1$s." "%1$s Android" "Astindu erroreen berri emateko" "Huts egin du multimedia aukeratzeak, saiatu berriro." - "Huts egin du multimedia igotzeak, saiatu berriro." "Finkatutako mezuak" "Bidali mezua hala ere" "%2$s(e)tik %1$s" diff --git a/libraries/ui-strings/src/main/res/values-fa/translations.xml b/libraries/ui-strings/src/main/res/values-fa/translations.xml index 346cb8a746..16a95e9f78 100644 --- a/libraries/ui-strings/src/main/res/values-fa/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fa/translations.xml @@ -334,8 +334,6 @@ "گزینه‌ها" "تنظیمات" "گزینش رسانه شکست خورد. لطفاً دوباره تلاش کنید." - "پردازش رسانه برای بارگذاری شکست خورد. لطفاً دوباره تلاش کنید." - "بارگذاری رسانه شکست خورد. لطفاً دوباره تلاش کنید." "پیام‌های سنجاق شده" "داردید برای بازنشانی هویتتان به حساب %1$s می‌روید. پس از آن به کاره برگردانده خواهید شد." "فرستادن پیام به هر روی" diff --git a/libraries/ui-strings/src/main/res/values-fi/translations.xml b/libraries/ui-strings/src/main/res/values-fi/translations.xml index 4835f297de..6ebf3b84e1 100644 --- a/libraries/ui-strings/src/main/res/values-fi/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fi/translations.xml @@ -390,12 +390,6 @@ Haluatko varmasti jatkaa?" "Poista %1$s" "Asetukset" "Median valinta epäonnistui, yritä uudelleen." - "Kuvatekstit eivät välttämättä näy ihmisille, jotka käyttävät vanhempia sovelluksia." - "Tiedostoa ei voitu lähettää." - "Median käsittely epäonnistui, yritä uudelleen." - "Median lähettäminen epäonnistui, yritä uudelleen." - "Suurin sallittu tiedostokoko on %1$s." - "Tiedosto on liian suuri lähetettäväksi" "Paina viestiä ja valitse “%1$s” lisätäksesi sen tänne." "Kiinnitä tärkeät viestit, jotta ne löytyvät helposti." diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index 0fa4883f5f..bde9f7500f 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -380,12 +380,6 @@ Raison : %1$s." "Supprimer %1$s" "Paramètres" "Échec de la sélection du média, veuillez réessayer." - "Les légendes peuvent ne pas être visibles pour les utilisateurs d’anciennes applications." - "Le fichier n’a pas pu être envoyé." - "Échec du traitement des médias à télécharger, veuillez réessayer." - "Échec du téléchargement du média, veuillez réessayer." - "La taille maximale autorisée pour les fichiers est de %1$s." - "Le fichier est trop volumineux pour être envoyé." "Cliquez (clic long) sur un message et choisissez « %1$s » pour qu‘il apparaisse ici." "Épinglez les messages importants pour leur donner plus de visibilité" diff --git a/libraries/ui-strings/src/main/res/values-hu/translations.xml b/libraries/ui-strings/src/main/res/values-hu/translations.xml index 96ce17d3cd..e68ffe8939 100644 --- a/libraries/ui-strings/src/main/res/values-hu/translations.xml +++ b/libraries/ui-strings/src/main/res/values-hu/translations.xml @@ -402,15 +402,6 @@ Biztos, hogy folytatja?" "Eltávolítás: %1$s" "Beállítások" "Nem sikerült kiválasztani a médiát, próbálja újra." - "Előfordulhat, hogy a feliratok nem láthatók a régebbi alkalmazásokat használók számára." - "Koppintson a feltöltött videók minőségének módosításához" - "A fájl nem tölthető fel." - "Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra." - "Nem sikerült a média feltöltése, próbálja újra." - "A maximálisan megengedett fájlméret: %1$s ." - "A fájl túl nagy a feltöltéshez" - "Képminőség optimalizációja" - "Feldolgozás…" "Nyomjon hosszan az üzenetre, és válassza a „%1$s” lehetőséget, hogy itt szerepeljen." "Tűzze ki a fontos üzeneteket, hogy könnyen felfedezhetők legyenek" diff --git a/libraries/ui-strings/src/main/res/values-in/translations.xml b/libraries/ui-strings/src/main/res/values-in/translations.xml index 1f54ebdc51..294ab5fd27 100644 --- a/libraries/ui-strings/src/main/res/values-in/translations.xml +++ b/libraries/ui-strings/src/main/res/values-in/translations.xml @@ -360,9 +360,6 @@ Apakah Anda yakin ingin melanjutkan?" "Hapus %1$s" "Pengaturan" "Gagal memilih media, silakan coba lagi." - "Keterangan mungkin tidak terlihat oleh orang yang menggunakan aplikasi lama." - "Gagal memproses media untuk diunggah, silakan coba lagi." - "Gagal mengunggah media, silakan coba lagi." "Tekan pesan dan pilih “%1$s” untuk disertakan di sini." "Sematkan pesan penting agar mudah ditemukan" diff --git a/libraries/ui-strings/src/main/res/values-it/translations.xml b/libraries/ui-strings/src/main/res/values-it/translations.xml index 94289b4298..c5bc5e45b2 100644 --- a/libraries/ui-strings/src/main/res/values-it/translations.xml +++ b/libraries/ui-strings/src/main/res/values-it/translations.xml @@ -361,9 +361,6 @@ Sei sicuro di voler continuare?" "Rimuovi %1$s" "Impostazioni" "Selezione del file multimediale fallita, riprova." - "Le didascalie potrebbero non essere visibili agli utenti di app meno recenti." - "Elaborazione del file multimediale da caricare fallita, riprova." - "Caricamento del file multimediale fallito, riprova." "Premi su un messaggio e scegli “%1$s” per includerlo qui." "Fissa i messaggi importanti così che possano essere trovati facilmente" diff --git a/libraries/ui-strings/src/main/res/values-ka/translations.xml b/libraries/ui-strings/src/main/res/values-ka/translations.xml index e6ccac11bd..dd67ca02c2 100644 --- a/libraries/ui-strings/src/main/res/values-ka/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ka/translations.xml @@ -253,8 +253,6 @@ "%1$s Android" "შეცდომის შესატყობინებლად ტელეფონის შენჯღრევა" "მედიის შერჩევა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." - "მედიის ატვირთვა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." - "მედიის ატვირთვა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." "მედიის ატვირთვა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." "მომხმარებლის მონაცემების მოძიება ვერ მოხერხდა" "მდებარეობის გაზიარება" diff --git a/libraries/ui-strings/src/main/res/values-lt/translations.xml b/libraries/ui-strings/src/main/res/values-lt/translations.xml index 8bd7c6aaa1..535e57f7fe 100644 --- a/libraries/ui-strings/src/main/res/values-lt/translations.xml +++ b/libraries/ui-strings/src/main/res/values-lt/translations.xml @@ -142,8 +142,6 @@ "%1$s Android" "Papurtykite, kad praneštumėte apie klaidą" "Nepavyko pasirinkti laikmenos, pabandykite dar kartą." - "Nepavyko apdoroti įkeliamos laikmenos, bandykite dar kartą." - "Nepavyko įkelti laikmenos, pabandykite dar kartą." "Nepavyko apdoroti įkeliamos laikmenos, bandykite dar kartą." "Nepavyko gauti naudotojo išsamios informacijos." "Versija: %1$s (%2$s)" diff --git a/libraries/ui-strings/src/main/res/values-nb/translations.xml b/libraries/ui-strings/src/main/res/values-nb/translations.xml index de3253e780..253db15e4d 100644 --- a/libraries/ui-strings/src/main/res/values-nb/translations.xml +++ b/libraries/ui-strings/src/main/res/values-nb/translations.xml @@ -362,9 +362,6 @@ Er du sikker på at du vil fortsette?" "Fjern %1$s" "Innstillinger" "Kunne ikke velge medium, prøv igjen." - "Teksting er kanskje ikke synlig for personer som bruker eldre apper." - "Kunne ikke behandle medier for opplasting, vennligst prøv igjen." - "Opplasting av medier mislyktes, vennligst prøv igjen." "Trykk på en melding og velg “%1$s” for å inkludere her." "Fest viktige meldinger slik at de lett kan ses" diff --git a/libraries/ui-strings/src/main/res/values-nl/translations.xml b/libraries/ui-strings/src/main/res/values-nl/translations.xml index c8b4d7eaf2..ffd68b2010 100644 --- a/libraries/ui-strings/src/main/res/values-nl/translations.xml +++ b/libraries/ui-strings/src/main/res/values-nl/translations.xml @@ -311,8 +311,6 @@ Reden: %1$s." "%1$s Android" "Schudden om een bug te melden" "Het selecteren van media is mislukt. Probeer het opnieuw." - "Het verwerken van media voor uploaden is mislukt. Probeer het opnieuw." - "Het uploaden van media is mislukt. Probeer het opnieuw." "Druk op een bericht en kies „%1$s” om het hier toe te voegen." "Zet belangrijke berichten vast zodat ze gemakkelijk te vinden zijn" diff --git a/libraries/ui-strings/src/main/res/values-pl/translations.xml b/libraries/ui-strings/src/main/res/values-pl/translations.xml index 383fb05e31..4248212aff 100644 --- a/libraries/ui-strings/src/main/res/values-pl/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pl/translations.xml @@ -386,12 +386,6 @@ Czy na pewno chcesz kontynuować?" "Usuń %1$s" "Ustawienia" "Nie udało się wybrać multimediów. Spróbuj ponownie." - "Opis może być niedostępny dla osób korzystających ze starszej wersji aplikacji." - "Nie udało się przesłać pliku." - "Przetwarzanie multimediów do przesłania nie powiodło się, spróbuj ponownie." - "Przesyłanie multimediów nie powiodło się, spróbuj ponownie." - "Maksymalny dozwolony rozmiar pliku to %1$s." - "Plik jest za duży, aby go przesłać." "Naciśnij wiadomość i wybierz “%1$s”, aby dołączyć tutaj." "Przypinaj ważne wiadomości, aby można było je łatwo znaleźć" diff --git a/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml b/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml index bcdf36736b..57ecd3388d 100644 --- a/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml @@ -361,9 +361,6 @@ Você tem certeza de que deseja continuar?" "Remover %1$s" "Configurações" "Falha ao selecionar a mídia, tente novamente." - "As legendas podem não ser visíveis para pessoas que usam aplicativos mais antigos." - "Falha ao processar mídia para upload. Tente novamente." - "Falha ao enviar mídia. Tente novamente." "Pressione em uma mensagem e escolha \"%1$s\" para incluir aqui." "Fixe mensagens importantes para que elas possam ser facilmente descobertas" diff --git a/libraries/ui-strings/src/main/res/values-pt/translations.xml b/libraries/ui-strings/src/main/res/values-pt/translations.xml index 8dbfbd7ba2..ce5c97b78f 100644 --- a/libraries/ui-strings/src/main/res/values-pt/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pt/translations.xml @@ -390,12 +390,6 @@ Tens a certeza de que queres continuar?" "Remover %1$s" "Configurações" "Falha ao selecionar multimédia, por favor tente novamente." - "As legendas poderão não ser visíveis em versões mais antigas da aplicação." - "Não foi possível enviar o ficheiro" - "Falha ao processar multimédia para carregamento, por favor tente novamente." - "Falhar ao carregar multimédia, por favor tente novamente." - "O tamanho máximo permitido é %1$s." - "O ficheiro é demasiado grande para enviar" "Pressione uma mensagem e escolha \"%1$s\" para incluir aqui." "Fixa mensagens importantes para que possam ser facilmente descobertas" diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml index 913670ca9a..77f1a715be 100644 --- a/libraries/ui-strings/src/main/res/values-ro/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -302,8 +302,6 @@ Motiv:%1$s." "%1$s Android" "Rageshake pentru a raporta erori" "Selectarea fișierelor media a eșuat, încercați din nou." - "Procesarea datelor media a eșuat, vă rugăm să încercați din nou." - "Încărcarea fișierelor media a eșuat, încercați din nou." "Apăsați pe un mesaj și alegeți \"%1$s\" pentru a-l include aici." "Fixați mesajele importante, astfel încât să poată fi descoperite cu ușurință" diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml index f9d3b2c021..70b55a2252 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -351,9 +351,6 @@ "%1$s Android" "Встряхните устройство, чтобы сообщить об ошибке" "Не удалось выбрать носитель, попробуйте еще раз." - "Подпись может быть не видна пользователям старых приложений." - "Не удалось обработать медиафайл для загрузки, попробуйте еще раз." - "Не удалось загрузить медиафайлы, попробуйте еще раз." "Нажмите на сообщение и выберите “%1$s”, чтобы добавить его сюда." "Закрепите важные сообщения, чтобы их можно было легко найти" diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml index f1e0d764eb..4c542069be 100644 --- a/libraries/ui-strings/src/main/res/values-sk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -411,15 +411,6 @@ Naozaj chcete pokračovať?" "Odstrániť %1$s" "Nastavenia" "Nepodarilo sa vybrať médium, skúste to prosím znova." - "Titulky nemusia byť viditeľné pre ľudí používajúcich staršie aplikácie." - "Ťuknutím zmeníte kvalitu nahratého videa" - "Súbor sa nepodarilo nahrať." - "Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova." - "Nepodarilo sa nahrať médiá, skúste to prosím znova." - "Maximálna povolená veľkosť súboru je %1$s." - "Súbor je príliš veľký na nahratie" - "Optimalizovať kvalitu obrázku" - "Prebieha spracovanie…" "Stlačte správu a vyberte možnosť „%1$s“, ktorú chcete zahrnúť sem." "Pripnite dôležité správy, aby sa dali ľahko nájsť" diff --git a/libraries/ui-strings/src/main/res/values-sv/translations.xml b/libraries/ui-strings/src/main/res/values-sv/translations.xml index fba1a25b62..8187c4b3a5 100644 --- a/libraries/ui-strings/src/main/res/values-sv/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sv/translations.xml @@ -373,9 +373,6 @@ Anledning:%1$s." "Ta bort %1$s" "Inställningar" "Misslyckades att välja media, vänligen pröva igen." - "Bildtexter kanske inte är synliga för personer som använder äldre appar." - "Misslyckades att bearbeta media för uppladdning, vänligen pröva igen." - "Misslyckades att ladda upp media, vänligen pröva igen." "Tryck på ett meddelande och välj ”%1$s” för att inkludera det här." "Fäst viktiga meddelanden så att de lätt kan upptäckas" diff --git a/libraries/ui-strings/src/main/res/values-tr/translations.xml b/libraries/ui-strings/src/main/res/values-tr/translations.xml index b281beb4e5..d4db6202fd 100644 --- a/libraries/ui-strings/src/main/res/values-tr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-tr/translations.xml @@ -323,9 +323,6 @@ Neden: %1$s." "%1$s Android" "Hata bildirmek için Rageshake" "Medya seçilemedi, lütfen tekrar deneyin." - "Açıklamalar, eski uygulamaları kullanan kişiler tarafından görülemeyebilir." - "Medya yüklenemedi, lütfen tekrar deneyin." - "Medya yüklenemedi, lütfen tekrar deneyin." "Bir mesaja basın ve buraya eklemek için “%1$s” yi seçin." "Önemli mesajları kolayca keşfedilebilmeleri için sabitleyin" diff --git a/libraries/ui-strings/src/main/res/values-uk/translations.xml b/libraries/ui-strings/src/main/res/values-uk/translations.xml index f1e46558a0..c2bc2a895e 100644 --- a/libraries/ui-strings/src/main/res/values-uk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-uk/translations.xml @@ -385,12 +385,6 @@ "Вилучити %1$s" "Налаштування" "Не вдалося вибрати медіафайл, спробуйте ще раз." - "Користувачі старих застосунків можуть не бачити підписи." - "Файл не може бути вивантажено." - "Не вдалося обробити медіафайл для завантаження, спробуйте ще раз." - "Не вдалося завантажити медіафайл, спробуйте ще раз." - "Максимально дозволений розмір файлу — %1$s." - "Файл завеликий для вивантаження" "Натисніть на повідомлення і виберіть \"%1$s\", щоб додати його сюди." "Закріпіть важливі повідомлення, щоб їх можна було легко знайти" diff --git a/libraries/ui-strings/src/main/res/values-ur/translations.xml b/libraries/ui-strings/src/main/res/values-ur/translations.xml index 3b36fad8c9..1df9fd99aa 100644 --- a/libraries/ui-strings/src/main/res/values-ur/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ur/translations.xml @@ -274,8 +274,6 @@ "%1$s Android" "خطاء کی اطلاع دینے کیلئے غصے سے ہلائیں" "وسائط منتخب کرنا ناکام، برائے مہربانی دوبارہ کوشش کریں۔" - "وسائط کا معالجہ برائے ترفیع ناکام، برائے مہربانی دوبارہ کوشش کریں۔" - "وسائط رفع کرنے میں ناکام، برائے مہربانی دوبارہ کوشش کریں۔" "%1$d مثبوتہ پیغام" "%1$d مثبوتہ پیغامات" diff --git a/libraries/ui-strings/src/main/res/values-uz/translations.xml b/libraries/ui-strings/src/main/res/values-uz/translations.xml index e655c6dd60..888ab594d6 100644 --- a/libraries/ui-strings/src/main/res/values-uz/translations.xml +++ b/libraries/ui-strings/src/main/res/values-uz/translations.xml @@ -191,8 +191,6 @@ "%1$sAndroid" "Xato haqida xabar berish uchun G\'azablanish" "Media tanlash jarayonida xatolik yuz berdi, qayta urinib ko\'ring" - "Mediani yuklab bo‘lmadi, qayta urinib ko‘ring." - "Media yuklanmadi, qayta urinib ko‘ring." "Mediani yuklab bo‘lmadi, qayta urinib ko‘ring." "Foydalanuvchi tafsilotlarini olinmadi" "Joylashuvni ulashish" diff --git a/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml index cc456ea265..4f3fb4b963 100644 --- a/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml @@ -341,9 +341,6 @@ "%1$s Android" "憤怒搖晃以回報臭蟲" "選取媒體失敗,請再試一次。" - "使用舊應用程式的使用者可能看不到標題。" - "無法處理要上傳的媒體,請再試一次。" - "無法上傳媒體檔案,請稍後再試。" "按一下訊息,然後選擇「%1$s」以加入至此。" "釘選重要訊息,如此才能輕鬆發現" diff --git a/libraries/ui-strings/src/main/res/values-zh/translations.xml b/libraries/ui-strings/src/main/res/values-zh/translations.xml index 07cc969033..0363ae9405 100644 --- a/libraries/ui-strings/src/main/res/values-zh/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh/translations.xml @@ -344,9 +344,6 @@ "%1$s Android" "摇一摇以报错" "选择媒体失败,请重试。" - "使用旧版应用程序的用户可能无法看到字幕。" - "处理要上传的媒体失败,请重试。" - "上传媒体失败,请重试。" "按下消息并选择 “%1$s” 将其包含在此处。" "固定重要消息,以便轻松发现它们" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 3020644358..ec29d79f19 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -403,15 +403,6 @@ Are you sure you want to continue?" "Remove %1$s" "Settings" "Failed selecting media, please try again." - "Captions might not be visible to people using older apps." - "Tap to change the video upload quality" - "The file could not be uploaded." - "Failed processing media to upload, please try again." - "Failed uploading media, please try again." - "The maximum file size allowed is %1$s." - "The file is too large to upload" - "Optimise image quality" - "Processing…" "Press on a message and choose “%1$s” to include here." "Pin important messages so that they can be easily discovered" diff --git a/libraries/ui-utils/build.gradle.kts b/libraries/ui-utils/build.gradle.kts index fc60dc277e..62962fbcb4 100644 --- a/libraries/ui-utils/build.gradle.kts +++ b/libraries/ui-utils/build.gradle.kts @@ -13,6 +13,9 @@ android { namespace = "io.element.android.libraries.ui.utils" dependencies { + implementation(projects.libraries.androidutils) + implementation(projects.services.toolbox.impl) + testImplementation(libs.test.junit) testImplementation(libs.test.truth) testImplementation(libs.coroutines.test) diff --git a/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/formatter/FIleSizeFormatter.kt b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/formatter/FIleSizeFormatter.kt new file mode 100644 index 0000000000..3be7a438f3 --- /dev/null +++ b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/formatter/FIleSizeFormatter.kt @@ -0,0 +1,24 @@ +/* + * 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.ui.utils.formatter + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import io.element.android.libraries.androidutils.filesize.AndroidFileSizeFormatter +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.ui.utils.version.LocalSdkIntVersionProvider + +@Composable +fun rememberFileSizeFormatter(): FileSizeFormatter { + val context = LocalContext.current + val sdkIntProvider = LocalSdkIntVersionProvider.current + return remember { + AndroidFileSizeFormatter(context, sdkIntProvider) + } +} diff --git a/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/version/LocalSdkIntVersionProvider.kt b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/version/LocalSdkIntVersionProvider.kt new file mode 100644 index 0000000000..d1dc5a5eb7 --- /dev/null +++ b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/version/LocalSdkIntVersionProvider.kt @@ -0,0 +1,14 @@ +/* + * 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.ui.utils.version + +import androidx.compose.runtime.staticCompositionLocalOf +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import io.element.android.services.toolbox.impl.sdk.DefaultBuildVersionSdkIntProvider + +val LocalSdkIntVersionProvider = staticCompositionLocalOf { DefaultBuildVersionSdkIntProvider() } diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_0_en.png index 0be9c85fe2..cecd1965bc 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:724df787c6c033c40967bfc62de74e71613ef8221bfc18a9859fcc0b5fb9a8ca -size 394854 +oid sha256:27cf5dae58f473d3fd9554b159aa925596c6316934ad4bb47069d5829fd0b28c +size 400237 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_10_en.png new file mode 100644 index 0000000000..e466a5f480 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b9faa86b429960368f6df670817c8d7236434d83b6154f295e1100003838ae80 +size 83633 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_1_en.png index 3d03bc54a2..e884cae8ff 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a4b9d7cfa35310566401bc031a2554e49ea71820b3050abc449f91fad3ad892a -size 394573 +oid sha256:3aefd14cc6e89b89e0965042b69e4d005a3c243e02c27d7d16296d314ad0e377 +size 399961 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_2_en.png index 9f1be8d864..af9151e2af 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ccf51958e5757064087f6a589032b7bb25b30827ecfc7864f157a6da689a67c -size 51024 +oid sha256:bb7b368caebda5198bf2c523318ae349f010b89da011bf649d25374cba32318c +size 59517 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_3_en.png index 0be9c85fe2..cecd1965bc 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:724df787c6c033c40967bfc62de74e71613ef8221bfc18a9859fcc0b5fb9a8ca -size 394854 +oid sha256:27cf5dae58f473d3fd9554b159aa925596c6316934ad4bb47069d5829fd0b28c +size 400237 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_4_en.png index ae6fd4011c..1b32113513 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:15782147de5dc2fda9d77f75694bba22d9998db5c245969baa192e87543387cf -size 50991 +oid sha256:599542430fa68487a2114228f79e6d1c91f2ad3e495c6e969ba08d43de66ed49 +size 59330 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_5_en.png index 859bfb59c5..6a78324db8 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9476e888ecf6ac7d374d146a8aa5bfa3ed66b0c4b722d46052b56d177a543733 -size 89112 +oid sha256:ab0aac7e1228338e9f5c1b5e1a939ae13e9af6b8128b2508d0d37756b3baee34 +size 86550 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_6_en.png index 0366e59529..252936e18e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:36aa7ce68da79c229612156d1870c904a87482b64bc9eb262d2c52c1e813d31d -size 390438 +oid sha256:ca6f96cb449f902205bb7eac111e98dbde38b0043640cd2264ecabed605ee758 +size 395882 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_7_en.png index 0be9c85fe2..cecd1965bc 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:724df787c6c033c40967bfc62de74e71613ef8221bfc18a9859fcc0b5fb9a8ca -size 394854 +oid sha256:27cf5dae58f473d3fd9554b159aa925596c6316934ad4bb47069d5829fd0b28c +size 400237 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_8_en.png new file mode 100644 index 0000000000..5696315478 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01a9e0fac77cb3d1b1915407f59c44a1a9aa870033eb14e7cf3b2ae51e403c76 +size 72954 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_9_en.png new file mode 100644 index 0000000000..05d94b86f7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52ec4658e2f4f4d07f1ad9ea16ac288ca9fa49440656a4590f686ea17e9ea309 +size 406043 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Day_0_en.png new file mode 100644 index 0000000000..af03698322 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9677ef17b1c00248df44354599debe4af0f21998b3e0797a9e701d39d48abecc +size 56820 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Night_0_en.png new file mode 100644 index 0000000000..bf1b04add8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f6184dbd6c3511f0d8a703bd86c1c85b77efbabcdbe0e116f4ce034a392f410e +size 54474 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_8_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_8_en.png new file mode 100644 index 0000000000..e5fb3178c4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bd8d14ba4e202190b47fed580cf35c568c03d1aaaf8971b274a4ba9949176c9b +size 52870 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_8_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_8_en.png new file mode 100644 index 0000000000..817ce70d06 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa743f37c2131ff63979836259ae9290c9937f23f4341e52e14418e3c23010bc +size 55003 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_VideoQualitySelectorDialog_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_VideoQualitySelectorDialog_Day_0_en.png new file mode 100644 index 0000000000..87fd99aca9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_VideoQualitySelectorDialog_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2945a3dfc8090c03755a8fefdf37b20e235a35fcb48ff5efce216100679999bd +size 49823 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_VideoQualitySelectorDialog_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_VideoQualitySelectorDialog_Night_0_en.png new file mode 100644 index 0000000000..0800217c1e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_VideoQualitySelectorDialog_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5507dc3686ec765a56caca94180a8650a316744f6bf419a0c916229c1f973da7 +size 47562 diff --git a/tools/detekt/detekt.yml b/tools/detekt/detekt.yml index 9af20180c6..aab640d906 100644 --- a/tools/detekt/detekt.yml +++ b/tools/detekt/detekt.yml @@ -231,6 +231,7 @@ Compose: - LocalAnalyticsService - LocalBuildMeta - LocalUiTestMode + - LocalSdkIntVersionProvider CompositionLocalNaming: active: true ContentEmitterReturningValues: diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 010a722e30..1f0d291d0b 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -77,7 +77,8 @@ "name" : ":libraries:textcomposer:impl", "includeRegex" : [ "rich_text_editor.*", - ".*voice_message_tooltip" + ".*voice_message_tooltip", + "screen\\.media_upload_preview.caption_warning" ] }, { @@ -213,7 +214,8 @@ "screen_room_retry.*", "screen_room_timeline.*", "screen\\.room_timeline.*", - "screen_room_typing.*" + "screen_room_typing.*", + "screen\\.media_upload.*" ] }, {