Add media file limit size warning and media quality selection (#5131)
* Add `VideoCompressorPreset` enum This represents the different compression presets used for processing videos before uploading them * Add `VideoCompressorHelper` util class to calculate the scaled output size of the video given an input size and its optimal bitrate Also add `MediaOptimizationConfig` which will be used to decide how to apply compression in `MediaPreProcessor` * Add `RustMatrixClient.getMaxFileUploadSize()` function and `MaxUploadSizeProvider` so we can import only this functionality into other components * Try preloading the max file upload size the first time we get network connectivity - it's a best effort This should help ensure we'll have this value available later, even if we still need to load it asynchronously. * Split the `compressMedia` preference into `compressImages` and `compressMediaPreset` * Modify the media processing parts to use the new classes and utils * Add `MediaOptimizationSelectorPresenter`, which will retrieve the compression values and the max file upload size, also estimating the compressed video file sizes if needed. * Add a feature flag to allow selecting the media upload quality per upload * Integrate the previous changes with the attachments preview screen Add strings from localazy too. * Adapt the rest of the app calls to upload media to using the media optimization configs * Allow modifying the default compression values in advanced settings, based on the feature flag value * Pass the `fileSize` in `MediaUploadInfo` too, to be able to check it against the `maxUploadSize` * Update screenshots --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
committed by
GitHub
parent
ffe183c952
commit
a170d80cb3
@@ -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<LoggedInFlowNode.NavTarget>(
|
||||
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) {
|
||||
|
||||
@@ -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<ConfigureRoomState> {
|
||||
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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AttachmentsPreviewState> {
|
||||
@@ -89,21 +95,80 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||
|
||||
var useSendQueue by remember { mutableStateOf(false) }
|
||||
var preprocessMediaJob by remember { mutableStateOf<Job?>(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<SendActionState>,
|
||||
) = 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>,
|
||||
) {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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<AttachmentsPreviewState> {
|
||||
@@ -36,6 +43,21 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())),
|
||||
anAttachmentsPreviewState(allowCaption = false),
|
||||
anAttachmentsPreviewState(showCaptionCompatibilityWarning = true),
|
||||
anAttachmentsPreviewState(displayFileTooLargeError = true),
|
||||
anAttachmentsPreviewState(
|
||||
mediaInfo = aVideoMediaInfo(),
|
||||
mediaOptimizationSelectorState = aMediaOptimisationSelectorState(
|
||||
selectedVideoPreset = VideoCompressionPreset.STANDARD,
|
||||
videoSizeEstimations = aVideoSizeEstimationList(),
|
||||
)
|
||||
),
|
||||
anAttachmentsPreviewState(
|
||||
mediaInfo = aVideoMediaInfo(),
|
||||
mediaOptimizationSelectorState = aMediaOptimisationSelectorState(
|
||||
videoSizeEstimations = aVideoSizeEstimationList(),
|
||||
displayVideoPresetSelectorDialog = true,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +67,8 @@ fun anAttachmentsPreviewState(
|
||||
sendActionState: SendActionState = SendActionState.Idle,
|
||||
allowCaption: Boolean = true,
|
||||
showCaptionCompatibilityWarning: Boolean = true,
|
||||
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
|
||||
displayFileTooLargeError: Boolean = false,
|
||||
) = AttachmentsPreviewState(
|
||||
attachment = Attachment.Media(
|
||||
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
|
||||
@@ -53,6 +77,8 @@ fun anAttachmentsPreviewState(
|
||||
textEditorState = textEditorState,
|
||||
allowCaption = allowCaption,
|
||||
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
|
||||
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
|
||||
displayFileTooLargeError = displayFileTooLargeError,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -72,3 +98,35 @@ fun aMediaUploadInfo(
|
||||
),
|
||||
thumbnailFile = thumbnailFilePath?.let { File(it) },
|
||||
)
|
||||
|
||||
fun aMediaOptimisationSelectorState(
|
||||
maxUploadSize: Long = 100,
|
||||
videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>> = 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<ImmutableList<VideoUploadEstimation>> = AsyncData.Success(
|
||||
persistentListOf(
|
||||
VideoUploadEstimation(
|
||||
preset = VideoCompressionPreset.HIGH,
|
||||
sizeInBytes = 8_200_000L,
|
||||
canUpload = false,
|
||||
),
|
||||
VideoUploadEstimation(
|
||||
preset = VideoCompressionPreset.STANDARD,
|
||||
sizeInBytes = 4_200_000L,
|
||||
canUpload = true,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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<VideoUploadEstimation>,
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Boolean?>(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<AsyncData<ImmutableList<VideoUploadEstimation>>>(
|
||||
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<Boolean>>(AsyncData.Loading()) }
|
||||
var selectedVideoOptimizationPreset by remember { mutableStateOf<AsyncData<VideoCompressionPreset>>(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<ImmutableList<VideoUploadEstimation>>,
|
||||
): AsyncData<VideoCompressionPreset> {
|
||||
val estimations = videoSizeEstimations.dataOrNull() ?: return AsyncData.Loading()
|
||||
// This will find the best video preset that can be used to produce a video that can be uploaded
|
||||
val bestEstimation = estimations.find { it.preset.ordinal >= defaultVideoPreset.ordinal && it.canUpload }?.preset
|
||||
return if (bestEstimation != null) {
|
||||
AsyncData.Success(bestEstimation)
|
||||
} else {
|
||||
AsyncData.Failure(
|
||||
IllegalStateException("No suitable video preset found for default preset: $defaultVideoPreset")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<MediaOptimizationSelectorState> {
|
||||
interface Factory {
|
||||
fun create(
|
||||
localMedia: LocalMedia,
|
||||
): MediaOptimizationSelectorPresenter
|
||||
}
|
||||
}
|
||||
@@ -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<Long>,
|
||||
val videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
|
||||
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,
|
||||
)
|
||||
@@ -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<Size>
|
||||
fun getDuration(): Result<Long>
|
||||
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<Size> = 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<Long> = runCatchingExceptions {
|
||||
mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong()
|
||||
?.takeIf { it > 0L }
|
||||
?: error("Could not retrieve video duration from metadata")
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
mediaMetadataRetriever.release()
|
||||
}
|
||||
}
|
||||
@@ -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<MessageComposerState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -519,6 +521,7 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
progressCallback = null,
|
||||
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
|
||||
).getOrThrow()
|
||||
}
|
||||
.onFailure { cause ->
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,6 +28,7 @@ fun aTimelineItemAudioContent(
|
||||
caption: String? = null,
|
||||
) = TimelineItemAudioContent(
|
||||
filename = fileName,
|
||||
fileSize = 100 * 1024L,
|
||||
caption = caption,
|
||||
formattedCaption = null,
|
||||
isEdited = false,
|
||||
|
||||
@@ -26,6 +26,7 @@ sealed interface TimelineItemEventContentWithAttachment :
|
||||
TimelineItemEventContent,
|
||||
TimelineItemEventMutableContent {
|
||||
val filename: String
|
||||
val fileSize: Long?
|
||||
val caption: String?
|
||||
val formattedCaption: CharSequence?
|
||||
val mediaSource: MediaSource
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,7 @@ fun aTimelineItemFileContent(
|
||||
caption: String? = null,
|
||||
) = TimelineItemFileContent(
|
||||
filename = fileName,
|
||||
fileSize = 100 * 1024L,
|
||||
caption = caption,
|
||||
formattedCaption = null,
|
||||
isEdited = false,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,6 +29,7 @@ fun aTimelineItemImageContent(
|
||||
caption: String? = null,
|
||||
) = TimelineItemImageContent(
|
||||
filename = filename,
|
||||
fileSize = 4 * 1024 * 1024L,
|
||||
caption = caption,
|
||||
formattedCaption = null,
|
||||
isEdited = false,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -45,6 +45,7 @@ fun aTimelineItemVoiceContent(
|
||||
waveform: List<Float> = 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,
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="emoji_picker_category_people">"Усмешкі & Удзельнікі"</string>
|
||||
<string name="emoji_picker_category_places">"Падарожжы & Месцы"</string>
|
||||
<string name="emoji_picker_category_symbols">"Сімвалы"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз."</string>
|
||||
<string name="screen_report_content_block_user">"Заблакіраваць карыстальніка"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Адзначце, ці хочаце вы схаваць усе бягучыя і будучыя паведамленні ад гэтага карыстальніка"</string>
|
||||
<string name="screen_report_content_explanation">"Гэтае паведамленне будзе перададзена адміністратару вашага хатняга сервера. Яны не змогуць прачытаць зашыфраваныя паведамленні."</string>
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
<string name="emoji_picker_category_people">"Smajlíci a lidé"</string>
|
||||
<string name="emoji_picker_category_places">"Cestování a místa"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symboly"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace."</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Soubor nelze nahrát."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_message">"Maximální povolená velikost souboru je %1$s."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_title">"Soubor je pro nahrání příliš velký."</string>
|
||||
<string name="screen_report_content_block_user">"Zablokovat uživatele"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Zaškrtněte, pokud chcete skrýt všechny aktuální a budoucí zprávy od tohoto uživatele"</string>
|
||||
<string name="screen_report_content_explanation">"Tato zpráva bude nahlášena správci vašeho domovského serveru. Nebude si moci přečíst žádné šifrované zprávy."</string>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<string name="emoji_picker_category_people">"Wynebau Hapus a Phobl"</string>
|
||||
<string name="emoji_picker_category_places">"Teithio a Llefydd"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbolau"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Efallai na fydd capsiynau yn weladwy i bobl sy\'n defnyddio apiau hŷn."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Wedi methu â phrosesu cyfryngau i\'w llwytho, ceisiwch eto."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Wedi methu llwytho cyfryngau, ceisiwch eto."</string>
|
||||
<string name="screen_report_content_block_user">"Rhwystro defnyddiwr"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Gwiriwch a ydych am guddio\'r holl negeseuon presennol ac yn y dyfodol gan y defnyddiwr hwn"</string>
|
||||
<string name="screen_report_content_explanation">"Bydd y neges hon yn cael ei hadrodd i weinyddwr eich gweinyddwr cartref. Fyddan nhw ddim yn gallu darllen unrhyw negeseuon wedi\'u hamgryptio."</string>
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
<string name="emoji_picker_category_people">"Smileys og mennesker"</string>
|
||||
<string name="emoji_picker_category_places">"Rejser og steder"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symboler"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Billedtekster er muligvis ikke synlige for personer, der bruger ældre apps."</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Filen kunne ikke uploades."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Det lykkedes ikke at behandle medier til upload. Prøv venligst igen."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Upload af medier mislykkedes. Prøv igen."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_message">"Den maksimalt tilladte filstørrelse er %1$s ."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_title">"Filen er for stor til at kunne uploades."</string>
|
||||
<string name="screen_report_content_block_user">"Bloker bruger"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Marker, hvis du vil skjule alle nuværende og fremtidige beskeder fra denne bruger"</string>
|
||||
<string name="screen_report_content_explanation">"Denne meddelelse vil blive indberettet til administratoren af din hjemmeserver. De vil ikke være i stand til at læse nogen krypterede meddelelser."</string>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<string name="emoji_picker_category_people">"Smileys & Menschen"</string>
|
||||
<string name="emoji_picker_category_places">"Reisen & Orte"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbole"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Bildunterschriften sind für Nutzer älterer Apps möglicherweise nicht sichtbar."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Das Hochladen der Medien ist fehlgeschlagen. Bitte versuche es erneut."</string>
|
||||
<string name="screen_report_content_block_user">"Nutzer blockieren"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Prüfen Sie, ob Sie alle aktuellen und zukünftigen Nachrichten dieses Nutzers ausblenden wollen"</string>
|
||||
<string name="screen_report_content_explanation">"Diese Nachricht wird dem Administrator ihres Homeservers gemeldet. Dieser kann allerdings keine verschlüsselten Nachrichten lesen."</string>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<string name="emoji_picker_category_people">"Φατσούλες & Άνθρωποι"</string>
|
||||
<string name="emoji_picker_category_places">"Ταξίδια & Μέρη"</string>
|
||||
<string name="emoji_picker_category_symbols">"Σύμβολα"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Οι λεζάντες ενδέχεται να μην είναι ορατές σε άτομα που χρησιμοποιούν παλαιότερες εφαρμογές."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Αποτυχία μεταφόρτωσης μέσου, δοκίμασε ξανά."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Αποτυχία μεταφόρτωσης πολυμέσων, δοκίμασε ξανά."</string>
|
||||
<string name="screen_report_content_block_user">"Αποκλεισμός χρήστη"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Επέλεξε εάν θες να αποκρύψεις όλα τα τρέχοντα και μελλοντικά μηνύματα από αυτόν τον χρήστη"</string>
|
||||
<string name="screen_report_content_explanation">"Αυτό το μήνυμα θα αναφερθεί στον διαχειριστή του οικιακού διακομιστή σας. Δεν θα μπορεί να διαβάσει κρυπτογραφημένα μηνύματα."</string>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<string name="emoji_picker_category_people">"Emojis y personas"</string>
|
||||
<string name="emoji_picker_category_places">"Viajes y lugares"</string>
|
||||
<string name="emoji_picker_category_symbols">"Símbolos"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Es posible que las leyendas no sean visibles para las personas que usan aplicaciones más antiguas."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Error al procesar el contenido multimedia, por favor inténtalo de nuevo."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Error al subir el contenido multimedia, por favor inténtalo de nuevo."</string>
|
||||
<string name="screen_report_content_block_user">"Bloquear usuario"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Marca esta casilla si quieres ocultar todos los mensajes actuales y futuros de este usuario"</string>
|
||||
<string name="screen_report_content_explanation">"Se denunciará este mensaje al administrador de tu servidor base. No será capaz de leer ningún mensaje cifrado."</string>
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
<string name="emoji_picker_category_people">"Emotikonid ja inimesed"</string>
|
||||
<string name="emoji_picker_category_places">"Reisimine ja kohad"</string>
|
||||
<string name="emoji_picker_category_symbols">"Sümbolid"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Selgitused ja alapealkirjad ei pruugi olla nähtavad vanemate rakenduste kasutajatele."</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Faili üleslaadimine ei õnnestunud."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Meediafaili üleslaadimine ei õnnestunud. Palun proovi uuesti."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_message">"Maksimaalne lubatud failisuurus on %1$s."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_title">"Fail on üleslaadimiseks liiga suur"</string>
|
||||
<string name="screen_report_content_block_user">"Blokeeri kasutaja"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Vali see eelistus, kui sa soovid peita selle kasutaja kõik senised ja tulevased sõnumid"</string>
|
||||
<string name="screen_report_content_explanation">"Teade selle sõnumi kohta edastatakse sinu koduserveri haldajale. Haldajal ei ole võimalik lugeda krüptitud sõnumite sisu."</string>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<string name="emoji_picker_category_people">"Irribartxoak eta jendea"</string>
|
||||
<string name="emoji_picker_category_places">"Bidaiak eta tokiak"</string>
|
||||
<string name="emoji_picker_category_symbols">"Ikurrak"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Huts egin du multimedia igotzeak, saiatu berriro."</string>
|
||||
<string name="screen_report_content_block_user">"Blokeatu erabiltzailea"</string>
|
||||
<string name="screen_report_content_explanation">"Mezua zure zerbitzariko administratzaileari jakinaraziko zaio. Ezingo dute zifratutako mezurik irakurri."</string>
|
||||
<string name="screen_report_content_hint">"Edukia salatzeko arrazoia"</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="emoji_picker_category_people">"شکلکها و افراد"</string>
|
||||
<string name="emoji_picker_category_places">"سفر و مکانها"</string>
|
||||
<string name="emoji_picker_category_symbols">"نمادها"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"پردازش رسانه برای بارگذاری شکست خورد. لطفاً دوباره تلاش کنید."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"بارگذاری رسانه شکست خورد. لطفاً دوباره تلاش کنید."</string>
|
||||
<string name="screen_report_content_block_user">"انسداد کاربر"</string>
|
||||
<string name="screen_report_content_block_user_hint">"اگر میخواهید همه پیامهای فعلی و آینده را از این کاربر را پنهان کنید، علامت بزنید"</string>
|
||||
<string name="screen_report_content_explanation">"این پیام به مدیر کارساز خانگی شما گزارش خواهد شد. آنها قادر به خواندن پیام های رمزگذاری شده نخواهند بود."</string>
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
<string name="emoji_picker_category_people">"Hymiöt ja ihmiset"</string>
|
||||
<string name="emoji_picker_category_places">"Matkustaminen ja paikat"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbolit"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Kuvatekstit eivät välttämättä näy ihmisille, jotka käyttävät vanhempia sovelluksia."</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Tiedostoa ei voitu lähettää."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Median käsittely epäonnistui, yritä uudelleen."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Median lähettäminen epäonnistui, yritä uudelleen."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_message">"Suurin sallittu tiedostokoko on %1$s."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_title">"Tiedosto on liian suuri lähetettäväksi"</string>
|
||||
<string name="screen_report_content_block_user">"Estä käyttäjä"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Valitse tämä, jos haluat piilottaa kaikki nykyiset ja tulevat viestit tältä käyttäjältä"</string>
|
||||
<string name="screen_report_content_explanation">"Tämä viesti ilmoitetaan kotipalvelimesi ylläpitäjälle. Ylläpitäjä ei pysty lukemaan salattuja viestejä."</string>
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
<string name="emoji_picker_category_people">"Émoticônes et personnes"</string>
|
||||
<string name="emoji_picker_category_places">"Voyages & lieux"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symboles"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Les légendes peuvent ne pas être visibles pour les utilisateurs d’anciennes applications."</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Le fichier n’a pas pu être envoyé."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Échec du traitement des médias à télécharger, veuillez réessayer."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Échec du téléchargement du média, veuillez réessayer."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_message">"La taille maximale autorisée pour les fichiers est de %1$s."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_title">"Le fichier est trop volumineux pour être envoyé."</string>
|
||||
<string name="screen_report_content_block_user">"Bloquer l’utilisateur"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Cochez si vous souhaitez masquer tous les messages actuels et futurs de cet utilisateur."</string>
|
||||
<string name="screen_report_content_explanation">"Ce message sera signalé à l’administrateur de votre serveur d’accueil. Il ne pourra lire aucun message chiffré."</string>
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
<string name="emoji_picker_category_people">"Mosolyok és emberek"</string>
|
||||
<string name="emoji_picker_category_places">"Utazás és helyek"</string>
|
||||
<string name="emoji_picker_category_symbols">"Szimbólumok"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Előfordulhat, hogy a feliratok nem láthatók a régebbi alkalmazásokat használók számára."</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"A fájl nem tölthető fel."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Nem sikerült a média feltöltése, próbálja újra."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_message">"A maximálisan megengedett fájlméret: %1$s ."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_title">"A fájl túl nagy a feltöltéshez"</string>
|
||||
<string name="screen_report_content_block_user">"Felhasználó letiltása"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Jelölje be, ha el akarja rejteni az összes jelenlegi és jövőbeli üzenetet ettől a felhasználótól"</string>
|
||||
<string name="screen_report_content_explanation">"Ez az üzenet jelentve lesz a Matrix-kiszolgáló adminisztrátorának. Nem fogja tudni elolvasni a titkosított üzeneteket."</string>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<string name="emoji_picker_category_people">"Senyuman & Orang"</string>
|
||||
<string name="emoji_picker_category_places">"Wisata & Tempat"</string>
|
||||
<string name="emoji_picker_category_symbols">"Simbol"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Keterangan mungkin tidak terlihat oleh orang yang menggunakan aplikasi lama."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Gagal memproses media untuk diunggah, silakan coba lagi."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Gagal mengunggah media, silakan coba lagi."</string>
|
||||
<string name="screen_report_content_block_user">"Blokir pengguna"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Centang jika Anda ingin menyembunyikan semua pesan saat ini dan yang akan datang dari pengguna ini"</string>
|
||||
<string name="screen_report_content_explanation">"Pesan ini akan dilaporkan ke administrator homeserver Anda. Mereka tidak akan dapat membaca pesan terenkripsi apa pun."</string>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<string name="emoji_picker_category_people">"Faccine & Persone"</string>
|
||||
<string name="emoji_picker_category_places">"Viaggi & Luoghi"</string>
|
||||
<string name="emoji_picker_category_symbols">"Simboli"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Le didascalie potrebbero non essere visibili agli utenti di app meno recenti."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Elaborazione del file multimediale da caricare fallita, riprova."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Caricamento del file multimediale fallito, riprova."</string>
|
||||
<string name="screen_report_content_block_user">"Blocca utente"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Seleziona se vuoi nascondere tutti i messaggi attuali e futuri di questo utente"</string>
|
||||
<string name="screen_report_content_explanation">"Questo messaggio verrà segnalato all\'amministratore dell\'homeserver. Questi non sarà in grado di leggere i messaggi cifrati."</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="emoji_picker_category_people">"ღიმილები & ხალხი"</string>
|
||||
<string name="emoji_picker_category_places">"მოგზაურობა და ადგილები"</string>
|
||||
<string name="emoji_picker_category_symbols">"სიმბოლოები"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"მედიის ატვირთვა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"მედიის ატვირთვა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა."</string>
|
||||
<string name="screen_report_content_block_user">"მომხმარებლის დაბლოკვა"</string>
|
||||
<string name="screen_report_content_block_user_hint">"შეამოწმეთ, გსურთ თუ არა ამ მომხმარებლის ყველა მიმდინარე და მომავალი შეტყობინების დამალვა"</string>
|
||||
<string name="screen_report_content_explanation">"ეს შეტყობინება გაგზავნილი იქნება თქვენი სახლის სერვერის ადმინისტრატორისადმი. მას არ ექნება დაშიფვრული შეტყობინებების წაკითხვის შესაძლებლობა."</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="emoji_picker_category_people">"Šypsenėlės ir Žmonės"</string>
|
||||
<string name="emoji_picker_category_places">"Kelionės ir Vietovės"</string>
|
||||
<string name="emoji_picker_category_symbols">"Simboliai"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Nepavyko apdoroti įkeliamos laikmenos, bandykite dar kartą."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Nepavyko įkelti laikmenos, pabandykite dar kartą."</string>
|
||||
<string name="screen_report_content_block_user">"Blokuoti vartotoją"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Pažymėkite, jei norite paslėpti visas esamas ir būsimas šio vartotojo žinutes"</string>
|
||||
<string name="screen_report_content_explanation">"Apie šią žinutę bus pranešta Jūsų serverio administracijai. Jie negalės perskaityti jokių užšifruotų žinučių."</string>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<string name="emoji_picker_category_people">"Smilefjes og mennesker"</string>
|
||||
<string name="emoji_picker_category_places">"Reising og steder"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symboler"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Teksting er kanskje ikke synlig for personer som bruker eldre apper."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Kunne ikke behandle medier for opplasting, vennligst prøv igjen."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Opplasting av medier mislyktes, vennligst prøv igjen."</string>
|
||||
<string name="screen_report_content_block_user">"Blokker bruker"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Kryss av for om du vil skjule alle nåværende og fremtidige meldinger fra denne brukeren"</string>
|
||||
<string name="screen_report_content_explanation">"Denne meldingen vil bli rapportert til hjemmeserverens administratorer. De vil ikke kunne lese noen krypterte meldinger."</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="emoji_picker_category_people">"Smileys & Mensen"</string>
|
||||
<string name="emoji_picker_category_places">"Reizen & Locaties"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbolen"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Het verwerken van media voor uploaden is mislukt. Probeer het opnieuw."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Het uploaden van media is mislukt. Probeer het opnieuw."</string>
|
||||
<string name="screen_report_content_block_user">"Gebruiker blokkeren"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Vink aan als je alle huidige en toekomstige berichten van deze gebruiker wilt verbergen"</string>
|
||||
<string name="screen_report_content_explanation">"Dit bericht wordt gerapporteerd aan de beheerder van je homeserver. Ze zullen geen versleutelde berichten kunnen lezen."</string>
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
<string name="emoji_picker_category_people">"Buźki i osoby"</string>
|
||||
<string name="emoji_picker_category_places">"Podróż i miejsca"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbole"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Opis może być niedostępny dla osób korzystających ze starszej wersji aplikacji."</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Nie udało się przesłać pliku."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Przetwarzanie multimediów do przesłania nie powiodło się, spróbuj ponownie."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Przesyłanie multimediów nie powiodło się, spróbuj ponownie."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_message">"Maksymalny dozwolony rozmiar pliku to %1$s."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_title">"Plik jest za duży, aby go przesłać."</string>
|
||||
<string name="screen_report_content_block_user">"Zablokuj użytkownika"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Sprawdź, czy chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika."</string>
|
||||
<string name="screen_report_content_explanation">"Ta wiadomość zostanie zgłoszona do administratora Twojego serwera domowego. Nie będzie mógł on przeczytać żadnych zaszyfrowanych wiadomości."</string>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<string name="emoji_picker_category_people">"Sorrisos & Pessoas"</string>
|
||||
<string name="emoji_picker_category_places">"Viagens & Lugares"</string>
|
||||
<string name="emoji_picker_category_symbols">"Símbolos"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"As legendas podem não ser visíveis para pessoas que usam aplicativos mais antigos."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Falha ao processar mídia para upload. Tente novamente."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Falha ao enviar mídia. Tente novamente."</string>
|
||||
<string name="screen_report_content_block_user">"Bloquear usuário"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Marque se você deseja ocultar todas as mensagens atuais e futuras desse usuário"</string>
|
||||
<string name="screen_report_content_explanation">"Essa mensagem será reportada ao administrador do seu homeserver. Eles não conseguirão ler nenhuma mensagem criptografada."</string>
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
<string name="emoji_picker_category_people">"Caras e Pessoas"</string>
|
||||
<string name="emoji_picker_category_places">"Viagens e Lugares"</string>
|
||||
<string name="emoji_picker_category_symbols">"Símbolos"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"As legendas poderão não ser visíveis em versões mais antigas da aplicação."</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Não foi possível enviar o ficheiro"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Falha ao processar multimédia para carregamento, por favor tente novamente."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Falhar ao carregar multimédia, por favor tente novamente."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_message">"O tamanho máximo permitido é %1$s."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_title">"O ficheiro é demasiado grande para enviar"</string>
|
||||
<string name="screen_report_content_block_user">"Bloquear utilizador"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Ativar para ocultar todas as atuais e futuras mensagens deste utilizador"</string>
|
||||
<string name="screen_report_content_explanation">"Esta mensagem será denunciada ao administrador do teu servidor. Porém, não lhe será possível ler quaisquer mensagens cifradas."</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="emoji_picker_category_people">"Fețe zâmbitoare & Oameni"</string>
|
||||
<string name="emoji_picker_category_places">"Călătorii & Locuri"</string>
|
||||
<string name="emoji_picker_category_symbols">"Simboluri"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Procesarea datelor media a eșuat, vă rugăm să încercați din nou."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Încărcarea fișierelor media a eșuat, încercați din nou."</string>
|
||||
<string name="screen_report_content_block_user">"Blocați utilizatorul"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator"</string>
|
||||
<string name="screen_report_content_explanation">"Acest mesaj va fi raportat administratorilor homeserver-ului tau. Ei nu vor putea citi niciun mesaj criptat."</string>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<string name="emoji_picker_category_people">"Улыбки и люди"</string>
|
||||
<string name="emoji_picker_category_places">"Путешествия и места"</string>
|
||||
<string name="emoji_picker_category_symbols">"Символы"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Подпись может быть не видна пользователям старых приложений."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Не удалось загрузить медиафайлы, попробуйте еще раз."</string>
|
||||
<string name="screen_report_content_block_user">"Заблокировать пользователя"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Отметьте, хотите ли вы скрыть все текущие и будущие сообщения от этого пользователя"</string>
|
||||
<string name="screen_report_content_explanation">"Это сообщение будет передано администратору вашего домашнего сервера. Они не смогут прочитать зашифрованные сообщения."</string>
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
<string name="emoji_picker_category_people">"Smajlíky a ľudia"</string>
|
||||
<string name="emoji_picker_category_places">"Cestovanie a miesta"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symboly"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Titulky nemusia byť viditeľné pre ľudí používajúcich staršie aplikácie."</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Súbor sa nepodarilo nahrať."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Nepodarilo sa nahrať médiá, skúste to prosím znova."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_message">"Maximálna povolená veľkosť súboru je %1$s."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_title">"Súbor je príliš veľký na nahratie"</string>
|
||||
<string name="screen_report_content_block_user">"Zablokovať používateľa"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Označte, či chcete skryť všetky aktuálne a budúce správy od tohto používateľa"</string>
|
||||
<string name="screen_report_content_explanation">"Táto správa bude nahlásená správcovi vášho domovského servera. Nebude môcť prečítať žiadne šifrované správy."</string>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<string name="emoji_picker_category_people">"Smileys & personer"</string>
|
||||
<string name="emoji_picker_category_places">"Resor & platser"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symboler"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Bildtexter kanske inte är synliga för personer som använder äldre appar."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Misslyckades att bearbeta media för uppladdning, vänligen pröva igen."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Misslyckades att ladda upp media, vänligen pröva igen."</string>
|
||||
<string name="screen_report_content_block_user">"Blockera användare"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Markera om du vill dölja alla nuvarande och framtida meddelanden från denna användare"</string>
|
||||
<string name="screen_report_content_explanation">"Det här meddelandet kommer att rapporteras till din hemservers administratör. Denne kommer inte att kunna läsa några krypterade meddelanden."</string>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<string name="emoji_picker_category_people">"İfadeler ve İnsanlar"</string>
|
||||
<string name="emoji_picker_category_places">"Seyahat ve Yerler"</string>
|
||||
<string name="emoji_picker_category_symbols">"Simgeler"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Açıklamalar, eski uygulamaları kullanan kişiler tarafından görülemeyebilir."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Medya yüklenemedi, lütfen tekrar deneyin."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Medya yüklenemedi, lütfen tekrar deneyin."</string>
|
||||
<string name="screen_report_content_block_user">"Kullanıcıyı engelle"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Bu kullanıcıdan gelen mevcut ve gelecekteki tüm mesajları gizlemek isteyip istemediğinizi işaretleyin"</string>
|
||||
<string name="screen_report_content_explanation">"Bu mesaj ana sunucunuzun yöneticisine bildirilecektir. Şifrelenmiş mesajları okuyamayacaklardır."</string>
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
<string name="emoji_picker_category_people">"Смайлики та люди"</string>
|
||||
<string name="emoji_picker_category_places">"Подорожі та місця"</string>
|
||||
<string name="emoji_picker_category_symbols">"Символи"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Користувачі старих застосунків можуть не бачити підписи."</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Файл не може бути вивантажено."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Не вдалося обробити медіафайл для завантаження, спробуйте ще раз."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Не вдалося завантажити медіафайл, спробуйте ще раз."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_message">"Максимально дозволений розмір файлу — %1$s."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_title">"Файл завеликий для вивантаження"</string>
|
||||
<string name="screen_report_content_block_user">"Заблокувати користувача"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Перевірте, чи хочете ви приховати всі поточні та майбутні повідомлення від цього користувача"</string>
|
||||
<string name="screen_report_content_explanation">"Це повідомлення буде надіслано адміністраторам вашого домашнього сервера. Вони не зможуть прочитати зашифровані повідомлення."</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="emoji_picker_category_people">"مسکراہٹیں و لوگ"</string>
|
||||
<string name="emoji_picker_category_places">"سفر و مقامات"</string>
|
||||
<string name="emoji_picker_category_symbols">"علامتیں"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"وسائط کا معالجہ برائے ترفیع ناکام، برائے مہربانی دوبارہ کوشش کریں۔"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"وسائط رفع کرنے میں ناکام، برائے مہربانی دوبارہ کوشش کریں۔"</string>
|
||||
<string name="screen_report_content_block_user">"صارف کو مسدود کریں"</string>
|
||||
<string name="screen_report_content_block_user_hint">"پڑتال کریں کہ کیا آپ اس صارف سے تمام موجودہ اور مستقبلی پیغامات چھپانا چاہتے ہیں۔"</string>
|
||||
<string name="screen_report_content_explanation">"اس پیغام کی اطلاع آپکے منزلی خادم کے منتظم کو دی جائیگی۔ وہ کوئی مرموزکردہ پیغامات نہیں پڑھ سکیں گے۔"</string>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<string name="emoji_picker_category_people">"Smayllar va odamlar"</string>
|
||||
<string name="emoji_picker_category_places">"Sayohat va Joylar"</string>
|
||||
<string name="emoji_picker_category_symbols">"Belgilar"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Mediani yuklab bo‘lmadi, qayta urinib ko‘ring."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Media yuklanmadi, qayta urinib ko‘ring."</string>
|
||||
<string name="screen_report_content_block_user">"Foydalanuvchini bloklash"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Ushbu foydalanuvchidan barcha joriy va kelajakdagi xabarlarni yashirishni xohlayotganingizni tekshiring"</string>
|
||||
<string name="screen_report_content_explanation">"Bu xabar uy serveringiz administratoriga xabar qilinadi. Ular hech qanday shifrlangan xabarlarni o\'qiy olmaydi."</string>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<string name="emoji_picker_category_people">"表情與人物"</string>
|
||||
<string name="emoji_picker_category_places">"旅行與景點"</string>
|
||||
<string name="emoji_picker_category_symbols">"標誌"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"使用舊應用程式的使用者可能看不到標題。"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"無法處理要上傳的媒體,請再試一次。"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"無法上傳媒體檔案,請稍後再試。"</string>
|
||||
<string name="screen_report_content_block_user">"封鎖使用者"</string>
|
||||
<string name="screen_report_content_block_user_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</string>
|
||||
<string name="screen_report_content_explanation">"此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。"</string>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<string name="emoji_picker_category_people">"表情和人物"</string>
|
||||
<string name="emoji_picker_category_places">"旅行和地点"</string>
|
||||
<string name="emoji_picker_category_symbols">"符号"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"使用旧版应用程序的用户可能无法看到字幕。"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"处理要上传的媒体失败,请重试。"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"上传媒体失败,请重试。"</string>
|
||||
<string name="screen_report_content_block_user">"封禁用户"</string>
|
||||
<string name="screen_report_content_block_user_hint">"请确认是否要隐藏该用户当前和未来的所有信息"</string>
|
||||
<string name="screen_report_content_explanation">"此消息将举报给您的服务器管理员。他们无法读取任何加密消息。"</string>
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
<string name="emoji_picker_category_people">"Smileys & People"</string>
|
||||
<string name="emoji_picker_category_places">"Travel & Places"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbols"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Captions might not be visible to people using older apps."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tap to change the video upload quality"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"The file could not be uploaded."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_message">"The maximum file size allowed is %1$s."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_title">"The file is too large to upload"</string>
|
||||
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimise image quality"</string>
|
||||
<string name="screen_media_upload_preview_processing">"Processing…"</string>
|
||||
<string name="screen_report_content_block_user">"Block user"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Check if you want to hide all current and future messages from this user"</string>
|
||||
<string name="screen_report_content_explanation">"This message will be reported to your homeserver’s administrator. They will not be able to read any encrypted messages."</string>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Unit> {}
|
||||
|
||||
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<Unit> {}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Size> = Result.success(Size(1, 1)),
|
||||
private val duration: Result<Long> = Result.success(1L),
|
||||
) : VideoMetadataExtractor {
|
||||
override fun getSize(): Result<Size> = sizeResult
|
||||
|
||||
override fun getDuration(): Result<Long> = duration
|
||||
|
||||
override fun close() = Unit
|
||||
}
|
||||
|
||||
class FakeVideoMetadataExtractorFactory(
|
||||
private val fakeVideoMetadataExtractor: FakeVideoMetadataExtractor = FakeVideoMetadataExtractor(),
|
||||
) : VideoMetadataExtractor.Factory {
|
||||
override fun create(uri: Uri): VideoMetadataExtractor {
|
||||
return fakeVideoMetadataExtractor
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<AdvancedSettingsState> {
|
||||
@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<Boolean?>(null) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality)
|
||||
}
|
||||
|
||||
val mediaOptimizationState by produceState<MediaOptimizationState?>(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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AdvancedSettingsState> {
|
||||
override val values: Sequence<AdvancedSettingsState>
|
||||
@@ -17,18 +18,22 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
|
||||
aAdvancedSettingsState(),
|
||||
aAdvancedSettingsState(isDeveloperModeEnabled = true),
|
||||
aAdvancedSettingsState(isSharePresenceEnabled = true),
|
||||
aAdvancedSettingsState(doesCompressMedia = true),
|
||||
aAdvancedSettingsState(mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true)),
|
||||
aAdvancedSettingsState(hideInviteAvatars = true),
|
||||
aAdvancedSettingsState(timelineMediaPreviewValue = MediaPreviewValue.Off),
|
||||
aAdvancedSettingsState(setHideInviteAvatarsAction = AsyncAction.Loading),
|
||||
aAdvancedSettingsState(setTimelineMediaPreviewAction = AsyncAction.Loading),
|
||||
aAdvancedSettingsState(mediaOptimizationState = MediaOptimizationState.Split(
|
||||
compressImages = true,
|
||||
videoPreset = VideoCompressionPreset.HIGH,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
fun aAdvancedSettingsState(
|
||||
isDeveloperModeEnabled: Boolean = false,
|
||||
isSharePresenceEnabled: Boolean = false,
|
||||
doesCompressMedia: Boolean = false,
|
||||
mediaOptimizationState: MediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = false),
|
||||
theme: ThemeOption = ThemeOption.System,
|
||||
hideInviteAvatars: Boolean = false,
|
||||
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
|
||||
@@ -38,7 +43,7 @@ fun aAdvancedSettingsState(
|
||||
) = AdvancedSettingsState(
|
||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||
isSharePresenceEnabled = isSharePresenceEnabled,
|
||||
doesCompressMedia = doesCompressMedia,
|
||||
mediaOptimizationState = mediaOptimizationState,
|
||||
theme = theme,
|
||||
mediaPreviewConfigState = MediaPreviewConfigState(
|
||||
hideInviteAvatars = hideInviteAvatars,
|
||||
|
||||
@@ -10,20 +10,27 @@ package io.element.android.features.preferences.impl.advanced
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
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.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
|
||||
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.ListSectionHeader
|
||||
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
|
||||
@@ -34,6 +41,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.compose.LocalAnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
@@ -94,6 +102,11 @@ fun AdvancedSettingsView(
|
||||
),
|
||||
onClick = { state.eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) }
|
||||
)
|
||||
val compressImages = state.mediaOptimizationState?.shouldCompressImages
|
||||
|
||||
when (state.mediaOptimizationState) {
|
||||
null -> Unit
|
||||
is MediaOptimizationState.AllMedia -> {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_title))
|
||||
@@ -102,10 +115,10 @@ fun AdvancedSettingsView(
|
||||
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_description))
|
||||
},
|
||||
trailingContent = ListItemContent.Switch(
|
||||
checked = state.doesCompressMedia,
|
||||
checked = compressImages ?: false,
|
||||
),
|
||||
onClick = {
|
||||
val newValue = !state.doesCompressMedia
|
||||
val newValue = !(compressImages ?: false)
|
||||
analyticsService.captureInteraction(
|
||||
if (newValue) {
|
||||
Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled
|
||||
@@ -116,10 +129,126 @@ fun AdvancedSettingsView(
|
||||
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 */ }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EditUserProfileState> {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ class AdvancedSettingsViewTest {
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
rule.setAdvancedSettingsView(
|
||||
state = aAdvancedSettingsState(
|
||||
doesCompressMedia = true,
|
||||
mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
analyticsService = analyticsService
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RoomDetailsEditState> {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ShareState> {
|
||||
@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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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<Long>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -692,6 +692,10 @@ class RustMatrixClient(
|
||||
innerClient.isLivekitRtcSupported()
|
||||
}
|
||||
|
||||
override suspend fun getMaxFileUploadSize(): Result<Long> = withContext(sessionDispatcher) {
|
||||
runCatchingExceptions { innerClient.getMaxMediaUploadSize().toLong() }
|
||||
}
|
||||
|
||||
private suspend fun File.getCacheSize(
|
||||
includeCryptoDb: Boolean = false,
|
||||
): Long = withContext(sessionDispatcher) {
|
||||
|
||||
@@ -94,6 +94,7 @@ class FakeMatrixClient(
|
||||
private val canReportRoomLambda: () -> Boolean = { false },
|
||||
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
|
||||
override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
|
||||
private val getMaxUploadSizeResult: () -> Result<Long> = { 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<Long> {
|
||||
return getMaxUploadSizeResult()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaupload.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Provides the maximum upload size allowed by the Matrix server.
|
||||
*/
|
||||
class MaxUploadSizeProvider @Inject constructor(
|
||||
private val matrixClient: MatrixClient,
|
||||
) {
|
||||
suspend fun getMaxUploadSize(): Result<Long> {
|
||||
return matrixClient.getMaxFileUploadSize()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaupload.api
|
||||
|
||||
import io.element.android.libraries.androidutils.media.VideoCompressorHelper
|
||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
|
||||
data class MediaOptimizationConfig(
|
||||
val compressImages: Boolean,
|
||||
val videoCompressionPreset: VideoCompressionPreset,
|
||||
)
|
||||
|
||||
fun VideoCompressionPreset.compressorHelper(): VideoCompressorHelper = when (this) {
|
||||
VideoCompressionPreset.STANDARD -> VideoCompressorHelper(1280)
|
||||
VideoCompressionPreset.HIGH -> VideoCompressorHelper(1920)
|
||||
VideoCompressionPreset.LOW -> VideoCompressorHelper(640)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaupload.api
|
||||
|
||||
fun interface MediaOptimizationConfigProvider {
|
||||
suspend fun get(): MediaOptimizationConfig
|
||||
}
|
||||
@@ -19,7 +19,7 @@ interface MediaPreProcessor {
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
deleteOriginal: Boolean,
|
||||
compressIfPossible: Boolean,
|
||||
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||
): Result<MediaUploadInfo>
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,17 +14,15 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
|
||||
class MediaSender @Inject constructor(
|
||||
private val preProcessor: MediaPreProcessor,
|
||||
private val room: JoinedRoom,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||
) {
|
||||
private val ongoingUploadJobs = ConcurrentHashMap<Job.Key, MediaUploadHandler>()
|
||||
val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty()
|
||||
@@ -32,14 +30,14 @@ class MediaSender @Inject constructor(
|
||||
suspend fun preProcessMedia(
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||
): Result<MediaUploadInfo> {
|
||||
val compressIfPossible = sessionPreferencesStore.doesCompressMedia().first()
|
||||
return preProcessor
|
||||
.process(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
deleteOriginal = false,
|
||||
compressIfPossible = compressIfPossible,
|
||||
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,14 +65,14 @@ class MediaSender @Inject constructor(
|
||||
formattedCaption: String? = null,
|
||||
progressCallback: ProgressCallback? = null,
|
||||
inReplyToEventId: EventId? = null,
|
||||
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||
): Result<Unit> {
|
||||
val compressIfPossible = sessionPreferencesStore.doesCompressMedia().first()
|
||||
return preProcessor
|
||||
.process(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
deleteOriginal = false,
|
||||
compressIfPossible = compressIfPossible,
|
||||
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||
)
|
||||
.flatMapCatching { info ->
|
||||
room.liveTimeline.sendMedia(
|
||||
@@ -100,7 +98,7 @@ class MediaSender @Inject constructor(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
deleteOriginal = true,
|
||||
compressIfPossible = false,
|
||||
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
|
||||
)
|
||||
.flatMapCatching { info ->
|
||||
val audioInfo = (info as MediaUploadInfo.Audio).audioInfo
|
||||
|
||||
@@ -19,8 +19,7 @@ import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -34,6 +33,11 @@ import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MediaSenderTest {
|
||||
private val mediaOptimizationConfig = MediaOptimizationConfig(
|
||||
compressImages = true,
|
||||
videoCompressionPreset = VideoCompressionPreset.STANDARD,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `given an attachment when sending it the preprocessor always runs`() = runTest {
|
||||
val preProcessor = FakeMediaPreProcessor()
|
||||
@@ -57,7 +61,7 @@ class MediaSenderTest {
|
||||
)
|
||||
|
||||
val uri = Uri.parse("content://image.jpg")
|
||||
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
|
||||
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
|
||||
|
||||
assertThat(preProcessor.processCallCount).isEqualTo(1)
|
||||
}
|
||||
@@ -76,7 +80,7 @@ class MediaSenderTest {
|
||||
val sender = createMediaSender(room = room)
|
||||
|
||||
val uri = Uri.parse("content://image.jpg")
|
||||
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
|
||||
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -87,7 +91,7 @@ class MediaSenderTest {
|
||||
val sender = createMediaSender(preProcessor)
|
||||
|
||||
val uri = Uri.parse("content://image.jpg")
|
||||
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
|
||||
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
|
||||
|
||||
assertThat(result.exceptionOrNull()).isNotNull()
|
||||
}
|
||||
@@ -112,7 +116,7 @@ class MediaSenderTest {
|
||||
)
|
||||
|
||||
val uri = Uri.parse("content://image.jpg")
|
||||
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
|
||||
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
|
||||
|
||||
assertThat(result.exceptionOrNull()).isNotNull()
|
||||
}
|
||||
@@ -132,7 +136,7 @@ class MediaSenderTest {
|
||||
val sender = createMediaSender(room = room)
|
||||
val sendJob = launch {
|
||||
val uri = Uri.parse("content://image.jpg")
|
||||
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
|
||||
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
|
||||
}
|
||||
// Wait until several internal tasks run and the file is being uploaded
|
||||
advanceTimeBy(3L)
|
||||
@@ -154,10 +158,10 @@ class MediaSenderTest {
|
||||
private fun createMediaSender(
|
||||
preProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
|
||||
room: JoinedRoom = FakeJoinedRoom(),
|
||||
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||
mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = MediaOptimizationConfigProvider { mediaOptimizationConfig },
|
||||
) = MediaSender(
|
||||
preProcessor = preProcessor,
|
||||
room = room,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<MediaUploadInfo> = 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}%")
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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<VideoTranscodingEvent> = callbackFlow {
|
||||
fun compress(uri: Uri, videoCompressionPreset: VideoCompressionPreset): Flow<VideoTranscodingEvent> = 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user