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
|
|
@ -52,6 +52,8 @@ import io.element.android.features.ftue.api.state.FtueService
|
||||||
import io.element.android.features.ftue.api.state.FtueState
|
import io.element.android.features.ftue.api.state.FtueState
|
||||||
import io.element.android.features.home.api.HomeEntryPoint
|
import io.element.android.features.home.api.HomeEntryPoint
|
||||||
import io.element.android.features.logout.api.LogoutEntryPoint
|
import io.element.android.features.logout.api.LogoutEntryPoint
|
||||||
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
||||||
import io.element.android.features.roomdirectory.api.RoomDescription
|
import io.element.android.features.roomdirectory.api.RoomDescription
|
||||||
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
|
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
|
||||||
|
|
@ -125,6 +127,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
private val logoutEntryPoint: LogoutEntryPoint,
|
private val logoutEntryPoint: LogoutEntryPoint,
|
||||||
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
|
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
|
||||||
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
|
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
|
||||||
|
private val networkMonitor: NetworkMonitor,
|
||||||
snackbarDispatcher: SnackbarDispatcher,
|
snackbarDispatcher: SnackbarDispatcher,
|
||||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
|
|
@ -192,6 +195,12 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
matrixClient.sessionVerificationService().setListener(verificationListener)
|
matrixClient.sessionVerificationService().setListener(verificationListener)
|
||||||
mediaPreviewConfigMigration()
|
mediaPreviewConfigMigration()
|
||||||
|
|
||||||
|
sessionCoroutineScope.launch {
|
||||||
|
// Wait for the network to be connected before pre-fetching the max file upload size
|
||||||
|
networkMonitor.connectivity.first { networkStatus -> networkStatus == NetworkStatus.Connected }
|
||||||
|
matrixClient.getMaxFileUploadSize()
|
||||||
|
}
|
||||||
|
|
||||||
ftueService.state
|
ftueService.state
|
||||||
.onEach { ftueState ->
|
.onEach { ftueState ->
|
||||||
when (ftueState) {
|
when (ftueState) {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||||
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
|
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
|
||||||
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEffect
|
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEffect
|
||||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
|
|
@ -57,6 +58,7 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val roomAliasHelper: RoomAliasHelper,
|
private val roomAliasHelper: RoomAliasHelper,
|
||||||
|
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||||
) : Presenter<ConfigureRoomState> {
|
) : Presenter<ConfigureRoomState> {
|
||||||
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
|
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
|
||||||
private var pendingPermissionRequest = false
|
private var pendingPermissionRequest = false
|
||||||
|
|
@ -201,7 +203,7 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||||
uri = avatarUri,
|
uri = avatarUri,
|
||||||
mimeType = MimeTypes.Jpeg,
|
mimeType = MimeTypes.Jpeg,
|
||||||
deleteOriginal = false,
|
deleteOriginal = false,
|
||||||
compressIfPossible = false,
|
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
|
||||||
).getOrThrow()
|
).getOrThrow()
|
||||||
val byteArray = preprocessed.file.readBytes()
|
val byteArray = preprocessed.file.readBytes()
|
||||||
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow()
|
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow()
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
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.mediaupload.test.FakeMediaPreProcessor
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||||
|
|
@ -411,6 +412,7 @@ class ConfigureBaseRoomPresenterTest {
|
||||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||||
isKnockFeatureEnabled: Boolean = true,
|
isKnockFeatureEnabled: Boolean = true,
|
||||||
|
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||||
) = ConfigureRoomPresenter(
|
) = ConfigureRoomPresenter(
|
||||||
dataStore = createRoomDataStore,
|
dataStore = createRoomDataStore,
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
|
|
@ -421,6 +423,7 @@ class ConfigureBaseRoomPresenterTest {
|
||||||
roomAliasHelper = roomAliasHelper,
|
roomAliasHelper = roomAliasHelper,
|
||||||
featureFlagService = FakeFeatureFlagService(
|
featureFlagService = FakeFeatureFlagService(
|
||||||
mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled)
|
mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled)
|
||||||
)
|
),
|
||||||
|
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,5 +17,7 @@ android {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
implementation(projects.libraries.mediaviewer.api)
|
||||||
|
implementation(projects.libraries.preferences.api)
|
||||||
api(projects.libraries.textcomposer.impl)
|
api(projects.libraries.textcomposer.impl)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -462,6 +462,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
eventId = event.eventId,
|
eventId = event.eventId,
|
||||||
mediaInfo = MediaInfo(
|
mediaInfo = MediaInfo(
|
||||||
filename = content.filename,
|
filename = content.filename,
|
||||||
|
fileSize = content.fileSize,
|
||||||
caption = content.caption,
|
caption = content.caption,
|
||||||
mimeType = content.mimeType,
|
mimeType = content.mimeType,
|
||||||
formattedFileSize = content.formattedFileSize,
|
formattedFileSize = content.formattedFileSize,
|
||||||
|
|
|
||||||
|
|
@ -22,21 +22,26 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
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.TemporaryUriDeleter
|
||||||
import io.element.android.libraries.androidutils.file.safeDelete
|
import io.element.android.libraries.androidutils.file.safeDelete
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.core.coroutine.firstInstanceOf
|
import io.element.android.libraries.core.coroutine.firstInstanceOf
|
||||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
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.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
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.MediaSender
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||||
import io.element.android.libraries.mediaupload.api.allFiles
|
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.TextEditorState
|
||||||
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
|
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
@ -54,6 +59,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
private val permalinkBuilder: PermalinkBuilder,
|
private val permalinkBuilder: PermalinkBuilder,
|
||||||
private val temporaryUriDeleter: TemporaryUriDeleter,
|
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
|
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
|
||||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
) : Presenter<AttachmentsPreviewState> {
|
) : Presenter<AttachmentsPreviewState> {
|
||||||
|
|
@ -89,21 +95,80 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
var useSendQueue by remember { mutableStateOf(false) }
|
var useSendQueue by remember { mutableStateOf(false) }
|
||||||
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
|
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
useSendQueue = featureFlagService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
|
|
||||||
|
|
||||||
preprocessMediaJob = preProcessAttachment(
|
val mediaAttachment = attachment as Attachment.Media
|
||||||
attachment,
|
val mediaOptimizationSelectorPresenter = remember {
|
||||||
sendActionState
|
mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
val mediaOptimizationSelectorState = mediaOptimizationSelectorPresenter.present()
|
||||||
|
|
||||||
val observableSendState = snapshotFlow { sendActionState.value }
|
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) {
|
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
|
||||||
when (attachmentsPreviewEvents) {
|
when (attachmentsPreviewEvents) {
|
||||||
is AttachmentsPreviewEvents.SendAttachment -> {
|
is AttachmentsPreviewEvents.SendAttachment -> {
|
||||||
ongoingSendAttachmentJob.value = coroutineScope.launch {
|
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 the processing was hidden before, make it visible now
|
||||||
if (sendActionState.value is SendActionState.Sending.Processing) {
|
if (sendActionState.value is SendActionState.Sending.Processing) {
|
||||||
sendActionState.value = SendActionState.Sending.Processing(displayProgress = true)
|
sendActionState.value = SendActionState.Sending.Processing(displayProgress = true)
|
||||||
|
|
@ -138,6 +203,8 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AttachmentsPreviewEvents.CancelAndDismiss -> {
|
AttachmentsPreviewEvents.CancelAndDismiss -> {
|
||||||
|
displayFileTooLargeError = false
|
||||||
|
|
||||||
// Cancel media preprocessing and sending
|
// Cancel media preprocessing and sending
|
||||||
preprocessMediaJob?.cancel()
|
preprocessMediaJob?.cancel()
|
||||||
// If we couldn't send the pre-processed media, remove it
|
// If we couldn't send the pre-processed media, remove it
|
||||||
|
|
@ -173,18 +240,24 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
textEditorState = textEditorState,
|
textEditorState = textEditorState,
|
||||||
allowCaption = allowCaption,
|
allowCaption = allowCaption,
|
||||||
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
|
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
|
||||||
|
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
|
||||||
|
displayFileTooLargeError = displayFileTooLargeError,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.preProcessAttachment(
|
private fun CoroutineScope.preProcessAttachment(
|
||||||
attachment: Attachment,
|
attachment: Attachment,
|
||||||
|
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||||
|
displayProgress: Boolean,
|
||||||
sendActionState: MutableState<SendActionState>,
|
sendActionState: MutableState<SendActionState>,
|
||||||
) = launch(dispatchers.io) {
|
) = launch(dispatchers.io) {
|
||||||
when (attachment) {
|
when (attachment) {
|
||||||
is Attachment.Media -> {
|
is Attachment.Media -> {
|
||||||
preProcessMedia(
|
preProcessMedia(
|
||||||
mediaAttachment = attachment,
|
mediaAttachment = attachment,
|
||||||
|
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||||
|
displayProgress = displayProgress,
|
||||||
sendActionState = sendActionState,
|
sendActionState = sendActionState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -193,12 +266,15 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
private suspend fun preProcessMedia(
|
private suspend fun preProcessMedia(
|
||||||
mediaAttachment: Attachment.Media,
|
mediaAttachment: Attachment.Media,
|
||||||
|
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||||
|
displayProgress: Boolean,
|
||||||
sendActionState: MutableState<SendActionState>,
|
sendActionState: MutableState<SendActionState>,
|
||||||
) {
|
) {
|
||||||
sendActionState.value = SendActionState.Sending.Processing(displayProgress = false)
|
sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress)
|
||||||
mediaSender.preProcessMedia(
|
mediaSender.preProcessMedia(
|
||||||
uri = mediaAttachment.localMedia.uri,
|
uri = mediaAttachment.localMedia.uri,
|
||||||
mimeType = mediaAttachment.localMedia.info.mimeType,
|
mimeType = mediaAttachment.localMedia.info.mimeType,
|
||||||
|
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||||
).fold(
|
).fold(
|
||||||
onSuccess = { mediaUploadInfo ->
|
onSuccess = { mediaUploadInfo ->
|
||||||
sendActionState.value = SendActionState.Sending.ReadyToUpload(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 androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
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.mediaupload.api.MediaUploadInfo
|
||||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||||
|
|
||||||
|
|
@ -18,6 +19,8 @@ data class AttachmentsPreviewState(
|
||||||
val textEditorState: TextEditorState,
|
val textEditorState: TextEditorState,
|
||||||
val allowCaption: Boolean,
|
val allowCaption: Boolean,
|
||||||
val showCaptionCompatibilityWarning: Boolean,
|
val showCaptionCompatibilityWarning: Boolean,
|
||||||
|
val mediaOptimizationSelectorState: MediaOptimizationSelectorState,
|
||||||
|
val displayFileTooLargeError: Boolean,
|
||||||
val eventSink: (AttachmentsPreviewEvents) -> Unit
|
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.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
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.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
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.anImageMediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
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.TextEditorState
|
||||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
|
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
|
||||||
|
|
@ -36,6 +43,21 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
|
||||||
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())),
|
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())),
|
||||||
anAttachmentsPreviewState(allowCaption = false),
|
anAttachmentsPreviewState(allowCaption = false),
|
||||||
anAttachmentsPreviewState(showCaptionCompatibilityWarning = true),
|
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,
|
sendActionState: SendActionState = SendActionState.Idle,
|
||||||
allowCaption: Boolean = true,
|
allowCaption: Boolean = true,
|
||||||
showCaptionCompatibilityWarning: Boolean = true,
|
showCaptionCompatibilityWarning: Boolean = true,
|
||||||
|
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
|
||||||
|
displayFileTooLargeError: Boolean = false,
|
||||||
) = AttachmentsPreviewState(
|
) = AttachmentsPreviewState(
|
||||||
attachment = Attachment.Media(
|
attachment = Attachment.Media(
|
||||||
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
|
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
|
||||||
|
|
@ -53,6 +77,8 @@ fun anAttachmentsPreviewState(
|
||||||
textEditorState = textEditorState,
|
textEditorState = textEditorState,
|
||||||
allowCaption = allowCaption,
|
allowCaption = allowCaption,
|
||||||
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
|
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
|
||||||
|
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
|
||||||
|
displayFileTooLargeError = displayFileTooLargeError,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -72,3 +98,35 @@ fun aMediaUploadInfo(
|
||||||
),
|
),
|
||||||
thumbnailFile = thumbnailFilePath?.let { File(it) },
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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.foundation.layout.padding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
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.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
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.Attachment
|
||||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
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.ProgressDialog
|
||||||
import io.element.android.libraries.designsystem.components.ProgressDialogType
|
import io.element.android.libraries.designsystem.components.ProgressDialogType
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
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.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.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.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.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
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.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
|
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.TextComposer
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
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 io.element.android.wysiwyg.display.TextDisplay
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -109,7 +135,7 @@ private fun AttachmentSendStateView(
|
||||||
if (sendActionState.displayProgress) {
|
if (sendActionState.displayProgress) {
|
||||||
ProgressDialog(
|
ProgressDialog(
|
||||||
type = ProgressDialogType.Indeterminate,
|
type = ProgressDialogType.Indeterminate,
|
||||||
text = stringResource(id = CommonStrings.common_sending),
|
text = stringResource(CommonStrings.common_preparing),
|
||||||
showCancelButton = true,
|
showCancelButton = true,
|
||||||
onDismissRequest = onDismissClick,
|
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(
|
AttachmentsPreviewBottomActions(
|
||||||
state = state,
|
state = state,
|
||||||
onSendClick = onSendClick,
|
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
|
@Composable
|
||||||
private fun AttachmentsPreviewBottomActions(
|
private fun AttachmentsPreviewBottomActions(
|
||||||
state: AttachmentsPreviewState,
|
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
|
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.mediaupload.api.MediaPreProcessor
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
|
||||||
|
|
||||||
fun sendAttachmentError(
|
fun sendAttachmentError(
|
||||||
throwable: Throwable
|
throwable: Throwable
|
||||||
): Int {
|
): Int {
|
||||||
return if (throwable is MediaPreProcessor.Failure) {
|
return if (throwable is MediaPreProcessor.Failure) {
|
||||||
CommonStrings.screen_media_upload_preview_error_failed_processing
|
R.string.screen_media_upload_preview_error_failed_processing
|
||||||
} else {
|
} 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.InReplyToDetails
|
||||||
import io.element.android.libraries.matrix.ui.messages.reply.map
|
import io.element.android.libraries.matrix.ui.messages.reply.map
|
||||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
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.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||||
|
|
@ -120,6 +121,7 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
private val mentionSpanProvider: MentionSpanProvider,
|
private val mentionSpanProvider: MentionSpanProvider,
|
||||||
private val pillificationHelper: TextPillificationHelper,
|
private val pillificationHelper: TextPillificationHelper,
|
||||||
private val suggestionsProcessor: SuggestionsProcessor,
|
private val suggestionsProcessor: SuggestionsProcessor,
|
||||||
|
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||||
) : Presenter<MessageComposerState> {
|
) : Presenter<MessageComposerState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -519,6 +521,7 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
progressCallback = null,
|
progressCallback = null,
|
||||||
|
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
|
||||||
).getOrThrow()
|
).getOrThrow()
|
||||||
}
|
}
|
||||||
.onFailure { cause ->
|
.onFailure { cause ->
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
||||||
TimelineItemImageContent(
|
TimelineItemImageContent(
|
||||||
filename = messageType.filename,
|
filename = messageType.filename,
|
||||||
|
fileSize = messageType.info?.size ?: 0,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
|
|
@ -106,6 +107,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
||||||
TimelineItemStickerContent(
|
TimelineItemStickerContent(
|
||||||
filename = messageType.filename,
|
filename = messageType.filename,
|
||||||
|
fileSize = messageType.info?.size ?: 0,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
|
|
@ -142,6 +144,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
||||||
TimelineItemVideoContent(
|
TimelineItemVideoContent(
|
||||||
filename = messageType.filename,
|
filename = messageType.filename,
|
||||||
|
fileSize = messageType.info?.size ?: 0,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
|
|
@ -162,6 +165,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
is AudioMessageType -> {
|
is AudioMessageType -> {
|
||||||
TimelineItemAudioContent(
|
TimelineItemAudioContent(
|
||||||
filename = messageType.filename,
|
filename = messageType.filename,
|
||||||
|
fileSize = messageType.info?.size ?: 0,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
|
|
@ -178,6 +182,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
TimelineItemVoiceContent(
|
TimelineItemVoiceContent(
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
filename = messageType.filename,
|
filename = messageType.filename,
|
||||||
|
fileSize = messageType.info?.size ?: 0,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
|
|
@ -192,6 +197,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
false -> {
|
false -> {
|
||||||
TimelineItemAudioContent(
|
TimelineItemAudioContent(
|
||||||
filename = messageType.filename,
|
filename = messageType.filename,
|
||||||
|
fileSize = messageType.info?.size ?: 0,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
|
|
@ -208,6 +214,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
val fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
|
val fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
|
||||||
TimelineItemFileContent(
|
TimelineItemFileContent(
|
||||||
filename = messageType.filename,
|
filename = messageType.filename,
|
||||||
|
fileSize = messageType.info?.size ?: 0,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ class TimelineItemContentStickerFactory @Inject constructor(
|
||||||
|
|
||||||
return TimelineItemStickerContent(
|
return TimelineItemStickerContent(
|
||||||
filename = content.filename,
|
filename = content.filename,
|
||||||
|
fileSize = content.info.size ?: 0L,
|
||||||
caption = content.body,
|
caption = content.body,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import kotlin.time.Duration
|
||||||
|
|
||||||
data class TimelineItemAudioContent(
|
data class TimelineItemAudioContent(
|
||||||
override val filename: String,
|
override val filename: String,
|
||||||
|
override val fileSize: Long?,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: CharSequence?,
|
override val formattedCaption: CharSequence?,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ fun aTimelineItemAudioContent(
|
||||||
caption: String? = null,
|
caption: String? = null,
|
||||||
) = TimelineItemAudioContent(
|
) = TimelineItemAudioContent(
|
||||||
filename = fileName,
|
filename = fileName,
|
||||||
|
fileSize = 100 * 1024L,
|
||||||
caption = caption,
|
caption = caption,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ sealed interface TimelineItemEventContentWithAttachment :
|
||||||
TimelineItemEventContent,
|
TimelineItemEventContent,
|
||||||
TimelineItemEventMutableContent {
|
TimelineItemEventMutableContent {
|
||||||
val filename: String
|
val filename: String
|
||||||
|
val fileSize: Long?
|
||||||
val caption: String?
|
val caption: String?
|
||||||
val formattedCaption: CharSequence?
|
val formattedCaption: CharSequence?
|
||||||
val mediaSource: MediaSource
|
val mediaSource: MediaSource
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAn
|
||||||
|
|
||||||
data class TimelineItemFileContent(
|
data class TimelineItemFileContent(
|
||||||
override val filename: String,
|
override val filename: String,
|
||||||
|
override val fileSize: Long?,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: CharSequence?,
|
override val formattedCaption: CharSequence?,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ fun aTimelineItemFileContent(
|
||||||
caption: String? = null,
|
caption: String? = null,
|
||||||
) = TimelineItemFileContent(
|
) = TimelineItemFileContent(
|
||||||
filename = fileName,
|
filename = fileName,
|
||||||
|
fileSize = 100 * 1024L,
|
||||||
caption = caption,
|
caption = caption,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
|
|
||||||
data class TimelineItemImageContent(
|
data class TimelineItemImageContent(
|
||||||
override val filename: String,
|
override val filename: String,
|
||||||
|
override val fileSize: Long?,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: CharSequence?,
|
override val formattedCaption: CharSequence?,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ fun aTimelineItemImageContent(
|
||||||
caption: String? = null,
|
caption: String? = null,
|
||||||
) = TimelineItemImageContent(
|
) = TimelineItemImageContent(
|
||||||
filename = filename,
|
filename = filename,
|
||||||
|
fileSize = 4 * 1024 * 1024L,
|
||||||
caption = caption,
|
caption = caption,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
|
||||||
data class TimelineItemStickerContent(
|
data class TimelineItemStickerContent(
|
||||||
override val filename: String,
|
override val filename: String,
|
||||||
|
override val fileSize: Long?,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: CharSequence?,
|
override val formattedCaption: CharSequence?,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ fun aTimelineItemStickerContent(
|
||||||
blurhash: String? = A_BLUR_HASH,
|
blurhash: String? = A_BLUR_HASH,
|
||||||
) = TimelineItemStickerContent(
|
) = TimelineItemStickerContent(
|
||||||
filename = "a sticker.gif",
|
filename = "a sticker.gif",
|
||||||
|
fileSize = 4 * 1024 * 1024L,
|
||||||
caption = "a body",
|
caption = "a body",
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import kotlin.time.Duration
|
||||||
|
|
||||||
data class TimelineItemVideoContent(
|
data class TimelineItemVideoContent(
|
||||||
override val filename: String,
|
override val filename: String,
|
||||||
|
override val fileSize: Long?,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: CharSequence?,
|
override val formattedCaption: CharSequence?,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ fun aTimelineItemVideoContent(
|
||||||
blurhash: String? = A_BLUR_HASH,
|
blurhash: String? = A_BLUR_HASH,
|
||||||
) = TimelineItemVideoContent(
|
) = TimelineItemVideoContent(
|
||||||
filename = "Video.mp4",
|
filename = "Video.mp4",
|
||||||
|
fileSize = 14 * 1024 * 1024L,
|
||||||
caption = null,
|
caption = null,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import kotlin.time.Duration
|
||||||
data class TimelineItemVoiceContent(
|
data class TimelineItemVoiceContent(
|
||||||
val eventId: EventId?,
|
val eventId: EventId?,
|
||||||
override val filename: String,
|
override val filename: String,
|
||||||
|
override val fileSize: Long?,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: CharSequence?,
|
override val formattedCaption: CharSequence?,
|
||||||
override val isEdited: Boolean,
|
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),
|
waveform: List<Float> = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
|
||||||
) = TimelineItemVoiceContent(
|
) = TimelineItemVoiceContent(
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
|
fileSize = 1024 * 1024,
|
||||||
filename = filename,
|
filename = filename,
|
||||||
caption = caption,
|
caption = caption,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
<string name="emoji_picker_category_people">"Усмешкі & Удзельнікі"</string>
|
<string name="emoji_picker_category_people">"Усмешкі & Удзельнікі"</string>
|
||||||
<string name="emoji_picker_category_places">"Падарожжы & Месцы"</string>
|
<string name="emoji_picker_category_places">"Падарожжы & Месцы"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Сімвалы"</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">"Заблакіраваць карыстальніка"</string>
|
||||||
<string name="screen_report_content_block_user_hint">"Адзначце, ці хочаце вы схаваць усе бягучыя і будучыя паведамленні ад гэтага карыстальніка"</string>
|
<string name="screen_report_content_block_user_hint">"Адзначце, ці хочаце вы схаваць усе бягучыя і будучыя паведамленні ад гэтага карыстальніка"</string>
|
||||||
<string name="screen_report_content_explanation">"Гэтае паведамленне будзе перададзена адміністратару вашага хатняга сервера. Яны не змогуць прачытаць зашыфраваныя паведамленні."</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_people">"Smajlíci a lidé"</string>
|
||||||
<string name="emoji_picker_category_places">"Cestování a místa"</string>
|
<string name="emoji_picker_category_places">"Cestování a místa"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Symboly"</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">"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_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>
|
<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_people">"Wynebau Hapus a Phobl"</string>
|
||||||
<string name="emoji_picker_category_places">"Teithio a Llefydd"</string>
|
<string name="emoji_picker_category_places">"Teithio a Llefydd"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Symbolau"</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">"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_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>
|
<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_people">"Smileys og mennesker"</string>
|
||||||
<string name="emoji_picker_category_places">"Rejser og steder"</string>
|
<string name="emoji_picker_category_places">"Rejser og steder"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Symboler"</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">"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_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>
|
<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_people">"Smileys & Menschen"</string>
|
||||||
<string name="emoji_picker_category_places">"Reisen & Orte"</string>
|
<string name="emoji_picker_category_places">"Reisen & Orte"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Symbole"</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">"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_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>
|
<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_people">"Φατσούλες & Άνθρωποι"</string>
|
||||||
<string name="emoji_picker_category_places">"Ταξίδια & Μέρη"</string>
|
<string name="emoji_picker_category_places">"Ταξίδια & Μέρη"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Σύμβολα"</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">"Αποκλεισμός χρήστη"</string>
|
||||||
<string name="screen_report_content_block_user_hint">"Επέλεξε εάν θες να αποκρύψεις όλα τα τρέχοντα και μελλοντικά μηνύματα από αυτόν τον χρήστη"</string>
|
<string name="screen_report_content_block_user_hint">"Επέλεξε εάν θες να αποκρύψεις όλα τα τρέχοντα και μελλοντικά μηνύματα από αυτόν τον χρήστη"</string>
|
||||||
<string name="screen_report_content_explanation">"Αυτό το μήνυμα θα αναφερθεί στον διαχειριστή του οικιακού διακομιστή σας. Δεν θα μπορεί να διαβάσει κρυπτογραφημένα μηνύματα."</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_people">"Emojis y personas"</string>
|
||||||
<string name="emoji_picker_category_places">"Viajes y lugares"</string>
|
<string name="emoji_picker_category_places">"Viajes y lugares"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Símbolos"</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">"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_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>
|
<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_people">"Emotikonid ja inimesed"</string>
|
||||||
<string name="emoji_picker_category_places">"Reisimine ja kohad"</string>
|
<string name="emoji_picker_category_places">"Reisimine ja kohad"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Sümbolid"</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">"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_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>
|
<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_people">"Irribartxoak eta jendea"</string>
|
||||||
<string name="emoji_picker_category_places">"Bidaiak eta tokiak"</string>
|
<string name="emoji_picker_category_places">"Bidaiak eta tokiak"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Ikurrak"</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_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_explanation">"Mezua zure zerbitzariko administratzaileari jakinaraziko zaio. Ezingo dute zifratutako mezurik irakurri."</string>
|
||||||
<string name="screen_report_content_hint">"Edukia salatzeko arrazoia"</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_people">"شکلکها و افراد"</string>
|
||||||
<string name="emoji_picker_category_places">"سفر و مکانها"</string>
|
<string name="emoji_picker_category_places">"سفر و مکانها"</string>
|
||||||
<string name="emoji_picker_category_symbols">"نمادها"</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">"انسداد کاربر"</string>
|
||||||
<string name="screen_report_content_block_user_hint">"اگر میخواهید همه پیامهای فعلی و آینده را از این کاربر را پنهان کنید، علامت بزنید"</string>
|
<string name="screen_report_content_block_user_hint">"اگر میخواهید همه پیامهای فعلی و آینده را از این کاربر را پنهان کنید، علامت بزنید"</string>
|
||||||
<string name="screen_report_content_explanation">"این پیام به مدیر کارساز خانگی شما گزارش خواهد شد. آنها قادر به خواندن پیام های رمزگذاری شده نخواهند بود."</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_people">"Hymiöt ja ihmiset"</string>
|
||||||
<string name="emoji_picker_category_places">"Matkustaminen ja paikat"</string>
|
<string name="emoji_picker_category_places">"Matkustaminen ja paikat"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Symbolit"</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">"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_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>
|
<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_people">"Émoticônes et personnes"</string>
|
||||||
<string name="emoji_picker_category_places">"Voyages & lieux"</string>
|
<string name="emoji_picker_category_places">"Voyages & lieux"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Symboles"</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">"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_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>
|
<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_people">"Mosolyok és emberek"</string>
|
||||||
<string name="emoji_picker_category_places">"Utazás és helyek"</string>
|
<string name="emoji_picker_category_places">"Utazás és helyek"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Szimbólumok"</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">"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_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>
|
<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_people">"Senyuman & Orang"</string>
|
||||||
<string name="emoji_picker_category_places">"Wisata & Tempat"</string>
|
<string name="emoji_picker_category_places">"Wisata & Tempat"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Simbol"</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">"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_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>
|
<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_people">"Faccine & Persone"</string>
|
||||||
<string name="emoji_picker_category_places">"Viaggi & Luoghi"</string>
|
<string name="emoji_picker_category_places">"Viaggi & Luoghi"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Simboli"</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">"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_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>
|
<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_people">"ღიმილები & ხალხი"</string>
|
||||||
<string name="emoji_picker_category_places">"მოგზაურობა და ადგილები"</string>
|
<string name="emoji_picker_category_places">"მოგზაურობა და ადგილები"</string>
|
||||||
<string name="emoji_picker_category_symbols">"სიმბოლოები"</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">"მომხმარებლის დაბლოკვა"</string>
|
||||||
<string name="screen_report_content_block_user_hint">"შეამოწმეთ, გსურთ თუ არა ამ მომხმარებლის ყველა მიმდინარე და მომავალი შეტყობინების დამალვა"</string>
|
<string name="screen_report_content_block_user_hint">"შეამოწმეთ, გსურთ თუ არა ამ მომხმარებლის ყველა მიმდინარე და მომავალი შეტყობინების დამალვა"</string>
|
||||||
<string name="screen_report_content_explanation">"ეს შეტყობინება გაგზავნილი იქნება თქვენი სახლის სერვერის ადმინისტრატორისადმი. მას არ ექნება დაშიფვრული შეტყობინებების წაკითხვის შესაძლებლობა."</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_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_places">"Kelionės ir Vietovės"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Simboliai"</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">"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_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>
|
<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_people">"Smilefjes og mennesker"</string>
|
||||||
<string name="emoji_picker_category_places">"Reising og steder"</string>
|
<string name="emoji_picker_category_places">"Reising og steder"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Symboler"</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">"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_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>
|
<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_people">"Smileys & Mensen"</string>
|
||||||
<string name="emoji_picker_category_places">"Reizen & Locaties"</string>
|
<string name="emoji_picker_category_places">"Reizen & Locaties"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Symbolen"</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">"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_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>
|
<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_people">"Buźki i osoby"</string>
|
||||||
<string name="emoji_picker_category_places">"Podróż i miejsca"</string>
|
<string name="emoji_picker_category_places">"Podróż i miejsca"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Symbole"</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">"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_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>
|
<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_people">"Sorrisos & Pessoas"</string>
|
||||||
<string name="emoji_picker_category_places">"Viagens & Lugares"</string>
|
<string name="emoji_picker_category_places">"Viagens & Lugares"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Símbolos"</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">"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_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>
|
<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_people">"Caras e Pessoas"</string>
|
||||||
<string name="emoji_picker_category_places">"Viagens e Lugares"</string>
|
<string name="emoji_picker_category_places">"Viagens e Lugares"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Símbolos"</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">"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_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>
|
<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_people">"Fețe zâmbitoare & Oameni"</string>
|
||||||
<string name="emoji_picker_category_places">"Călătorii & Locuri"</string>
|
<string name="emoji_picker_category_places">"Călătorii & Locuri"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Simboluri"</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">"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_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>
|
<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_people">"Улыбки и люди"</string>
|
||||||
<string name="emoji_picker_category_places">"Путешествия и места"</string>
|
<string name="emoji_picker_category_places">"Путешествия и места"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Символы"</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">"Заблокировать пользователя"</string>
|
||||||
<string name="screen_report_content_block_user_hint">"Отметьте, хотите ли вы скрыть все текущие и будущие сообщения от этого пользователя"</string>
|
<string name="screen_report_content_block_user_hint">"Отметьте, хотите ли вы скрыть все текущие и будущие сообщения от этого пользователя"</string>
|
||||||
<string name="screen_report_content_explanation">"Это сообщение будет передано администратору вашего домашнего сервера. Они не смогут прочитать зашифрованные сообщения."</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_people">"Smajlíky a ľudia"</string>
|
||||||
<string name="emoji_picker_category_places">"Cestovanie a miesta"</string>
|
<string name="emoji_picker_category_places">"Cestovanie a miesta"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Symboly"</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">"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_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>
|
<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_people">"Smileys & personer"</string>
|
||||||
<string name="emoji_picker_category_places">"Resor & platser"</string>
|
<string name="emoji_picker_category_places">"Resor & platser"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Symboler"</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">"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_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>
|
<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_people">"İfadeler ve İnsanlar"</string>
|
||||||
<string name="emoji_picker_category_places">"Seyahat ve Yerler"</string>
|
<string name="emoji_picker_category_places">"Seyahat ve Yerler"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Simgeler"</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">"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_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>
|
<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_people">"Смайлики та люди"</string>
|
||||||
<string name="emoji_picker_category_places">"Подорожі та місця"</string>
|
<string name="emoji_picker_category_places">"Подорожі та місця"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Символи"</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">"Заблокувати користувача"</string>
|
||||||
<string name="screen_report_content_block_user_hint">"Перевірте, чи хочете ви приховати всі поточні та майбутні повідомлення від цього користувача"</string>
|
<string name="screen_report_content_block_user_hint">"Перевірте, чи хочете ви приховати всі поточні та майбутні повідомлення від цього користувача"</string>
|
||||||
<string name="screen_report_content_explanation">"Це повідомлення буде надіслано адміністраторам вашого домашнього сервера. Вони не зможуть прочитати зашифровані повідомлення."</string>
|
<string name="screen_report_content_explanation">"Це повідомлення буде надіслано адміністраторам вашого домашнього сервера. Вони не зможуть прочитати зашифровані повідомлення."</string>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
<string name="emoji_picker_category_people">"مسکراہٹیں و لوگ"</string>
|
<string name="emoji_picker_category_people">"مسکراہٹیں و لوگ"</string>
|
||||||
<string name="emoji_picker_category_places">"سفر و مقامات"</string>
|
<string name="emoji_picker_category_places">"سفر و مقامات"</string>
|
||||||
<string name="emoji_picker_category_symbols">"علامتیں"</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">"صارف کو مسدود کریں"</string>
|
||||||
<string name="screen_report_content_block_user_hint">"پڑتال کریں کہ کیا آپ اس صارف سے تمام موجودہ اور مستقبلی پیغامات چھپانا چاہتے ہیں۔"</string>
|
<string name="screen_report_content_block_user_hint">"پڑتال کریں کہ کیا آپ اس صارف سے تمام موجودہ اور مستقبلی پیغامات چھپانا چاہتے ہیں۔"</string>
|
||||||
<string name="screen_report_content_explanation">"اس پیغام کی اطلاع آپکے منزلی خادم کے منتظم کو دی جائیگی۔ وہ کوئی مرموزکردہ پیغامات نہیں پڑھ سکیں گے۔"</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_people">"Smayllar va odamlar"</string>
|
||||||
<string name="emoji_picker_category_places">"Sayohat va Joylar"</string>
|
<string name="emoji_picker_category_places">"Sayohat va Joylar"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Belgilar"</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">"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_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>
|
<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_people">"表情與人物"</string>
|
||||||
<string name="emoji_picker_category_places">"旅行與景點"</string>
|
<string name="emoji_picker_category_places">"旅行與景點"</string>
|
||||||
<string name="emoji_picker_category_symbols">"標誌"</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">"封鎖使用者"</string>
|
||||||
<string name="screen_report_content_block_user_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</string>
|
<string name="screen_report_content_block_user_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</string>
|
||||||
<string name="screen_report_content_explanation">"此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。"</string>
|
<string name="screen_report_content_explanation">"此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。"</string>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
<string name="emoji_picker_category_people">"表情和人物"</string>
|
<string name="emoji_picker_category_people">"表情和人物"</string>
|
||||||
<string name="emoji_picker_category_places">"旅行和地点"</string>
|
<string name="emoji_picker_category_places">"旅行和地点"</string>
|
||||||
<string name="emoji_picker_category_symbols">"符号"</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">"封禁用户"</string>
|
||||||
<string name="screen_report_content_block_user_hint">"请确认是否要隐藏该用户当前和未来的所有信息"</string>
|
<string name="screen_report_content_block_user_hint">"请确认是否要隐藏该用户当前和未来的所有信息"</string>
|
||||||
<string name="screen_report_content_explanation">"此消息将举报给您的服务器管理员。他们无法读取任何加密消息。"</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_people">"Smileys & People"</string>
|
||||||
<string name="emoji_picker_category_places">"Travel & Places"</string>
|
<string name="emoji_picker_category_places">"Travel & Places"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Symbols"</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">"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_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>
|
<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(
|
val mediaMessage = aMessageEvent(
|
||||||
content = TimelineItemImageContent(
|
content = TimelineItemImageContent(
|
||||||
filename = "image.jpg",
|
filename = "image.jpg",
|
||||||
|
fileSize = 4 * 1024 * 1024L,
|
||||||
caption = null,
|
caption = null,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
|
@ -365,6 +366,7 @@ class MessagesPresenterTest {
|
||||||
val mediaMessage = aMessageEvent(
|
val mediaMessage = aMessageEvent(
|
||||||
content = TimelineItemVideoContent(
|
content = TimelineItemVideoContent(
|
||||||
filename = "video.mp4",
|
filename = "video.mp4",
|
||||||
|
fileSize = 50 * 1024 * 1024L,
|
||||||
caption = null,
|
caption = null,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
|
@ -406,6 +408,7 @@ class MessagesPresenterTest {
|
||||||
val mediaMessage = aMessageEvent(
|
val mediaMessage = aMessageEvent(
|
||||||
content = TimelineItemFileContent(
|
content = TimelineItemFileContent(
|
||||||
filename = "file.pdf",
|
filename = "file.pdf",
|
||||||
|
fileSize = 10 * 1024 * 1024L,
|
||||||
caption = null,
|
caption = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
formattedCaption = null,
|
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.AttachmentsPreviewPresenter
|
||||||
import io.element.android.features.messages.impl.attachments.preview.OnDoneListener
|
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.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.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.androidutils.file.TemporaryUriDeleter
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
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.permalink.FakePermalinkBuilder
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
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.MediaPreProcessor
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
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.api.local.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
|
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.WarmUpRule
|
||||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
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.fake.FakeTemporaryUriDeleter
|
||||||
import io.element.android.tests.testutils.lambda.any
|
import io.element.android.tests.testutils.lambda.any
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
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.test
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
|
@ -64,6 +73,7 @@ import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
@Suppress("LargeClass")
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class AttachmentsPreviewPresenterTest {
|
class AttachmentsPreviewPresenterTest {
|
||||||
@get:Rule
|
@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(
|
private fun TestScope.createAttachmentsPreviewPresenter(
|
||||||
localMedia: LocalMedia = aLocalMedia(
|
localMedia: LocalMedia = aLocalMedia(
|
||||||
uri = mockMediaUrl,
|
uri = mockMediaUrl,
|
||||||
|
|
@ -560,11 +675,27 @@ class AttachmentsPreviewPresenterTest {
|
||||||
mediaUploadOnSendQueueEnabled: Boolean = true,
|
mediaUploadOnSendQueueEnabled: Boolean = true,
|
||||||
allowCaption: Boolean = true,
|
allowCaption: Boolean = true,
|
||||||
showCaptionCompatibilityWarning: 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 {
|
): AttachmentsPreviewPresenter {
|
||||||
return AttachmentsPreviewPresenter(
|
return AttachmentsPreviewPresenter(
|
||||||
attachment = aMediaAttachment(localMedia),
|
attachment = aMediaAttachment(localMedia),
|
||||||
onDoneListener = onDoneListener,
|
onDoneListener = onDoneListener,
|
||||||
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
|
mediaSender = MediaSender(mediaPreProcessor, room, {
|
||||||
|
MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD)
|
||||||
|
}),
|
||||||
permalinkBuilder = permalinkBuilder,
|
permalinkBuilder = permalinkBuilder,
|
||||||
temporaryUriDeleter = temporaryUriDeleter,
|
temporaryUriDeleter = temporaryUriDeleter,
|
||||||
featureFlagService = FakeFeatureFlagService(
|
featureFlagService = FakeFeatureFlagService(
|
||||||
|
|
@ -576,6 +707,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
),
|
),
|
||||||
sessionCoroutineScope = this,
|
sessionCoroutineScope = this,
|
||||||
dispatchers = testCoroutineDispatchers(),
|
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.matrix.ui.messages.reply.InReplyToDetails
|
||||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
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.MediaPreProcessor
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
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.mediaupload.test.FakeMediaPreProcessor
|
||||||
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
|
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
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.SessionPreferencesStore
|
||||||
|
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||||
|
|
@ -1542,6 +1545,7 @@ class MessageComposerPresenterTest {
|
||||||
textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(),
|
textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(),
|
||||||
isRichTextEditorEnabled: Boolean = true,
|
isRichTextEditorEnabled: Boolean = true,
|
||||||
draftService: ComposerDraftService = FakeComposerDraftService(),
|
draftService: ComposerDraftService = FakeComposerDraftService(),
|
||||||
|
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||||
) = MessageComposerPresenter(
|
) = MessageComposerPresenter(
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
sessionCoroutineScope = this,
|
sessionCoroutineScope = this,
|
||||||
|
|
@ -1550,7 +1554,11 @@ class MessageComposerPresenterTest {
|
||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
sessionPreferencesStore = sessionPreferencesStore,
|
sessionPreferencesStore = sessionPreferencesStore,
|
||||||
localMediaFactory = localMediaFactory,
|
localMediaFactory = localMediaFactory,
|
||||||
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
|
mediaSender = MediaSender(
|
||||||
|
preProcessor = mediaPreProcessor,
|
||||||
|
room = room,
|
||||||
|
mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) }
|
||||||
|
),
|
||||||
snackbarDispatcher = snackbarDispatcher,
|
snackbarDispatcher = snackbarDispatcher,
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
locationService = locationService,
|
locationService = locationService,
|
||||||
|
|
@ -1565,6 +1573,7 @@ class MessageComposerPresenterTest {
|
||||||
mentionSpanProvider = mentionSpanProvider,
|
mentionSpanProvider = mentionSpanProvider,
|
||||||
pillificationHelper = textPillificationHelper,
|
pillificationHelper = textPillificationHelper,
|
||||||
suggestionsProcessor = SuggestionsProcessor(),
|
suggestionsProcessor = SuggestionsProcessor(),
|
||||||
|
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||||
).apply {
|
).apply {
|
||||||
isTesting = true
|
isTesting = true
|
||||||
showTextFormatting = isRichTextEditorEnabled
|
showTextFormatting = isRichTextEditorEnabled
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
)
|
)
|
||||||
val expected = TimelineItemVideoContent(
|
val expected = TimelineItemVideoContent(
|
||||||
filename = "filename",
|
filename = "filename",
|
||||||
|
fileSize = 0L,
|
||||||
caption = null,
|
caption = null,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
|
@ -283,6 +284,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
)
|
)
|
||||||
val expected = TimelineItemVideoContent(
|
val expected = TimelineItemVideoContent(
|
||||||
filename = "body.mp4",
|
filename = "body.mp4",
|
||||||
|
fileSize = 555L,
|
||||||
caption = "body.mp4 caption",
|
caption = "body.mp4 caption",
|
||||||
formattedCaption = SpannedString("formatted"),
|
formattedCaption = SpannedString("formatted"),
|
||||||
isEdited = true,
|
isEdited = true,
|
||||||
|
|
@ -312,6 +314,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
)
|
)
|
||||||
val expected = TimelineItemAudioContent(
|
val expected = TimelineItemAudioContent(
|
||||||
filename = "filename",
|
filename = "filename",
|
||||||
|
fileSize = 0L,
|
||||||
caption = null,
|
caption = null,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
|
@ -347,6 +350,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
)
|
)
|
||||||
val expected = TimelineItemAudioContent(
|
val expected = TimelineItemAudioContent(
|
||||||
filename = "body.mp3",
|
filename = "body.mp3",
|
||||||
|
fileSize = 123L,
|
||||||
caption = null,
|
caption = null,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = true,
|
isEdited = true,
|
||||||
|
|
@ -369,6 +373,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
)
|
)
|
||||||
val expected = TimelineItemVoiceContent(
|
val expected = TimelineItemVoiceContent(
|
||||||
filename = "filename",
|
filename = "filename",
|
||||||
|
fileSize = 0L,
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
caption = null,
|
caption = null,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
|
|
@ -411,6 +416,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
val expected = TimelineItemVoiceContent(
|
val expected = TimelineItemVoiceContent(
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
filename = "body.ogg",
|
filename = "body.ogg",
|
||||||
|
fileSize = 123L,
|
||||||
caption = null,
|
caption = null,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = true,
|
isEdited = true,
|
||||||
|
|
@ -440,6 +446,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
)
|
)
|
||||||
val expected = TimelineItemAudioContent(
|
val expected = TimelineItemAudioContent(
|
||||||
filename = "filename",
|
filename = "filename",
|
||||||
|
fileSize = 0L,
|
||||||
caption = null,
|
caption = null,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
|
@ -462,6 +469,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
)
|
)
|
||||||
val expected = TimelineItemImageContent(
|
val expected = TimelineItemImageContent(
|
||||||
filename = "filename",
|
filename = "filename",
|
||||||
|
fileSize = 0L,
|
||||||
caption = "body",
|
caption = "body",
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
|
@ -492,6 +500,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
)
|
)
|
||||||
val expected = TimelineItemStickerContent(
|
val expected = TimelineItemStickerContent(
|
||||||
filename = "filename",
|
filename = "filename",
|
||||||
|
fileSize = 8_192L,
|
||||||
caption = null,
|
caption = null,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
|
@ -540,6 +549,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
)
|
)
|
||||||
val expected = TimelineItemImageContent(
|
val expected = TimelineItemImageContent(
|
||||||
filename = "body.jpg",
|
filename = "body.jpg",
|
||||||
|
fileSize = 888L,
|
||||||
caption = "body.jpg caption",
|
caption = "body.jpg caption",
|
||||||
formattedCaption = SpannedString("formatted"),
|
formattedCaption = SpannedString("formatted"),
|
||||||
isEdited = true,
|
isEdited = true,
|
||||||
|
|
@ -568,6 +578,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
)
|
)
|
||||||
val expected = TimelineItemFileContent(
|
val expected = TimelineItemFileContent(
|
||||||
filename = "filename",
|
filename = "filename",
|
||||||
|
fileSize = 0L,
|
||||||
caption = null,
|
caption = null,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
|
|
@ -609,6 +620,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
)
|
)
|
||||||
val expected = TimelineItemFileContent(
|
val expected = TimelineItemFileContent(
|
||||||
filename = "body.pdf",
|
filename = "body.pdf",
|
||||||
|
fileSize = 123L,
|
||||||
caption = null,
|
caption = null,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
isEdited = true,
|
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.room.FakeJoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
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.api.MediaSender
|
||||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.api.aPermissionsState
|
import io.element.android.libraries.permissions.api.aPermissionsState
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
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.MessageComposerMode
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||||
|
|
@ -72,7 +73,11 @@ class VoiceMessageComposerPresenterTest {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
|
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()
|
private val messageComposerContext = FakeMessageComposerContext()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api(projects.features.messages.api)
|
api(projects.features.messages.impl)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
implementation(projects.libraries.mediaviewer.api)
|
||||||
|
implementation(projects.libraries.preferences.api)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.test.attachments.video
|
||||||
|
|
||||||
|
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter
|
||||||
|
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
|
|
||||||
|
class FakeMediaOptimizationSelectorPresenterFactory(
|
||||||
|
private val fakePresenter: MediaOptimizationSelectorPresenter = MediaOptimizationSelectorPresenter {
|
||||||
|
MediaOptimizationSelectorState(
|
||||||
|
maxUploadSize = AsyncData.Uninitialized,
|
||||||
|
videoSizeEstimations = AsyncData.Uninitialized,
|
||||||
|
isImageOptimizationEnabled = null,
|
||||||
|
selectedVideoPreset = null,
|
||||||
|
displayMediaSelectorViews = null,
|
||||||
|
displayVideoPresetSelectorDialog = false,
|
||||||
|
eventSink = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) : MediaOptimizationSelectorPresenter.Factory {
|
||||||
|
override fun create(localMedia: LocalMedia): MediaOptimizationSelectorPresenter {
|
||||||
|
return fakePresenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.test.attachments.video
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Size
|
||||||
|
import io.element.android.features.messages.impl.attachments.video.VideoMetadataExtractor
|
||||||
|
|
||||||
|
class FakeVideoMetadataExtractor(
|
||||||
|
private val sizeResult: Result<Size> = Result.success(Size(1, 1)),
|
||||||
|
private val duration: Result<Long> = Result.success(1L),
|
||||||
|
) : VideoMetadataExtractor {
|
||||||
|
override fun getSize(): Result<Size> = sizeResult
|
||||||
|
|
||||||
|
override fun getDuration(): Result<Long> = duration
|
||||||
|
|
||||||
|
override fun close() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeVideoMetadataExtractorFactory(
|
||||||
|
private val fakeVideoMetadataExtractor: FakeVideoMetadataExtractor = FakeVideoMetadataExtractor(),
|
||||||
|
) : VideoMetadataExtractor.Factory {
|
||||||
|
override fun create(uri: Uri): VideoMetadataExtractor {
|
||||||
|
return fakeVideoMetadataExtractor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,11 +8,14 @@
|
||||||
package io.element.android.features.preferences.impl.advanced
|
package io.element.android.features.preferences.impl.advanced
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
|
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||||
|
|
||||||
sealed interface AdvancedSettingsEvents {
|
sealed interface AdvancedSettingsEvents {
|
||||||
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
||||||
data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
||||||
data class SetCompressMedia(val compress: Boolean) : AdvancedSettingsEvents
|
data class SetCompressMedia(val compress: Boolean) : AdvancedSettingsEvents
|
||||||
|
data class SetCompressImages(val compress: Boolean) : AdvancedSettingsEvents
|
||||||
|
data class SetVideoUploadQuality(val videoPreset: VideoCompressionPreset) : AdvancedSettingsEvents
|
||||||
data class SetTheme(val theme: ThemeOption) : AdvancedSettingsEvents
|
data class SetTheme(val theme: ThemeOption) : AdvancedSettingsEvents
|
||||||
data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : AdvancedSettingsEvents
|
data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : AdvancedSettingsEvents
|
||||||
data class SetHideInviteAvatars(val value: Boolean) : AdvancedSettingsEvents
|
data class SetHideInviteAvatars(val value: Boolean) : AdvancedSettingsEvents
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,19 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import io.element.android.compound.theme.Theme
|
import io.element.android.compound.theme.Theme
|
||||||
import io.element.android.compound.theme.mapToTheme
|
import io.element.android.compound.theme.mapToTheme
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -29,6 +33,7 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||||
private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore,
|
private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore,
|
||||||
@SessionCoroutineScope
|
@SessionCoroutineScope
|
||||||
private val sessionCoroutineScope: CoroutineScope,
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
) : Presenter<AdvancedSettingsState> {
|
) : Presenter<AdvancedSettingsState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): AdvancedSettingsState {
|
override fun present(): AdvancedSettingsState {
|
||||||
|
|
@ -38,9 +43,6 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||||
val isSharePresenceEnabled by remember {
|
val isSharePresenceEnabled by remember {
|
||||||
sessionPreferencesStore.isSharePresenceEnabled()
|
sessionPreferencesStore.isSharePresenceEnabled()
|
||||||
}.collectAsState(initial = true)
|
}.collectAsState(initial = true)
|
||||||
val doesCompressMedia by remember {
|
|
||||||
sessionPreferencesStore.doesCompressMedia()
|
|
||||||
}.collectAsState(initial = true)
|
|
||||||
val theme = remember {
|
val theme = remember {
|
||||||
appPreferencesStore.getThemeFlow().mapToTheme()
|
appPreferencesStore.getThemeFlow().mapToTheme()
|
||||||
}.collectAsState(initial = Theme.System)
|
}.collectAsState(initial = Theme.System)
|
||||||
|
|
@ -57,6 +59,28 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val hasSplitMediaQualityOptions by produceState<Boolean?>(null) {
|
||||||
|
value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaOptimizationState by produceState<MediaOptimizationState?>(null) {
|
||||||
|
val hasSplitMediaQualityOptionsFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.SelectableMediaQuality)
|
||||||
|
combine(
|
||||||
|
hasSplitMediaQualityOptionsFlow,
|
||||||
|
sessionPreferencesStore.doesOptimizeImages(),
|
||||||
|
sessionPreferencesStore.getVideoCompressionPreset()
|
||||||
|
) { hasSplitOptions, compressImages, videoPreset ->
|
||||||
|
if (hasSplitMediaQualityOptions == true) {
|
||||||
|
value = MediaOptimizationState.Split(
|
||||||
|
compressImages = compressImages,
|
||||||
|
videoPreset = videoPreset,
|
||||||
|
)
|
||||||
|
} else if (hasSplitMediaQualityOptions == false) {
|
||||||
|
value = MediaOptimizationState.AllMedia(isEnabled = compressImages)
|
||||||
|
}
|
||||||
|
}.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvents(event: AdvancedSettingsEvents) {
|
fun handleEvents(event: AdvancedSettingsEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> sessionCoroutineScope.launch {
|
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> sessionCoroutineScope.launch {
|
||||||
|
|
@ -66,7 +90,7 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||||
sessionPreferencesStore.setSharePresence(event.enabled)
|
sessionPreferencesStore.setSharePresence(event.enabled)
|
||||||
}
|
}
|
||||||
is AdvancedSettingsEvents.SetCompressMedia -> sessionCoroutineScope.launch {
|
is AdvancedSettingsEvents.SetCompressMedia -> sessionCoroutineScope.launch {
|
||||||
sessionPreferencesStore.setCompressMedia(event.compress)
|
sessionPreferencesStore.setOptimizeImages(event.compress)
|
||||||
}
|
}
|
||||||
is AdvancedSettingsEvents.SetTheme -> sessionCoroutineScope.launch {
|
is AdvancedSettingsEvents.SetTheme -> sessionCoroutineScope.launch {
|
||||||
when (event.theme) {
|
when (event.theme) {
|
||||||
|
|
@ -77,13 +101,19 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
is AdvancedSettingsEvents.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value)
|
is AdvancedSettingsEvents.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value)
|
||||||
is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value)
|
is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value)
|
||||||
|
is AdvancedSettingsEvents.SetCompressImages -> sessionCoroutineScope.launch {
|
||||||
|
sessionPreferencesStore.setOptimizeImages(event.compress)
|
||||||
|
}
|
||||||
|
is AdvancedSettingsEvents.SetVideoUploadQuality -> sessionCoroutineScope.launch {
|
||||||
|
sessionPreferencesStore.setVideoCompressionPreset(event.videoPreset)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return AdvancedSettingsState(
|
return AdvancedSettingsState(
|
||||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||||
isSharePresenceEnabled = isSharePresenceEnabled,
|
isSharePresenceEnabled = isSharePresenceEnabled,
|
||||||
doesCompressMedia = doesCompressMedia,
|
mediaOptimizationState = mediaOptimizationState,
|
||||||
theme = themeOption,
|
theme = themeOption,
|
||||||
mediaPreviewConfigState = mediaPreviewConfigState,
|
mediaPreviewConfigState = mediaPreviewConfigState,
|
||||||
eventSink = ::handleEvents,
|
eventSink = ::handleEvents,
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,31 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import io.element.android.libraries.designsystem.components.preferences.DropdownOption
|
import io.element.android.libraries.designsystem.components.preferences.DropdownOption
|
||||||
|
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
data class AdvancedSettingsState(
|
data class AdvancedSettingsState(
|
||||||
val isDeveloperModeEnabled: Boolean,
|
val isDeveloperModeEnabled: Boolean,
|
||||||
val isSharePresenceEnabled: Boolean,
|
val isSharePresenceEnabled: Boolean,
|
||||||
val doesCompressMedia: Boolean,
|
val mediaOptimizationState: MediaOptimizationState?,
|
||||||
val theme: ThemeOption,
|
val theme: ThemeOption,
|
||||||
val mediaPreviewConfigState: MediaPreviewConfigState,
|
val mediaPreviewConfigState: MediaPreviewConfigState,
|
||||||
val eventSink: (AdvancedSettingsEvents) -> Unit
|
val eventSink: (AdvancedSettingsEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sealed interface MediaOptimizationState {
|
||||||
|
data class AllMedia(val isEnabled: Boolean) : MediaOptimizationState
|
||||||
|
data class Split(
|
||||||
|
val compressImages: Boolean,
|
||||||
|
val videoPreset: VideoCompressionPreset,
|
||||||
|
) : MediaOptimizationState
|
||||||
|
|
||||||
|
val shouldCompressImages: Boolean get() = when (this) {
|
||||||
|
is AllMedia -> isEnabled
|
||||||
|
is Split -> compressImages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum class ThemeOption : DropdownOption {
|
enum class ThemeOption : DropdownOption {
|
||||||
System {
|
System {
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.preferences.impl.advanced
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
|
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||||
|
|
||||||
open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSettingsState> {
|
open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSettingsState> {
|
||||||
override val values: Sequence<AdvancedSettingsState>
|
override val values: Sequence<AdvancedSettingsState>
|
||||||
|
|
@ -17,18 +18,22 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
|
||||||
aAdvancedSettingsState(),
|
aAdvancedSettingsState(),
|
||||||
aAdvancedSettingsState(isDeveloperModeEnabled = true),
|
aAdvancedSettingsState(isDeveloperModeEnabled = true),
|
||||||
aAdvancedSettingsState(isSharePresenceEnabled = true),
|
aAdvancedSettingsState(isSharePresenceEnabled = true),
|
||||||
aAdvancedSettingsState(doesCompressMedia = true),
|
aAdvancedSettingsState(mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true)),
|
||||||
aAdvancedSettingsState(hideInviteAvatars = true),
|
aAdvancedSettingsState(hideInviteAvatars = true),
|
||||||
aAdvancedSettingsState(timelineMediaPreviewValue = MediaPreviewValue.Off),
|
aAdvancedSettingsState(timelineMediaPreviewValue = MediaPreviewValue.Off),
|
||||||
aAdvancedSettingsState(setHideInviteAvatarsAction = AsyncAction.Loading),
|
aAdvancedSettingsState(setHideInviteAvatarsAction = AsyncAction.Loading),
|
||||||
aAdvancedSettingsState(setTimelineMediaPreviewAction = AsyncAction.Loading),
|
aAdvancedSettingsState(setTimelineMediaPreviewAction = AsyncAction.Loading),
|
||||||
|
aAdvancedSettingsState(mediaOptimizationState = MediaOptimizationState.Split(
|
||||||
|
compressImages = true,
|
||||||
|
videoPreset = VideoCompressionPreset.HIGH,
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aAdvancedSettingsState(
|
fun aAdvancedSettingsState(
|
||||||
isDeveloperModeEnabled: Boolean = false,
|
isDeveloperModeEnabled: Boolean = false,
|
||||||
isSharePresenceEnabled: Boolean = false,
|
isSharePresenceEnabled: Boolean = false,
|
||||||
doesCompressMedia: Boolean = false,
|
mediaOptimizationState: MediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = false),
|
||||||
theme: ThemeOption = ThemeOption.System,
|
theme: ThemeOption = ThemeOption.System,
|
||||||
hideInviteAvatars: Boolean = false,
|
hideInviteAvatars: Boolean = false,
|
||||||
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
|
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
|
||||||
|
|
@ -38,7 +43,7 @@ fun aAdvancedSettingsState(
|
||||||
) = AdvancedSettingsState(
|
) = AdvancedSettingsState(
|
||||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||||
isSharePresenceEnabled = isSharePresenceEnabled,
|
isSharePresenceEnabled = isSharePresenceEnabled,
|
||||||
doesCompressMedia = doesCompressMedia,
|
mediaOptimizationState = mediaOptimizationState,
|
||||||
theme = theme,
|
theme = theme,
|
||||||
mediaPreviewConfigState = MediaPreviewConfigState(
|
mediaPreviewConfigState = MediaPreviewConfigState(
|
||||||
hideInviteAvatars = hideInviteAvatars,
|
hideInviteAvatars = hideInviteAvatars,
|
||||||
|
|
|
||||||
|
|
@ -10,20 +10,27 @@ package io.element.android.features.preferences.impl.advanced
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import im.vector.app.features.analytics.plan.Interaction
|
import im.vector.app.features.analytics.plan.Interaction
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.features.preferences.impl.R
|
import io.element.android.features.preferences.impl.R
|
||||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
|
||||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
|
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||||
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
|
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
|
||||||
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
|
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
|
||||||
|
|
@ -34,6 +41,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
|
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.services.analytics.compose.LocalAnalyticsService
|
import io.element.android.services.analytics.compose.LocalAnalyticsService
|
||||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||||
|
|
@ -94,32 +102,153 @@ fun AdvancedSettingsView(
|
||||||
),
|
),
|
||||||
onClick = { state.eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) }
|
onClick = { state.eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) }
|
||||||
)
|
)
|
||||||
ListItem(
|
val compressImages = state.mediaOptimizationState?.shouldCompressImages
|
||||||
headlineContent = {
|
|
||||||
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_title))
|
when (state.mediaOptimizationState) {
|
||||||
},
|
null -> Unit
|
||||||
supportingContent = {
|
is MediaOptimizationState.AllMedia -> {
|
||||||
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_description))
|
ListItem(
|
||||||
},
|
headlineContent = {
|
||||||
trailingContent = ListItemContent.Switch(
|
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_title))
|
||||||
checked = state.doesCompressMedia,
|
},
|
||||||
),
|
supportingContent = {
|
||||||
onClick = {
|
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_description))
|
||||||
val newValue = !state.doesCompressMedia
|
},
|
||||||
analyticsService.captureInteraction(
|
trailingContent = ListItemContent.Switch(
|
||||||
if (newValue) {
|
checked = compressImages ?: false,
|
||||||
Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled
|
),
|
||||||
} else {
|
onClick = {
|
||||||
Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled
|
val newValue = !(compressImages ?: false)
|
||||||
|
analyticsService.captureInteraction(
|
||||||
|
if (newValue) {
|
||||||
|
Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled
|
||||||
|
} else {
|
||||||
|
Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled
|
||||||
|
}
|
||||||
|
)
|
||||||
|
state.eventSink(AdvancedSettingsEvents.SetCompressMedia(newValue))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
state.eventSink(AdvancedSettingsEvents.SetCompressMedia(newValue))
|
|
||||||
}
|
}
|
||||||
)
|
is MediaOptimizationState.Split -> {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_title))
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_description))
|
||||||
|
},
|
||||||
|
trailingContent = ListItemContent.Switch(
|
||||||
|
checked = compressImages ?: false,
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
val newValue = !(compressImages ?: false)
|
||||||
|
analyticsService.captureInteraction(
|
||||||
|
if (newValue) {
|
||||||
|
Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled
|
||||||
|
} else {
|
||||||
|
Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled
|
||||||
|
}
|
||||||
|
)
|
||||||
|
state.eventSink(AdvancedSettingsEvents.SetCompressMedia(newValue))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
var displaySelectorDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_title))
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
val description = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_description)
|
||||||
|
val quality = when (state.mediaOptimizationState.videoPreset) {
|
||||||
|
VideoCompressionPreset.LOW -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_low)
|
||||||
|
VideoCompressionPreset.STANDARD -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_standard)
|
||||||
|
VideoCompressionPreset.HIGH -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_high)
|
||||||
|
}
|
||||||
|
val descriptionWithValue = remember(quality) {
|
||||||
|
String.format(description, quality)
|
||||||
|
}
|
||||||
|
Text(text = descriptionWithValue)
|
||||||
|
},
|
||||||
|
onClick = { displaySelectorDialog = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (displaySelectorDialog) {
|
||||||
|
VideoQualitySelectorDialog(
|
||||||
|
selectedPreset = state.mediaOptimizationState.videoPreset,
|
||||||
|
onSubmit = { preset ->
|
||||||
|
state.eventSink(AdvancedSettingsEvents.SetVideoUploadQuality(preset))
|
||||||
|
displaySelectorDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { displaySelectorDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ModerationAndSafety(state)
|
ModerationAndSafety(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VideoQualitySelectorDialog(
|
||||||
|
selectedPreset: VideoCompressionPreset,
|
||||||
|
onSubmit: (VideoCompressionPreset) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val videoPresets = VideoCompressionPreset.entries
|
||||||
|
var localSelectedPreset by remember { mutableStateOf(selectedPreset) }
|
||||||
|
ListDialog(
|
||||||
|
title = stringResource(CommonStrings.dialog_video_quality_selector_title),
|
||||||
|
subtitle = stringResource(CommonStrings.dialog_default_video_quality_selector_subtitle),
|
||||||
|
onSubmit = { onSubmit(localSelectedPreset) },
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
applyPaddingToContents = false,
|
||||||
|
) {
|
||||||
|
for (preset in videoPresets) {
|
||||||
|
val isSelected = preset == localSelectedPreset
|
||||||
|
item(
|
||||||
|
key = preset,
|
||||||
|
contentType = preset,
|
||||||
|
) {
|
||||||
|
val title = when (preset) {
|
||||||
|
VideoCompressionPreset.LOW -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_low)
|
||||||
|
VideoCompressionPreset.STANDARD -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_standard)
|
||||||
|
VideoCompressionPreset.HIGH -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_high)
|
||||||
|
}
|
||||||
|
val subtitle = when (preset) {
|
||||||
|
VideoCompressionPreset.LOW -> stringResource(CommonStrings.common_video_quality_low_description)
|
||||||
|
VideoCompressionPreset.STANDARD -> stringResource(CommonStrings.common_video_quality_standard_description)
|
||||||
|
VideoCompressionPreset.HIGH -> stringResource(CommonStrings.common_video_quality_high_description)
|
||||||
|
}
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = ElementTheme.typography.fontBodyLgMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = ElementTheme.materialTypography.bodyMedium,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = ListItemContent.RadioButton(
|
||||||
|
selected = isSelected,
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
localSelectedPreset = preset
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ModerationAndSafety(
|
private fun ModerationAndSafety(
|
||||||
state: AdvancedSettingsState,
|
state: AdvancedSettingsState,
|
||||||
|
|
@ -202,3 +331,15 @@ private fun ContentToPreview(state: AdvancedSettingsState) {
|
||||||
onBackClick = { }
|
onBackClick = { }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@PreviewsDayNight
|
||||||
|
internal fun VideoQualitySelectorDialogPreview() {
|
||||||
|
ElementPreview {
|
||||||
|
VideoQualitySelectorDialog(
|
||||||
|
selectedPreset = VideoCompressionPreset.STANDARD,
|
||||||
|
onSubmit = { /* no-op */ },
|
||||||
|
onDismiss = { /* no-op */ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
|
|
@ -46,6 +47,7 @@ class EditUserProfilePresenter @AssistedInject constructor(
|
||||||
private val mediaPickerProvider: PickerProvider,
|
private val mediaPickerProvider: PickerProvider,
|
||||||
private val mediaPreProcessor: MediaPreProcessor,
|
private val mediaPreProcessor: MediaPreProcessor,
|
||||||
private val temporaryUriDeleter: TemporaryUriDeleter,
|
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||||
|
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||||
) : Presenter<EditUserProfileState> {
|
) : Presenter<EditUserProfileState> {
|
||||||
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
|
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
|
||||||
|
|
@ -175,7 +177,7 @@ class EditUserProfilePresenter @AssistedInject constructor(
|
||||||
uri = avatarUri,
|
uri = avatarUri,
|
||||||
mimeType = MimeTypes.Jpeg,
|
mimeType = MimeTypes.Jpeg,
|
||||||
deleteOriginal = false,
|
deleteOriginal = false,
|
||||||
compressIfPossible = false,
|
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
|
||||||
).getOrThrow()
|
).getOrThrow()
|
||||||
matrixClient.uploadAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
|
matrixClient.uploadAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
|
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
|
@ -34,13 +37,19 @@ class AdvancedSettingsPresenterTest {
|
||||||
with(awaitItem()) {
|
with(awaitItem()) {
|
||||||
assertThat(isDeveloperModeEnabled).isFalse()
|
assertThat(isDeveloperModeEnabled).isFalse()
|
||||||
assertThat(isSharePresenceEnabled).isTrue()
|
assertThat(isSharePresenceEnabled).isTrue()
|
||||||
assertThat(doesCompressMedia).isTrue()
|
assertThat(mediaOptimizationState).isNull()
|
||||||
assertThat(theme).isEqualTo(ThemeOption.System)
|
assertThat(theme).isEqualTo(ThemeOption.System)
|
||||||
assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
|
assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
|
||||||
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
|
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
|
||||||
assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized)
|
assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized)
|
||||||
assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Uninitialized)
|
assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Uninitialized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After the initial state, we expect the media optimization state to be set
|
||||||
|
with(awaitItem()) {
|
||||||
|
assertThat(mediaOptimizationState).isInstanceOf(MediaOptimizationState.AllMedia::class.java)
|
||||||
|
assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,6 +59,9 @@ class AdvancedSettingsPresenterTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
// Skip until the initial data it loaded
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
with(awaitItem()) {
|
with(awaitItem()) {
|
||||||
assertThat(isDeveloperModeEnabled).isFalse()
|
assertThat(isDeveloperModeEnabled).isFalse()
|
||||||
eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(true))
|
eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(true))
|
||||||
|
|
@ -70,6 +82,9 @@ class AdvancedSettingsPresenterTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
// Skip until the initial data it loaded
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
with(awaitItem()) {
|
with(awaitItem()) {
|
||||||
assertThat(isSharePresenceEnabled).isTrue()
|
assertThat(isSharePresenceEnabled).isTrue()
|
||||||
eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(false))
|
eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(false))
|
||||||
|
|
@ -90,16 +105,73 @@ class AdvancedSettingsPresenterTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
// Skip until the initial data it loaded
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
with(awaitItem()) {
|
with(awaitItem()) {
|
||||||
assertThat(doesCompressMedia).isTrue()
|
assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue()
|
||||||
eventSink(AdvancedSettingsEvents.SetCompressMedia(false))
|
eventSink(AdvancedSettingsEvents.SetCompressMedia(false))
|
||||||
}
|
}
|
||||||
with(awaitItem()) {
|
with(awaitItem()) {
|
||||||
assertThat(doesCompressMedia).isFalse()
|
assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isFalse()
|
||||||
eventSink(AdvancedSettingsEvents.SetCompressMedia(true))
|
eventSink(AdvancedSettingsEvents.SetCompressMedia(true))
|
||||||
}
|
}
|
||||||
with(awaitItem()) {
|
with(awaitItem()) {
|
||||||
assertThat(doesCompressMedia).isTrue()
|
assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - compress images off on`() = runTest {
|
||||||
|
val presenter = createAdvancedSettingsPresenter(
|
||||||
|
featureFlagService = FakeFeatureFlagService().apply {
|
||||||
|
setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
// Skip until the initial data it loaded
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
with(awaitItem()) {
|
||||||
|
assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue()
|
||||||
|
eventSink(AdvancedSettingsEvents.SetCompressImages(false))
|
||||||
|
}
|
||||||
|
with(awaitItem()) {
|
||||||
|
assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isFalse()
|
||||||
|
eventSink(AdvancedSettingsEvents.SetCompressImages(true))
|
||||||
|
}
|
||||||
|
with(awaitItem()) {
|
||||||
|
assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - video upload quality selector`() = runTest {
|
||||||
|
val presenter = createAdvancedSettingsPresenter(
|
||||||
|
featureFlagService = FakeFeatureFlagService().apply {
|
||||||
|
setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
// Skip until the initial data it loaded
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
with(awaitItem()) {
|
||||||
|
assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.STANDARD)
|
||||||
|
eventSink(AdvancedSettingsEvents.SetVideoUploadQuality(VideoCompressionPreset.LOW))
|
||||||
|
}
|
||||||
|
with(awaitItem()) {
|
||||||
|
assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.LOW)
|
||||||
|
eventSink(AdvancedSettingsEvents.SetVideoUploadQuality(VideoCompressionPreset.HIGH))
|
||||||
|
}
|
||||||
|
with(awaitItem()) {
|
||||||
|
assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.HIGH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +182,9 @@ class AdvancedSettingsPresenterTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
// Skip until the initial data it loaded
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
with(awaitItem()) {
|
with(awaitItem()) {
|
||||||
assertThat(theme).isEqualTo(ThemeOption.System)
|
assertThat(theme).isEqualTo(ThemeOption.System)
|
||||||
eventSink(AdvancedSettingsEvents.SetTheme(ThemeOption.Dark))
|
eventSink(AdvancedSettingsEvents.SetTheme(ThemeOption.Dark))
|
||||||
|
|
@ -135,6 +210,9 @@ class AdvancedSettingsPresenterTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
// Skip until the initial data it loaded
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
with(awaitItem()) {
|
with(awaitItem()) {
|
||||||
assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
|
assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
|
||||||
eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(true))
|
eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(true))
|
||||||
|
|
@ -157,6 +235,9 @@ class AdvancedSettingsPresenterTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
// Skip until the initial data it loaded
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
with(awaitItem()) {
|
with(awaitItem()) {
|
||||||
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
|
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
|
||||||
eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
|
eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
|
||||||
|
|
@ -184,6 +265,9 @@ class AdvancedSettingsPresenterTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
// Skip until the initial data it loaded
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
with(awaitItem()) {
|
with(awaitItem()) {
|
||||||
assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue()
|
assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue()
|
||||||
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
|
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
|
||||||
|
|
@ -201,6 +285,9 @@ class AdvancedSettingsPresenterTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
// Skip until the initial data it loaded
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
with(awaitItem()) {
|
with(awaitItem()) {
|
||||||
assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Loading)
|
assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Loading)
|
||||||
assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Success(Unit))
|
assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Success(Unit))
|
||||||
|
|
@ -212,10 +299,12 @@ class AdvancedSettingsPresenterTest {
|
||||||
appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
|
appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||||
mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(),
|
mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(),
|
||||||
|
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||||
) = AdvancedSettingsPresenter(
|
) = AdvancedSettingsPresenter(
|
||||||
appPreferencesStore = appPreferencesStore,
|
appPreferencesStore = appPreferencesStore,
|
||||||
sessionPreferencesStore = sessionPreferencesStore,
|
sessionPreferencesStore = sessionPreferencesStore,
|
||||||
mediaPreviewConfigStateStore = mediaPreviewConfigStateStore,
|
mediaPreviewConfigStateStore = mediaPreviewConfigStateStore,
|
||||||
|
featureFlagService = featureFlagService,
|
||||||
sessionCoroutineScope = this,
|
sessionCoroutineScope = this,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ class AdvancedSettingsViewTest {
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
rule.setAdvancedSettingsView(
|
rule.setAdvancedSettingsView(
|
||||||
state = aAdvancedSettingsState(
|
state = aAdvancedSettingsState(
|
||||||
doesCompressMedia = true,
|
mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true),
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
analyticsService = analyticsService
|
analyticsService = analyticsService
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
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.mediaupload.test.FakeMediaPreProcessor
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||||
|
|
@ -78,6 +79,7 @@ class EditUserProfilePresenterTest {
|
||||||
matrixUser: MatrixUser = aMatrixUser(),
|
matrixUser: MatrixUser = aMatrixUser(),
|
||||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||||
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
||||||
|
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||||
): EditUserProfilePresenter {
|
): EditUserProfilePresenter {
|
||||||
return EditUserProfilePresenter(
|
return EditUserProfilePresenter(
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
|
|
@ -86,6 +88,7 @@ class EditUserProfilePresenterTest {
|
||||||
mediaPreProcessor = fakeMediaPreProcessor,
|
mediaPreProcessor = fakeMediaPreProcessor,
|
||||||
temporaryUriDeleter = temporaryUriDeleter,
|
temporaryUriDeleter = temporaryUriDeleter,
|
||||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
||||||
|
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.ui.room.avatarUrl
|
||||||
import io.element.android.libraries.matrix.ui.room.rawName
|
import io.element.android.libraries.matrix.ui.room.rawName
|
||||||
import io.element.android.libraries.matrix.ui.room.topic
|
import io.element.android.libraries.matrix.ui.room.topic
|
||||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
|
|
@ -49,6 +50,7 @@ class RoomDetailsEditPresenter @Inject constructor(
|
||||||
private val mediaPreProcessor: MediaPreProcessor,
|
private val mediaPreProcessor: MediaPreProcessor,
|
||||||
private val temporaryUriDeleter: TemporaryUriDeleter,
|
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||||
|
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||||
) : Presenter<RoomDetailsEditState> {
|
) : Presenter<RoomDetailsEditState> {
|
||||||
private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
|
private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
|
||||||
private var pendingPermissionRequest = false
|
private var pendingPermissionRequest = false
|
||||||
|
|
@ -223,7 +225,7 @@ class RoomDetailsEditPresenter @Inject constructor(
|
||||||
uri = avatarUri,
|
uri = avatarUri,
|
||||||
mimeType = MimeTypes.Jpeg,
|
mimeType = MimeTypes.Jpeg,
|
||||||
deleteOriginal = false,
|
deleteOriginal = false,
|
||||||
compressIfPossible = false,
|
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
|
||||||
).getOrThrow()
|
).getOrThrow()
|
||||||
room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
|
room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_RAW_NAME
|
||||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
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.mediaupload.test.FakeMediaPreProcessor
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||||
|
|
@ -77,6 +78,7 @@ class RoomDetailsEditPresenterTest {
|
||||||
room: JoinedRoom,
|
room: JoinedRoom,
|
||||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||||
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
||||||
|
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||||
): RoomDetailsEditPresenter {
|
): RoomDetailsEditPresenter {
|
||||||
return RoomDetailsEditPresenter(
|
return RoomDetailsEditPresenter(
|
||||||
room = room,
|
room = room,
|
||||||
|
|
@ -84,6 +86,7 @@ class RoomDetailsEditPresenterTest {
|
||||||
mediaPreProcessor = fakeMediaPreProcessor,
|
mediaPreProcessor = fakeMediaPreProcessor,
|
||||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
||||||
temporaryUriDeleter = temporaryUriDeleter,
|
temporaryUriDeleter = temporaryUriDeleter,
|
||||||
|
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,9 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
|
||||||
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
|
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -37,8 +37,8 @@ class SharePresenter @AssistedInject constructor(
|
||||||
private val shareIntentHandler: ShareIntentHandler,
|
private val shareIntentHandler: ShareIntentHandler,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val mediaPreProcessor: MediaPreProcessor,
|
private val mediaPreProcessor: MediaPreProcessor,
|
||||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
|
||||||
private val activeRoomsHolder: ActiveRoomsHolder,
|
private val activeRoomsHolder: ActiveRoomsHolder,
|
||||||
|
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||||
) : Presenter<ShareState> {
|
) : Presenter<ShareState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -88,13 +88,14 @@ class SharePresenter @AssistedInject constructor(
|
||||||
val mediaSender = MediaSender(
|
val mediaSender = MediaSender(
|
||||||
preProcessor = mediaPreProcessor,
|
preProcessor = mediaPreProcessor,
|
||||||
room = room,
|
room = room,
|
||||||
sessionPreferencesStore = sessionPreferencesStore,
|
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||||
)
|
)
|
||||||
filesToShare
|
filesToShare
|
||||||
.map { fileToShare ->
|
.map { fileToShare ->
|
||||||
val result = mediaSender.sendMedia(
|
val result = mediaSender.sendMedia(
|
||||||
uri = fileToShare.uri,
|
uri = fileToShare.uri,
|
||||||
mimeType = fileToShare.mimeType,
|
mimeType = fileToShare.mimeType,
|
||||||
|
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
|
||||||
)
|
)
|
||||||
// If the coroutine was cancelled, destroy the room and rethrow the exception
|
// If the coroutine was cancelled, destroy the room and rethrow the exception
|
||||||
val cancellationException = result.exceptionOrNull() as? CancellationException
|
val cancellationException = result.exceptionOrNull() as? CancellationException
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
|
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
|
||||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
|
||||||
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
|
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
|
@ -166,6 +166,7 @@ class SharePresenterTest {
|
||||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||||
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
|
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
|
||||||
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
|
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
|
||||||
|
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||||
): SharePresenter {
|
): SharePresenter {
|
||||||
return SharePresenter(
|
return SharePresenter(
|
||||||
intent = intent,
|
intent = intent,
|
||||||
|
|
@ -173,8 +174,8 @@ class SharePresenterTest {
|
||||||
shareIntentHandler = shareIntentHandler,
|
shareIntentHandler = shareIntentHandler,
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
mediaPreProcessor = mediaPreProcessor,
|
mediaPreProcessor = mediaPreProcessor,
|
||||||
sessionPreferencesStore = InMemorySessionPreferencesStore(),
|
|
||||||
activeRoomsHolder = activeRoomsHolder,
|
activeRoomsHolder = activeRoomsHolder,
|
||||||
|
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.androidutils.media
|
||||||
|
|
||||||
|
import android.util.Size
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper class to calculate the resulting output size and optimal bitrate for video compression.
|
||||||
|
*/
|
||||||
|
class VideoCompressorHelper(
|
||||||
|
/**
|
||||||
|
* The maximum size (in pixels) for the output video.
|
||||||
|
* The output will maintain the aspect ratio of the input video.
|
||||||
|
*/
|
||||||
|
val maxSize: Int,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Calculates the output size for video compression based on the input size and [maxSize].
|
||||||
|
*/
|
||||||
|
fun getOutputSize(inputSize: Size): Size {
|
||||||
|
val resultMajor = min(inputSize.major(), maxSize)
|
||||||
|
val aspectRatio = inputSize.major().toFloat() / inputSize.minor().toFloat()
|
||||||
|
return Size(resultMajor, (resultMajor / aspectRatio).roundToInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the optimal bitrate for video compression based on the input size and frame rate.
|
||||||
|
*/
|
||||||
|
fun calculateOptimalBitrate(inputSize: Size, frameRate: Int): Long {
|
||||||
|
val outputSize = getOutputSize(inputSize)
|
||||||
|
val pixelsPerFrame = outputSize.width * outputSize.height
|
||||||
|
// Apparently, 0.1 bits per pixel is a sweet spot for video compression
|
||||||
|
val bitsPerPixel = 0.1f
|
||||||
|
return (pixelsPerFrame * bitsPerPixel * frameRate).toLong() / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Size.major(): Int = if (width > height) width else height
|
||||||
|
internal fun Size.minor(): Int = if (width < height) width else height
|
||||||
|
|
@ -184,4 +184,12 @@ enum class FeatureFlags(
|
||||||
// False so it's displayed in the developer options screen
|
// False so it's displayed in the developer options screen
|
||||||
isFinished = false,
|
isFinished = false,
|
||||||
),
|
),
|
||||||
|
SelectableMediaQuality(
|
||||||
|
key = "feature.selectable_media_quality",
|
||||||
|
title = "Select media quality per upload",
|
||||||
|
description = "You can select the media quality for each attachment you upload.",
|
||||||
|
defaultValue = { false },
|
||||||
|
// False so it's displayed in the developer options screen
|
||||||
|
isFinished = false,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,11 @@ interface MatrixClient {
|
||||||
* Return true if Livekit Rtc is supported, i.e. if Element Call is available.
|
* Return true if Livekit Rtc is supported, i.e. if Element Call is available.
|
||||||
*/
|
*/
|
||||||
suspend fun isLivekitRtcSupported(): Boolean
|
suspend fun isLivekitRtcSupported(): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the maximum file upload size allowed by the Matrix server.
|
||||||
|
*/
|
||||||
|
suspend fun getMaxFileUploadSize(): Result<Long>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -692,6 +692,10 @@ class RustMatrixClient(
|
||||||
innerClient.isLivekitRtcSupported()
|
innerClient.isLivekitRtcSupported()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getMaxFileUploadSize(): Result<Long> = withContext(sessionDispatcher) {
|
||||||
|
runCatchingExceptions { innerClient.getMaxMediaUploadSize().toLong() }
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun File.getCacheSize(
|
private suspend fun File.getCacheSize(
|
||||||
includeCryptoDb: Boolean = false,
|
includeCryptoDb: Boolean = false,
|
||||||
): Long = withContext(sessionDispatcher) {
|
): Long = withContext(sessionDispatcher) {
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ class FakeMatrixClient(
|
||||||
private val canReportRoomLambda: () -> Boolean = { false },
|
private val canReportRoomLambda: () -> Boolean = { false },
|
||||||
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
|
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
|
||||||
override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
|
override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
|
||||||
|
private val getMaxUploadSizeResult: () -> Result<Long> = { lambdaError() },
|
||||||
) : MatrixClient {
|
) : MatrixClient {
|
||||||
var setDisplayNameCalled: Boolean = false
|
var setDisplayNameCalled: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
@ -343,4 +344,8 @@ class FakeMatrixClient(
|
||||||
override suspend fun isLivekitRtcSupported(): Boolean {
|
override suspend fun isLivekitRtcSupported(): Boolean {
|
||||||
return isLivekitRtcSupportedLambda()
|
return isLivekitRtcSupportedLambda()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getMaxFileUploadSize(): Result<Long> {
|
||||||
|
return getMaxUploadSizeResult()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaupload.api
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the maximum upload size allowed by the Matrix server.
|
||||||
|
*/
|
||||||
|
class MaxUploadSizeProvider @Inject constructor(
|
||||||
|
private val matrixClient: MatrixClient,
|
||||||
|
) {
|
||||||
|
suspend fun getMaxUploadSize(): Result<Long> {
|
||||||
|
return matrixClient.getMaxFileUploadSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaupload.api
|
||||||
|
|
||||||
|
import io.element.android.libraries.androidutils.media.VideoCompressorHelper
|
||||||
|
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||||
|
|
||||||
|
data class MediaOptimizationConfig(
|
||||||
|
val compressImages: Boolean,
|
||||||
|
val videoCompressionPreset: VideoCompressionPreset,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun VideoCompressionPreset.compressorHelper(): VideoCompressorHelper = when (this) {
|
||||||
|
VideoCompressionPreset.STANDARD -> VideoCompressorHelper(1280)
|
||||||
|
VideoCompressionPreset.HIGH -> VideoCompressorHelper(1920)
|
||||||
|
VideoCompressionPreset.LOW -> VideoCompressorHelper(640)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaupload.api
|
||||||
|
|
||||||
|
fun interface MediaOptimizationConfigProvider {
|
||||||
|
suspend fun get(): MediaOptimizationConfig
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,7 @@ interface MediaPreProcessor {
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
deleteOriginal: Boolean,
|
deleteOriginal: Boolean,
|
||||||
compressIfPossible: Boolean,
|
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||||
): Result<MediaUploadInfo>
|
): Result<MediaUploadInfo>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,15 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||||
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
|
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class MediaSender @Inject constructor(
|
class MediaSender @Inject constructor(
|
||||||
private val preProcessor: MediaPreProcessor,
|
private val preProcessor: MediaPreProcessor,
|
||||||
private val room: JoinedRoom,
|
private val room: JoinedRoom,
|
||||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||||
) {
|
) {
|
||||||
private val ongoingUploadJobs = ConcurrentHashMap<Job.Key, MediaUploadHandler>()
|
private val ongoingUploadJobs = ConcurrentHashMap<Job.Key, MediaUploadHandler>()
|
||||||
val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty()
|
val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty()
|
||||||
|
|
@ -32,14 +30,14 @@ class MediaSender @Inject constructor(
|
||||||
suspend fun preProcessMedia(
|
suspend fun preProcessMedia(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
|
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||||
): Result<MediaUploadInfo> {
|
): Result<MediaUploadInfo> {
|
||||||
val compressIfPossible = sessionPreferencesStore.doesCompressMedia().first()
|
|
||||||
return preProcessor
|
return preProcessor
|
||||||
.process(
|
.process(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
deleteOriginal = false,
|
deleteOriginal = false,
|
||||||
compressIfPossible = compressIfPossible,
|
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,14 +65,14 @@ class MediaSender @Inject constructor(
|
||||||
formattedCaption: String? = null,
|
formattedCaption: String? = null,
|
||||||
progressCallback: ProgressCallback? = null,
|
progressCallback: ProgressCallback? = null,
|
||||||
inReplyToEventId: EventId? = null,
|
inReplyToEventId: EventId? = null,
|
||||||
|
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||||
): Result<Unit> {
|
): Result<Unit> {
|
||||||
val compressIfPossible = sessionPreferencesStore.doesCompressMedia().first()
|
|
||||||
return preProcessor
|
return preProcessor
|
||||||
.process(
|
.process(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
deleteOriginal = false,
|
deleteOriginal = false,
|
||||||
compressIfPossible = compressIfPossible,
|
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||||
)
|
)
|
||||||
.flatMapCatching { info ->
|
.flatMapCatching { info ->
|
||||||
room.liveTimeline.sendMedia(
|
room.liveTimeline.sendMedia(
|
||||||
|
|
@ -100,7 +98,7 @@ class MediaSender @Inject constructor(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
deleteOriginal = true,
|
deleteOriginal = true,
|
||||||
compressIfPossible = false,
|
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
|
||||||
)
|
)
|
||||||
.flatMapCatching { info ->
|
.flatMapCatching { info ->
|
||||||
val audioInfo = (info as MediaUploadInfo.Audio).audioInfo
|
val audioInfo = (info as MediaUploadInfo.Audio).audioInfo
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,7 @@ import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
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.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -34,6 +33,11 @@ import java.io.File
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class MediaSenderTest {
|
class MediaSenderTest {
|
||||||
|
private val mediaOptimizationConfig = MediaOptimizationConfig(
|
||||||
|
compressImages = true,
|
||||||
|
videoCompressionPreset = VideoCompressionPreset.STANDARD,
|
||||||
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `given an attachment when sending it the preprocessor always runs`() = runTest {
|
fun `given an attachment when sending it the preprocessor always runs`() = runTest {
|
||||||
val preProcessor = FakeMediaPreProcessor()
|
val preProcessor = FakeMediaPreProcessor()
|
||||||
|
|
@ -57,7 +61,7 @@ class MediaSenderTest {
|
||||||
)
|
)
|
||||||
|
|
||||||
val uri = Uri.parse("content://image.jpg")
|
val uri = Uri.parse("content://image.jpg")
|
||||||
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
|
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
|
||||||
|
|
||||||
assertThat(preProcessor.processCallCount).isEqualTo(1)
|
assertThat(preProcessor.processCallCount).isEqualTo(1)
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +80,7 @@ class MediaSenderTest {
|
||||||
val sender = createMediaSender(room = room)
|
val sender = createMediaSender(room = room)
|
||||||
|
|
||||||
val uri = Uri.parse("content://image.jpg")
|
val uri = Uri.parse("content://image.jpg")
|
||||||
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
|
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -87,7 +91,7 @@ class MediaSenderTest {
|
||||||
val sender = createMediaSender(preProcessor)
|
val sender = createMediaSender(preProcessor)
|
||||||
|
|
||||||
val uri = Uri.parse("content://image.jpg")
|
val uri = Uri.parse("content://image.jpg")
|
||||||
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
|
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
|
||||||
|
|
||||||
assertThat(result.exceptionOrNull()).isNotNull()
|
assertThat(result.exceptionOrNull()).isNotNull()
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +116,7 @@ class MediaSenderTest {
|
||||||
)
|
)
|
||||||
|
|
||||||
val uri = Uri.parse("content://image.jpg")
|
val uri = Uri.parse("content://image.jpg")
|
||||||
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
|
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
|
||||||
|
|
||||||
assertThat(result.exceptionOrNull()).isNotNull()
|
assertThat(result.exceptionOrNull()).isNotNull()
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +136,7 @@ class MediaSenderTest {
|
||||||
val sender = createMediaSender(room = room)
|
val sender = createMediaSender(room = room)
|
||||||
val sendJob = launch {
|
val sendJob = launch {
|
||||||
val uri = Uri.parse("content://image.jpg")
|
val uri = Uri.parse("content://image.jpg")
|
||||||
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg)
|
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
|
||||||
}
|
}
|
||||||
// Wait until several internal tasks run and the file is being uploaded
|
// Wait until several internal tasks run and the file is being uploaded
|
||||||
advanceTimeBy(3L)
|
advanceTimeBy(3L)
|
||||||
|
|
@ -154,10 +158,10 @@ class MediaSenderTest {
|
||||||
private fun createMediaSender(
|
private fun createMediaSender(
|
||||||
preProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
|
preProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
|
||||||
room: JoinedRoom = FakeJoinedRoom(),
|
room: JoinedRoom = FakeJoinedRoom(),
|
||||||
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
|
mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = MediaOptimizationConfigProvider { mediaOptimizationConfig },
|
||||||
) = MediaSender(
|
) = MediaSender(
|
||||||
preProcessor = preProcessor,
|
preProcessor = preProcessor,
|
||||||
room = room,
|
room = room,
|
||||||
sessionPreferencesStore = sessionPreferencesStore,
|
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,10 @@ import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||||
|
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||||
import kotlinx.coroutines.flow.filterIsInstance
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
|
@ -76,7 +78,7 @@ class AndroidMediaPreProcessor @Inject constructor(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
deleteOriginal: Boolean,
|
deleteOriginal: Boolean,
|
||||||
compressIfPossible: Boolean,
|
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||||
): Result<MediaUploadInfo> = withContext(coroutineDispatchers.computation) {
|
): Result<MediaUploadInfo> = withContext(coroutineDispatchers.computation) {
|
||||||
runCatchingExceptions {
|
runCatchingExceptions {
|
||||||
val result = when {
|
val result = when {
|
||||||
|
|
@ -85,10 +87,10 @@ class AndroidMediaPreProcessor @Inject constructor(
|
||||||
processFile(uri, mimeType)
|
processFile(uri, mimeType)
|
||||||
}
|
}
|
||||||
mimeType.isMimeTypeImage() -> {
|
mimeType.isMimeTypeImage() -> {
|
||||||
val shouldBeCompressed = compressIfPossible && mimeType !in notCompressibleImageTypes
|
val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes
|
||||||
processImage(uri, mimeType, shouldBeCompressed)
|
processImage(uri, mimeType, shouldBeCompressed)
|
||||||
}
|
}
|
||||||
mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, compressIfPossible)
|
mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, mediaOptimizationConfig.videoCompressionPreset)
|
||||||
mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType)
|
mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType)
|
||||||
else -> processFile(uri, mimeType)
|
else -> processFile(uri, mimeType)
|
||||||
}
|
}
|
||||||
|
|
@ -214,9 +216,9 @@ class AndroidMediaPreProcessor @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun processVideo(uri: Uri, mimeType: String?, shouldBeCompressed: Boolean): MediaUploadInfo {
|
private suspend fun processVideo(uri: Uri, mimeType: String?, videoCompressionPreset: VideoCompressionPreset): MediaUploadInfo {
|
||||||
val resultFile = runCatchingExceptions {
|
val resultFile = runCatchingExceptions {
|
||||||
videoCompressor.compress(uri, shouldBeCompressed)
|
videoCompressor.compress(uri, videoCompressionPreset)
|
||||||
.onEach {
|
.onEach {
|
||||||
if (it is VideoTranscodingEvent.Progress) {
|
if (it is VideoTranscodingEvent.Progress) {
|
||||||
Timber.d("Video compression progress: ${it.value}%")
|
Timber.d("Video compression progress: ${it.value}%")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaupload.impl
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
|
||||||
|
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(SessionScope::class)
|
||||||
|
class DefaultMediaOptimizationConfigProvider @Inject constructor(
|
||||||
|
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||||
|
) : MediaOptimizationConfigProvider {
|
||||||
|
override suspend fun get(): MediaOptimizationConfig = MediaOptimizationConfig(
|
||||||
|
compressImages = sessionPreferencesStore.doesOptimizeImages().first(),
|
||||||
|
videoCompressionPreset = sessionPreferencesStore.getVideoCompressionPreset().first(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,10 @@ import android.content.Context
|
||||||
import android.media.MediaCodecInfo
|
import android.media.MediaCodecInfo
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.util.Size
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MimeTypes
|
import androidx.media3.common.MimeTypes
|
||||||
import androidx.media3.common.util.Size
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.effect.Presentation
|
import androidx.media3.effect.Presentation
|
||||||
import androidx.media3.transformer.Composition
|
import androidx.media3.transformer.Composition
|
||||||
|
|
@ -31,6 +31,7 @@ import io.element.android.libraries.androidutils.file.createTmpFile
|
||||||
import io.element.android.libraries.androidutils.file.safeDelete
|
import io.element.android.libraries.androidutils.file.safeDelete
|
||||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -47,12 +48,12 @@ class VideoCompressor @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
fun compress(uri: Uri, shouldBeCompressed: Boolean): Flow<VideoTranscodingEvent> = callbackFlow {
|
fun compress(uri: Uri, videoCompressionPreset: VideoCompressionPreset): Flow<VideoTranscodingEvent> = callbackFlow {
|
||||||
val metadata = getVideoMetadata(uri)
|
val metadata = getVideoMetadata(uri)
|
||||||
|
|
||||||
val videoCompressorConfig = VideoCompressorConfigFactory.create(
|
val videoCompressorConfig = VideoCompressorConfigFactory.create(
|
||||||
metadata = metadata,
|
metadata = metadata,
|
||||||
shouldBeCompressed = shouldBeCompressed
|
preset = videoCompressionPreset,
|
||||||
)
|
)
|
||||||
|
|
||||||
val tmpFile = context.createTmpFile(extension = "mp4")
|
val tmpFile = context.createTmpFile(extension = "mp4")
|
||||||
|
|
@ -60,7 +61,7 @@ class VideoCompressor @Inject constructor(
|
||||||
val width = metadata?.width ?: Int.MAX_VALUE
|
val width = metadata?.width ?: Int.MAX_VALUE
|
||||||
val height = metadata?.height ?: Int.MAX_VALUE
|
val height = metadata?.height ?: Int.MAX_VALUE
|
||||||
|
|
||||||
val videoResizeEffect = videoCompressorConfig.resizer?.let {
|
val videoResizeEffect = videoCompressorConfig.videoCompressorHelper?.let {
|
||||||
val outputSize = it.getOutputSize(Size(width, height))
|
val outputSize = it.getOutputSize(Size(width, height))
|
||||||
if (metadata?.rotation == 90 || metadata?.rotation == 270) {
|
if (metadata?.rotation == 90 || metadata?.rotation == 270) {
|
||||||
// If the video is rotated, we need to swap width and height
|
// If the video is rotated, we need to swap width and height
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue