Add media file limit size warning and media quality selection (#5131)
* Add `VideoCompressorPreset` enum This represents the different compression presets used for processing videos before uploading them * Add `VideoCompressorHelper` util class to calculate the scaled output size of the video given an input size and its optimal bitrate Also add `MediaOptimizationConfig` which will be used to decide how to apply compression in `MediaPreProcessor` * Add `RustMatrixClient.getMaxFileUploadSize()` function and `MaxUploadSizeProvider` so we can import only this functionality into other components * Try preloading the max file upload size the first time we get network connectivity - it's a best effort This should help ensure we'll have this value available later, even if we still need to load it asynchronously. * Split the `compressMedia` preference into `compressImages` and `compressMediaPreset` * Modify the media processing parts to use the new classes and utils * Add `MediaOptimizationSelectorPresenter`, which will retrieve the compression values and the max file upload size, also estimating the compressed video file sizes if needed. * Add a feature flag to allow selecting the media upload quality per upload * Integrate the previous changes with the attachments preview screen Add strings from localazy too. * Adapt the rest of the app calls to upload media to using the media optimization configs * Allow modifying the default compression values in advanced settings, based on the feature flag value * Pass the `fileSize` in `MediaUploadInfo` too, to be able to check it against the `maxUploadSize` * Update screenshots --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
parent
ffe183c952
commit
a170d80cb3
174 changed files with 2152 additions and 340 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue