Image edition before sending: crop and rotate. (#6842)
* Add crop and rotate editing before sending images (#6363) * feat(messages): add crop and rotate before image upload * Update screenshots * chore: trigger CI after screenshot update * fix: resolve detekt violations in image editor and media viewer modules * fix: require explicit edits param, use plurals for rotation a11y, remove redundant @Inject * fix: require explicit edits param, use plurals for rotation a11y, remove redundant @Inject * fix: use semantically correct RotateRight icon for image rotation action * Update screenshots * chore: trigger CI after screenshot update --------- Co-authored-by: ElementBot <android@element.io> Co-authored-by: Benoit Marty <benoitm@element.io> * Update design and UX Update and add tests Improve preview * Update screenshots * For quality issue and improve preview * Update screenshots * Remove default value of data class. * Rename file. * Fix tests. * Allow detecting touch events outside the image by applying the drag detection to the parent node and offsetting the touch events * Improve touch detection. * Update screenshots * Remove useless line. --------- Co-authored-by: Gianluca Iavicoli <gianluca.iavicoli04@gmail.com> Co-authored-by: ElementBot <android@element.io> Co-authored-by: Jorge Martín <jorgem@element.io>
This commit is contained in:
commit
6fb79d6e06
35 changed files with 2030 additions and 90 deletions
|
|
@ -70,6 +70,7 @@ dependencies {
|
||||||
implementation(libs.jsoup)
|
implementation(libs.jsoup)
|
||||||
implementation(libs.androidx.constraintlayout)
|
implementation(libs.androidx.constraintlayout)
|
||||||
implementation(libs.androidx.constraintlayout.compose)
|
implementation(libs.androidx.constraintlayout.compose)
|
||||||
|
implementation(libs.androidx.exifinterface)
|
||||||
implementation(libs.androidx.media3.exoplayer)
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
implementation(libs.androidx.media3.ui)
|
implementation(libs.androidx.media3.ui)
|
||||||
implementation(libs.sigpwned.emoji4j)
|
implementation(libs.sigpwned.emoji4j)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.preview
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
||||||
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
internal fun MediaInfo.canEditImage(): Boolean {
|
||||||
|
val resolvedMimeType = resolvedImageMimeType() ?: return false
|
||||||
|
return resolvedMimeType.isMimeTypeImage() &&
|
||||||
|
!resolvedMimeType.isMimeTypeAnimatedImage() &&
|
||||||
|
resolvedMimeType != MimeTypes.Svg
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun MediaInfo.isImageAttachment(): Boolean {
|
||||||
|
return resolvedImageMimeType().isMimeTypeImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun MediaInfo.resolvedImageMimeType(): String? {
|
||||||
|
return mimeType.takeIf { it.isMimeTypeImage() } ?: fileExtension.toImageMimeTypeOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toImageMimeTypeOrNull(): String? {
|
||||||
|
return when (lowercase(Locale.ROOT)) {
|
||||||
|
"png" -> MimeTypes.Png
|
||||||
|
"jpg", "jpeg" -> MimeTypes.Jpeg
|
||||||
|
"gif" -> MimeTypes.Gif
|
||||||
|
"webp" -> MimeTypes.WebP
|
||||||
|
"svg" -> MimeTypes.Svg
|
||||||
|
"bmp" -> "image/bmp"
|
||||||
|
"heic" -> "image/heic"
|
||||||
|
"heif" -> "image/heif"
|
||||||
|
"avif" -> "image/avif"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,17 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.attachments.preview
|
package io.element.android.features.messages.impl.attachments.preview
|
||||||
|
|
||||||
|
import io.element.android.features.messages.impl.attachments.preview.imageeditor.NormalizedCropRect
|
||||||
|
|
||||||
sealed interface AttachmentsPreviewEvent {
|
sealed interface AttachmentsPreviewEvent {
|
||||||
data object SendAttachment : AttachmentsPreviewEvent
|
data object SendAttachment : AttachmentsPreviewEvent
|
||||||
data object CancelAndDismiss : AttachmentsPreviewEvent
|
data object CancelAndDismiss : AttachmentsPreviewEvent
|
||||||
data object CancelAndClearSendState : AttachmentsPreviewEvent
|
data object CancelAndClearSendState : AttachmentsPreviewEvent
|
||||||
|
data object OpenImageEditor : AttachmentsPreviewEvent
|
||||||
|
data object CloseImageEditor : AttachmentsPreviewEvent
|
||||||
|
data object RotateImageToTheLeft : AttachmentsPreviewEvent
|
||||||
|
data object ApplyImageEdits : AttachmentsPreviewEvent
|
||||||
|
data object ResetImageEdits : AttachmentsPreviewEvent
|
||||||
|
data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent
|
||||||
|
data object ClearImageEditError : AttachmentsPreviewEvent
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ import dev.zacsweers.metro.Assisted
|
||||||
import dev.zacsweers.metro.AssistedFactory
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.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.preview.imageeditor.AttachmentImageEditor
|
||||||
|
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorState
|
||||||
|
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEdits
|
||||||
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter
|
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter
|
||||||
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
|
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
|
||||||
import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector
|
import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector
|
||||||
|
|
@ -32,7 +35,6 @@ 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.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.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
|
@ -51,7 +53,9 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class AttachmentsPreviewPresenter(
|
class AttachmentsPreviewPresenter(
|
||||||
|
|
@ -62,6 +66,7 @@ class AttachmentsPreviewPresenter(
|
||||||
mediaSenderFactory: MediaSenderFactory,
|
mediaSenderFactory: MediaSenderFactory,
|
||||||
private val permalinkBuilder: PermalinkBuilder,
|
private val permalinkBuilder: PermalinkBuilder,
|
||||||
private val temporaryUriDeleter: TemporaryUriDeleter,
|
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||||
|
private val attachmentImageEditor: AttachmentImageEditor,
|
||||||
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
|
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
|
||||||
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
|
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
|
||||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||||
|
|
@ -87,6 +92,14 @@ class AttachmentsPreviewPresenter(
|
||||||
val sendActionState = remember {
|
val sendActionState = remember {
|
||||||
mutableStateOf<SendActionState>(SendActionState.Idle)
|
mutableStateOf<SendActionState>(SendActionState.Idle)
|
||||||
}
|
}
|
||||||
|
val originalLocalMedia = remember { (attachment as Attachment.Media).localMedia }
|
||||||
|
var currentAttachment by remember { mutableStateOf(attachment) }
|
||||||
|
var canEditImage by remember { mutableStateOf(originalLocalMedia.info.canEditImage()) }
|
||||||
|
var imageEditorState by remember { mutableStateOf<AttachmentImageEditorState?>(null) }
|
||||||
|
var appliedImageEdits by remember { mutableStateOf(AttachmentImageEdits()) }
|
||||||
|
var isApplyingImageEdits by remember { mutableStateOf(false) }
|
||||||
|
var displayImageEditError by remember { mutableStateOf(false) }
|
||||||
|
var editedTempFile by remember { mutableStateOf<File?>(null) }
|
||||||
|
|
||||||
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
|
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
|
||||||
val textEditorState by rememberUpdatedState(
|
val textEditorState by rememberUpdatedState(
|
||||||
|
|
@ -97,7 +110,7 @@ class AttachmentsPreviewPresenter(
|
||||||
|
|
||||||
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
|
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
val mediaAttachment = attachment as Attachment.Media
|
val mediaAttachment = currentAttachment as Attachment.Media
|
||||||
val mediaOptimizationSelectorPresenter = remember {
|
val mediaOptimizationSelectorPresenter = remember {
|
||||||
mediaOptimizationSelectorPresenterFactory.create(
|
mediaOptimizationSelectorPresenterFactory.create(
|
||||||
localMedia = mediaAttachment.localMedia,
|
localMedia = mediaAttachment.localMedia,
|
||||||
|
|
@ -113,11 +126,18 @@ class AttachmentsPreviewPresenter(
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
mediaOptimizationSelectorState.displayMediaSelectorViews,
|
mediaOptimizationSelectorState.displayMediaSelectorViews,
|
||||||
mediaOptimizationSelectorState.videoSizeEstimations,
|
mediaOptimizationSelectorState.videoSizeEstimations,
|
||||||
|
currentAttachment,
|
||||||
|
imageEditorState,
|
||||||
|
isApplyingImageEdits,
|
||||||
) {
|
) {
|
||||||
// If the media optimization selector is not displayed, we can pre-process the media
|
// 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
|
// to prepare it for sending. This is done to avoid blocking the UI thread when the
|
||||||
// user clicks on the send button.
|
// user clicks on the send button.
|
||||||
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false && preprocessMediaJob == null) {
|
@Suppress("ComplexCondition")
|
||||||
|
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false &&
|
||||||
|
preprocessMediaJob == null &&
|
||||||
|
imageEditorState == null &&
|
||||||
|
!isApplyingImageEdits) {
|
||||||
if (mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() && mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() == null) {
|
if (mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() && mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() == null) {
|
||||||
Timber.d("Waiting for video size estimations to be able to select the best video compression preset before pre-processing the media")
|
Timber.d("Waiting for video size estimations to be able to select the best video compression preset before pre-processing the media")
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
|
|
@ -127,7 +147,7 @@ class AttachmentsPreviewPresenter(
|
||||||
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
|
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
|
||||||
) ?: return@LaunchedEffect
|
) ?: return@LaunchedEffect
|
||||||
preprocessMediaJob = coroutineScope.preProcessAttachment(
|
preprocessMediaJob = coroutineScope.preProcessAttachment(
|
||||||
attachment = attachment,
|
attachment = currentAttachment,
|
||||||
mediaOptimizationConfig = config,
|
mediaOptimizationConfig = config,
|
||||||
displayProgress = false,
|
displayProgress = false,
|
||||||
sendActionState = sendActionState,
|
sendActionState = sendActionState,
|
||||||
|
|
@ -135,10 +155,14 @@ class AttachmentsPreviewPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(originalLocalMedia) {
|
||||||
|
canEditImage = originalLocalMedia.info.canEditImage() || attachmentImageEditor.canEdit(originalLocalMedia)
|
||||||
|
}
|
||||||
|
|
||||||
val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull()
|
val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull()
|
||||||
LaunchedEffect(maxUploadSize) {
|
LaunchedEffect(maxUploadSize) {
|
||||||
// Check file upload size if the media won't be processed for upload
|
// Check file upload size if the media won't be processed for upload
|
||||||
val isImageFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeImage()
|
val isImageFile = mediaAttachment.localMedia.info.isImageAttachment()
|
||||||
val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo()
|
val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo()
|
||||||
if (maxUploadSize != null && !(isImageFile || isVideoFile)) {
|
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.
|
// If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed.
|
||||||
|
|
@ -169,7 +193,7 @@ class AttachmentsPreviewPresenter(
|
||||||
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
|
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
|
||||||
)
|
)
|
||||||
preprocessMediaJob = preProcessAttachment(
|
preprocessMediaJob = preProcessAttachment(
|
||||||
attachment = attachment,
|
attachment = currentAttachment,
|
||||||
mediaOptimizationConfig = config,
|
mediaOptimizationConfig = config,
|
||||||
displayProgress = true,
|
displayProgress = true,
|
||||||
sendActionState = sendActionState,
|
sendActionState = sendActionState,
|
||||||
|
|
@ -188,6 +212,9 @@ class AttachmentsPreviewPresenter(
|
||||||
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
|
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
|
||||||
.takeIf { it.isNotEmpty() }
|
.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
val editedTempFileToDelete = editedTempFile
|
||||||
|
editedTempFile = null
|
||||||
|
|
||||||
// If we're supposed to send the media as a background job, we can dismiss this screen already
|
// If we're supposed to send the media as a background job, we can dismiss this screen already
|
||||||
if (coroutineContext.isActive) {
|
if (coroutineContext.isActive) {
|
||||||
onDoneListener()
|
onDoneListener()
|
||||||
|
|
@ -195,33 +222,36 @@ class AttachmentsPreviewPresenter(
|
||||||
|
|
||||||
// Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed
|
// Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed
|
||||||
sessionCoroutineScope.launch(dispatchers.io) {
|
sessionCoroutineScope.launch(dispatchers.io) {
|
||||||
sendPreProcessedMedia(
|
try {
|
||||||
mediaUploadInfo = mediaUploadInfo,
|
sendPreProcessedMedia(
|
||||||
caption = caption,
|
mediaUploadInfo = mediaUploadInfo,
|
||||||
sendActionState = sendActionState,
|
caption = caption,
|
||||||
dismissAfterSend = false,
|
sendActionState = sendActionState,
|
||||||
inReplyToEventId = inReplyToEventId,
|
dismissAfterSend = false,
|
||||||
)
|
inReplyToEventId = inReplyToEventId,
|
||||||
|
)
|
||||||
// Clean up the pre-processed media after it's been sent
|
} finally {
|
||||||
mediaSender.cleanUp()
|
editedTempFileToDelete?.safeDelete()
|
||||||
|
// Clean up the pre-processed media after it's been sent
|
||||||
|
mediaSender.cleanUp()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AttachmentsPreviewEvent.CancelAndDismiss -> {
|
AttachmentsPreviewEvent.CancelAndDismiss -> {
|
||||||
displayFileTooLargeError = false
|
displayFileTooLargeError = false
|
||||||
|
displayImageEditError = false
|
||||||
|
isApplyingImageEdits = false
|
||||||
|
|
||||||
// Cancel media preprocessing and sending
|
// Cancel media preprocessing and sending
|
||||||
preprocessMediaJob?.cancel()
|
preprocessMediaJob?.cancel()
|
||||||
|
preprocessMediaJob = null
|
||||||
// If we couldn't send the pre-processed media, remove it
|
// If we couldn't send the pre-processed media, remove it
|
||||||
mediaSender.cleanUp()
|
mediaSender.cleanUp()
|
||||||
ongoingSendAttachmentJob.value?.cancel()
|
ongoingSendAttachmentJob.value?.cancel()
|
||||||
|
|
||||||
// Dismiss the screen
|
// Dismiss the screen
|
||||||
dismiss(
|
dismiss(sendActionState, editedTempFile)
|
||||||
attachment,
|
|
||||||
sendActionState,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
AttachmentsPreviewEvent.CancelAndClearSendState -> {
|
AttachmentsPreviewEvent.CancelAndClearSendState -> {
|
||||||
// Cancel media sending
|
// Cancel media sending
|
||||||
|
|
@ -237,11 +267,88 @@ class AttachmentsPreviewPresenter(
|
||||||
SendActionState.Idle
|
SendActionState.Idle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AttachmentsPreviewEvent.OpenImageEditor -> {
|
||||||
|
val resolvedCanEditImage = canEditImage || originalLocalMedia.info.canEditImage()
|
||||||
|
if (resolvedCanEditImage) {
|
||||||
|
preprocessMediaJob?.cancel()
|
||||||
|
preprocessMediaJob = null
|
||||||
|
resetPreparedMedia(sendActionState)
|
||||||
|
imageEditorState = AttachmentImageEditorState(
|
||||||
|
localMedia = originalLocalMedia,
|
||||||
|
edits = appliedImageEdits,
|
||||||
|
previewDebug = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AttachmentsPreviewEvent.CloseImageEditor -> {
|
||||||
|
imageEditorState = null
|
||||||
|
}
|
||||||
|
is AttachmentsPreviewEvent.UpdateImageCropRect -> {
|
||||||
|
val pendingState = imageEditorState ?: return
|
||||||
|
imageEditorState = pendingState.copy(
|
||||||
|
edits = pendingState.edits.copy(cropRect = event.cropRect)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AttachmentsPreviewEvent.RotateImageToTheLeft -> {
|
||||||
|
val pendingState = imageEditorState ?: return
|
||||||
|
imageEditorState = pendingState.copy(
|
||||||
|
edits = pendingState.edits.rotateAntiClockwise()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AttachmentsPreviewEvent.ResetImageEdits -> {
|
||||||
|
imageEditorState = imageEditorState?.copy(
|
||||||
|
edits = AttachmentImageEdits()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AttachmentsPreviewEvent.ApplyImageEdits -> {
|
||||||
|
val pendingState = imageEditorState ?: return
|
||||||
|
if (!pendingState.edits.hasChanges) {
|
||||||
|
editedTempFile?.safeDelete()
|
||||||
|
editedTempFile = null
|
||||||
|
appliedImageEdits = pendingState.edits
|
||||||
|
currentAttachment = Attachment.Media(originalLocalMedia)
|
||||||
|
imageEditorState = null
|
||||||
|
resetPreparedMedia(sendActionState)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isApplyingImageEdits = true
|
||||||
|
displayImageEditError = false
|
||||||
|
coroutineScope.launch {
|
||||||
|
val result = withContext(dispatchers.io) {
|
||||||
|
attachmentImageEditor.exportEdits(
|
||||||
|
localMedia = originalLocalMedia,
|
||||||
|
edits = pendingState.edits,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { editedMedia ->
|
||||||
|
editedTempFile?.safeDelete()
|
||||||
|
editedTempFile = editedMedia.file
|
||||||
|
appliedImageEdits = pendingState.edits
|
||||||
|
currentAttachment = Attachment.Media(editedMedia.localMedia)
|
||||||
|
imageEditorState = null
|
||||||
|
resetPreparedMedia(sendActionState)
|
||||||
|
},
|
||||||
|
onFailure = {
|
||||||
|
Timber.e(it, "Failed to apply image edits")
|
||||||
|
displayImageEditError = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
isApplyingImageEdits = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AttachmentsPreviewEvent.ClearImageEditError -> {
|
||||||
|
displayImageEditError = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return AttachmentsPreviewState(
|
return AttachmentsPreviewState(
|
||||||
attachment = attachment,
|
attachment = currentAttachment,
|
||||||
|
imageEditorState = imageEditorState,
|
||||||
|
canEditImage = canEditImage,
|
||||||
|
isApplyingImageEdits = isApplyingImageEdits,
|
||||||
|
displayImageEditError = displayImageEditError,
|
||||||
sendActionState = sendActionState.value,
|
sendActionState = sendActionState.value,
|
||||||
textEditorState = textEditorState,
|
textEditorState = textEditorState,
|
||||||
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
|
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
|
||||||
|
|
@ -318,8 +425,8 @@ class AttachmentsPreviewPresenter(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun dismiss(
|
private fun dismiss(
|
||||||
attachment: Attachment,
|
|
||||||
sendActionState: MutableState<SendActionState>,
|
sendActionState: MutableState<SendActionState>,
|
||||||
|
editedTempFile: File?,
|
||||||
) {
|
) {
|
||||||
// Delete the temporary file
|
// Delete the temporary file
|
||||||
when (attachment) {
|
when (attachment) {
|
||||||
|
|
@ -330,6 +437,7 @@ class AttachmentsPreviewPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
editedTempFile?.safeDelete()
|
||||||
// Reset the sendActionState to ensure that dialog is closed before the screen
|
// Reset the sendActionState to ensure that dialog is closed before the screen
|
||||||
sendActionState.value = SendActionState.Done
|
sendActionState.value = SendActionState.Done
|
||||||
onDoneListener()
|
onDoneListener()
|
||||||
|
|
@ -343,6 +451,12 @@ class AttachmentsPreviewPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resetPreparedMedia(sendActionState: MutableState<SendActionState>) {
|
||||||
|
sendActionState.value.mediaUploadInfo()?.let(::cleanUp)
|
||||||
|
mediaSender.cleanUp()
|
||||||
|
sendActionState.value = SendActionState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun sendPreProcessedMedia(
|
private suspend fun sendPreProcessedMedia(
|
||||||
mediaUploadInfo: MediaUploadInfo,
|
mediaUploadInfo: MediaUploadInfo,
|
||||||
caption: String?,
|
caption: String?,
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,17 @@ 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.preview.imageeditor.AttachmentImageEditorState
|
||||||
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
|
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
|
||||||
|
|
||||||
data class AttachmentsPreviewState(
|
data class AttachmentsPreviewState(
|
||||||
val attachment: Attachment,
|
val attachment: Attachment,
|
||||||
|
val imageEditorState: AttachmentImageEditorState?,
|
||||||
|
val canEditImage: Boolean,
|
||||||
|
val isApplyingImageEdits: Boolean,
|
||||||
|
val displayImageEditError: Boolean,
|
||||||
val sendActionState: SendActionState,
|
val sendActionState: SendActionState,
|
||||||
val textEditorState: TextEditorState,
|
val textEditorState: TextEditorState,
|
||||||
val mediaOptimizationSelectorState: MediaOptimizationSelectorState,
|
val mediaOptimizationSelectorState: MediaOptimizationSelectorState,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ 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.preview.imageeditor.AttachmentImageEditorState
|
||||||
|
import io.element.android.features.messages.impl.attachments.preview.imageeditor.anAttachmentImageEditorState
|
||||||
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
|
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.attachments.video.VideoUploadEstimation
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
|
@ -42,6 +44,9 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
|
||||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(aMediaUploadInfo())),
|
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(aMediaUploadInfo())),
|
||||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(aMediaUploadInfo())),
|
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(aMediaUploadInfo())),
|
||||||
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())),
|
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())),
|
||||||
|
anAttachmentsPreviewState(
|
||||||
|
imageEditorState = anAttachmentImageEditorState(),
|
||||||
|
),
|
||||||
anAttachmentsPreviewState(displayFileTooLargeError = true),
|
anAttachmentsPreviewState(displayFileTooLargeError = true),
|
||||||
anAttachmentsPreviewState(
|
anAttachmentsPreviewState(
|
||||||
mediaInfo = aVideoMediaInfo(),
|
mediaInfo = aVideoMediaInfo(),
|
||||||
|
|
@ -64,12 +69,17 @@ fun anAttachmentsPreviewState(
|
||||||
mediaInfo: MediaInfo = anImageMediaInfo(),
|
mediaInfo: MediaInfo = anImageMediaInfo(),
|
||||||
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
|
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
|
||||||
sendActionState: SendActionState = SendActionState.Idle,
|
sendActionState: SendActionState = SendActionState.Idle,
|
||||||
|
imageEditorState: AttachmentImageEditorState? = null,
|
||||||
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
|
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
|
||||||
displayFileTooLargeError: Boolean = false,
|
displayFileTooLargeError: Boolean = false,
|
||||||
) = AttachmentsPreviewState(
|
) = AttachmentsPreviewState(
|
||||||
attachment = Attachment.Media(
|
attachment = Attachment.Media(
|
||||||
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
|
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
|
||||||
),
|
),
|
||||||
|
imageEditorState = imageEditorState,
|
||||||
|
canEditImage = true,
|
||||||
|
isApplyingImageEdits = false,
|
||||||
|
displayImageEditError = false,
|
||||||
sendActionState = sendActionState,
|
sendActionState = sendActionState,
|
||||||
textEditorState = textEditorState,
|
textEditorState = textEditorState,
|
||||||
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
|
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
|
||||||
|
|
|
||||||
|
|
@ -31,19 +31,20 @@ 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.semantics.heading
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
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 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.features.messages.impl.R
|
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.preview.imageeditor.AttachmentImageEditorView
|
||||||
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorEvent
|
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.MediaOptimizationSelectorState
|
||||||
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
|
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
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.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
|
||||||
|
|
@ -60,6 +61,7 @@ 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.Switch
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
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
|
||||||
|
|
@ -74,6 +76,9 @@ import io.element.android.wysiwyg.display.TextDisplay
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3514
|
||||||
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AttachmentsPreviewView(
|
fun AttachmentsPreviewView(
|
||||||
|
|
@ -81,6 +86,13 @@ fun AttachmentsPreviewView(
|
||||||
localMediaRenderer: LocalMediaRenderer,
|
localMediaRenderer: LocalMediaRenderer,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val canShowEditAction = when (state.sendActionState) {
|
||||||
|
is SendActionState.Sending.Uploading -> false
|
||||||
|
is SendActionState.Sending.Processing -> !state.sendActionState.displayProgress
|
||||||
|
SendActionState.Done -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
fun postSendAttachment() {
|
fun postSendAttachment() {
|
||||||
state.eventSink(AttachmentsPreviewEvent.SendAttachment)
|
state.eventSink(AttachmentsPreviewEvent.SendAttachment)
|
||||||
}
|
}
|
||||||
|
|
@ -93,33 +105,84 @@ fun AttachmentsPreviewView(
|
||||||
state.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState)
|
state.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState)
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(enabled = state.sendActionState !is SendActionState.Sending.Uploading && state.sendActionState !is SendActionState.Done) {
|
fun postOpenImageEditor() {
|
||||||
postCancel()
|
state.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
fun postCloseImageEditor() {
|
||||||
modifier = modifier,
|
state.eventSink(AttachmentsPreviewEvent.CloseImageEditor)
|
||||||
topBar = {
|
}
|
||||||
TopAppBar(
|
|
||||||
navigationIcon = {
|
fun postResetImageEditor() {
|
||||||
BackButton(
|
state.eventSink(AttachmentsPreviewEvent.ResetImageEdits)
|
||||||
imageVector = CompoundIcons.Close(),
|
}
|
||||||
onClick = ::postCancel,
|
|
||||||
)
|
fun postApplyImageEdits() {
|
||||||
},
|
state.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
|
||||||
title = {},
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = state.sendActionState !is SendActionState.Sending.Uploading && state.sendActionState !is SendActionState.Done) {
|
||||||
|
if (state.imageEditorState != null) {
|
||||||
|
postCloseImageEditor()
|
||||||
|
} else {
|
||||||
|
postCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.imageEditorState != null) {
|
||||||
|
AttachmentImageEditorView(
|
||||||
|
state = state.imageEditorState,
|
||||||
|
onCropRectChange = { cropRect ->
|
||||||
|
state.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect))
|
||||||
|
},
|
||||||
|
onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) },
|
||||||
|
onCancelClick = ::postCloseImageEditor,
|
||||||
|
onResetClick = ::postResetImageEditor,
|
||||||
|
onDoneClick = ::postApplyImageEdits,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
BackButton(
|
||||||
|
onClick = ::postCancel,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.semantics {
|
||||||
|
heading()
|
||||||
|
},
|
||||||
|
text = stringResource(R.string.screen_media_upload_preview_title),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (state.canEditImage && canShowEditAction) {
|
||||||
|
TextButton(
|
||||||
|
stringResource(CommonStrings.action_edit),
|
||||||
|
onClick = ::postOpenImageEditor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
AttachmentPreviewContent(
|
||||||
|
modifier = Modifier.padding(paddingValues),
|
||||||
|
state = state,
|
||||||
|
localMediaRenderer = localMediaRenderer,
|
||||||
|
onSendClick = ::postSendAttachment,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
|
||||||
AttachmentPreviewContent(
|
|
||||||
modifier = Modifier.padding(paddingValues),
|
|
||||||
state = state,
|
|
||||||
localMediaRenderer = localMediaRenderer,
|
|
||||||
onSendClick = ::postSendAttachment,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
AttachmentSendStateView(
|
AttachmentSendStateView(
|
||||||
sendActionState = state.sendActionState,
|
sendActionState = state.sendActionState,
|
||||||
|
isApplyingImageEdits = state.isApplyingImageEdits,
|
||||||
|
displayImageEditError = state.displayImageEditError,
|
||||||
|
onDismissImageEditError = { state.eventSink(AttachmentsPreviewEvent.ClearImageEditError) },
|
||||||
onDismissClick = ::postClearSendState,
|
onDismissClick = ::postClearSendState,
|
||||||
onRetryClick = ::postSendAttachment
|
onRetryClick = ::postSendAttachment
|
||||||
)
|
)
|
||||||
|
|
@ -128,36 +191,56 @@ fun AttachmentsPreviewView(
|
||||||
@Composable
|
@Composable
|
||||||
private fun AttachmentSendStateView(
|
private fun AttachmentSendStateView(
|
||||||
sendActionState: SendActionState,
|
sendActionState: SendActionState,
|
||||||
|
isApplyingImageEdits: Boolean,
|
||||||
|
displayImageEditError: Boolean,
|
||||||
|
onDismissImageEditError: () -> Unit,
|
||||||
onDismissClick: () -> Unit,
|
onDismissClick: () -> Unit,
|
||||||
onRetryClick: () -> Unit
|
onRetryClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
when (sendActionState) {
|
when {
|
||||||
is SendActionState.Sending.Processing -> {
|
isApplyingImageEdits -> {
|
||||||
if (sendActionState.displayProgress) {
|
ProgressDialog(
|
||||||
|
type = ProgressDialogType.Indeterminate,
|
||||||
|
text = stringResource(CommonStrings.common_preparing),
|
||||||
|
showCancelButton = false,
|
||||||
|
onDismissRequest = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
displayImageEditError -> {
|
||||||
|
AlertDialog(
|
||||||
|
title = stringResource(CommonStrings.common_error),
|
||||||
|
content = stringResource(CommonStrings.common_something_went_wrong_message),
|
||||||
|
onDismiss = onDismissImageEditError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> when (sendActionState) {
|
||||||
|
is SendActionState.Sending.Processing -> {
|
||||||
|
if (sendActionState.displayProgress) {
|
||||||
|
ProgressDialog(
|
||||||
|
type = ProgressDialogType.Indeterminate,
|
||||||
|
text = stringResource(CommonStrings.common_preparing),
|
||||||
|
showCancelButton = true,
|
||||||
|
onDismissRequest = onDismissClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is SendActionState.Sending.Uploading -> {
|
||||||
ProgressDialog(
|
ProgressDialog(
|
||||||
type = ProgressDialogType.Indeterminate,
|
type = ProgressDialogType.Indeterminate,
|
||||||
text = stringResource(CommonStrings.common_preparing),
|
text = stringResource(id = CommonStrings.common_sending),
|
||||||
showCancelButton = true,
|
showCancelButton = true,
|
||||||
onDismissRequest = onDismissClick,
|
onDismissRequest = onDismissClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is SendActionState.Failure -> {
|
||||||
|
RetryDialog(
|
||||||
|
content = stringResource(sendAttachmentError(sendActionState.error)),
|
||||||
|
onDismiss = onDismissClick,
|
||||||
|
onRetry = onRetryClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
is SendActionState.Sending.Uploading -> {
|
|
||||||
ProgressDialog(
|
|
||||||
type = ProgressDialogType.Indeterminate,
|
|
||||||
text = stringResource(id = CommonStrings.common_sending),
|
|
||||||
showCancelButton = true,
|
|
||||||
onDismissRequest = onDismissClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is SendActionState.Failure -> {
|
|
||||||
RetryDialog(
|
|
||||||
content = stringResource(sendAttachmentError(sendActionState.error)),
|
|
||||||
onDismiss = onDismissClick,
|
|
||||||
onRetry = onRetryClick
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,10 +267,10 @@ private fun AttachmentPreviewContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val mimeType = (state.attachment as? Attachment.Media)?.localMedia?.info?.mimeType
|
val mediaInfo = (state.attachment as? Attachment.Media)?.localMedia?.info
|
||||||
if (mimeType?.isMimeTypeImage() == true) {
|
if (mediaInfo?.isImageAttachment() == true) {
|
||||||
ImageOptimizationSelector(state.mediaOptimizationSelectorState)
|
ImageOptimizationSelector(state.mediaOptimizationSelectorState)
|
||||||
} else if (mimeType?.isMimeTypeVideo() == true) {
|
} else if (mediaInfo?.mimeType?.isMimeTypeVideo() == true) {
|
||||||
VideoPresetSelector(state = state.mediaOptimizationSelectorState)
|
VideoPresetSelector(state = state.mediaOptimizationSelectorState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,7 +303,8 @@ private fun AttachmentPreviewContent(
|
||||||
private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
|
private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
|
||||||
if (state.displayMediaSelectorViews == true) {
|
if (state.displayMediaSelectorViews == true) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
.niceClickable {
|
.niceClickable {
|
||||||
state.isImageOptimizationEnabled?.let { value ->
|
state.isImageOptimizationEnabled?.let { value ->
|
||||||
state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(!value))
|
state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(!value))
|
||||||
|
|
@ -229,7 +313,9 @@ private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.weight(1f).align(Alignment.CenterVertically),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.align(Alignment.CenterVertically),
|
||||||
text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title),
|
text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title),
|
||||||
style = ElementTheme.typography.fontBodyLgRegular,
|
style = ElementTheme.typography.fontBodyLgRegular,
|
||||||
)
|
)
|
||||||
|
|
@ -255,7 +341,8 @@ private fun VideoPresetSelector(
|
||||||
|
|
||||||
if (state.displayMediaSelectorViews == true && videoPresets != null && state.selectedVideoPreset != null) {
|
if (state.displayMediaSelectorViews == true && videoPresets != null && state.selectedVideoPreset != null) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||||
.niceClickable { state.eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) }
|
.niceClickable { state.eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) }
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.preview.imageeditor
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import io.element.android.features.messages.impl.attachments.preview.resolvedImageMimeType
|
||||||
|
import io.element.android.libraries.androidutils.bitmap.rotateToExifMetadataOrientation
|
||||||
|
import io.element.android.libraries.androidutils.bitmap.writeBitmap
|
||||||
|
import io.element.android.libraries.androidutils.file.createTmpFile
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
||||||
|
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private const val EDITED_MEDIA_DIR_NAME = "edited-media"
|
||||||
|
|
||||||
|
interface AttachmentImageEditor {
|
||||||
|
suspend fun canEdit(localMedia: LocalMedia): Boolean
|
||||||
|
|
||||||
|
suspend fun exportEdits(
|
||||||
|
localMedia: LocalMedia,
|
||||||
|
edits: AttachmentImageEdits,
|
||||||
|
): Result<EditedLocalMedia>
|
||||||
|
}
|
||||||
|
|
||||||
|
data class EditedLocalMedia(
|
||||||
|
val localMedia: LocalMedia,
|
||||||
|
val file: File,
|
||||||
|
)
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultAttachmentImageEditor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val dispatchers: CoroutineDispatchers,
|
||||||
|
) : AttachmentImageEditor {
|
||||||
|
override suspend fun canEdit(localMedia: LocalMedia): Boolean = withContext(dispatchers.io) {
|
||||||
|
localMedia.info.resolvedImageMimeType()
|
||||||
|
?.takeIf { it.isEditableStillImageMimeType() }
|
||||||
|
?.let { return@withContext true }
|
||||||
|
|
||||||
|
val decodedMimeType = context.contentResolver.openInputStream(localMedia.uri)?.use { input ->
|
||||||
|
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeStream(input, null, options)
|
||||||
|
options.outMimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedMimeType.isEditableStillImageMimeType()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun exportEdits(
|
||||||
|
localMedia: LocalMedia,
|
||||||
|
edits: AttachmentImageEdits,
|
||||||
|
): Result<EditedLocalMedia> = withContext(dispatchers.io) {
|
||||||
|
runCatchingExceptions {
|
||||||
|
val sourceMimeType = localMedia.info.resolvedImageMimeType() ?: localMedia.info.mimeType
|
||||||
|
val exportedMimeType = exportedMimeTypeFor(sourceMimeType)
|
||||||
|
val exifOrientation = context.contentResolver.openInputStream(localMedia.uri)?.let { input ->
|
||||||
|
input.use {
|
||||||
|
ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
|
||||||
|
}
|
||||||
|
} ?: ExifInterface.ORIENTATION_UNDEFINED
|
||||||
|
|
||||||
|
val decodedBitmap = context.contentResolver.openInputStream(localMedia.uri)?.use { input ->
|
||||||
|
BitmapFactory.decodeStream(input)
|
||||||
|
} ?: error("Unable to decode image from ${localMedia.uri}")
|
||||||
|
|
||||||
|
val normalizedBitmap = decodedBitmap.rotateToExifMetadataOrientation(exifOrientation)
|
||||||
|
if (normalizedBitmap !== decodedBitmap) {
|
||||||
|
decodedBitmap.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
val rotatedBitmap = normalizedBitmap.rotateQuarterTurns(edits.rotationQuarterTurns)
|
||||||
|
if (rotatedBitmap !== normalizedBitmap) {
|
||||||
|
normalizedBitmap.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
val cropRect = edits.cropRect.toPixelRect(
|
||||||
|
imageWidth = rotatedBitmap.width,
|
||||||
|
imageHeight = rotatedBitmap.height,
|
||||||
|
)
|
||||||
|
val isCropUnchanged = cropRect.left == 0 && cropRect.top == 0 &&
|
||||||
|
cropRect.width() == rotatedBitmap.width && cropRect.height() == rotatedBitmap.height
|
||||||
|
val croppedBitmap = if (isCropUnchanged) {
|
||||||
|
rotatedBitmap
|
||||||
|
} else {
|
||||||
|
Bitmap.createBitmap(
|
||||||
|
rotatedBitmap,
|
||||||
|
cropRect.left,
|
||||||
|
cropRect.top,
|
||||||
|
cropRect.width(),
|
||||||
|
cropRect.height(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (croppedBitmap !== rotatedBitmap) {
|
||||||
|
rotatedBitmap.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
val editedMediaDir = File(context.cacheDir, EDITED_MEDIA_DIR_NAME).apply { mkdirs() }
|
||||||
|
val outputFile = context.createTmpFile(baseDir = editedMediaDir, extension = compressFileExtension(exportedMimeType))
|
||||||
|
outputFile.writeBitmap(
|
||||||
|
bitmap = croppedBitmap,
|
||||||
|
format = compressFormat(exportedMimeType),
|
||||||
|
quality = 90,
|
||||||
|
)
|
||||||
|
croppedBitmap.recycle()
|
||||||
|
|
||||||
|
EditedLocalMedia(
|
||||||
|
localMedia = localMedia.copy(
|
||||||
|
uri = Uri.fromFile(outputFile),
|
||||||
|
info = localMedia.info.copy(mimeType = exportedMimeType),
|
||||||
|
),
|
||||||
|
file = outputFile,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun exportedMimeTypeFor(sourceMimeType: String?): String {
|
||||||
|
return if (sourceMimeType == MimeTypes.Png) {
|
||||||
|
MimeTypes.Png
|
||||||
|
} else {
|
||||||
|
MimeTypes.Jpeg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Bitmap.rotateQuarterTurns(quarterTurns: Int): Bitmap {
|
||||||
|
val normalizedTurns = (quarterTurns % 4 + 4) % 4
|
||||||
|
if (normalizedTurns == 0) return this
|
||||||
|
val matrix = Matrix().apply {
|
||||||
|
postRotate(normalizedTurns * 90f)
|
||||||
|
}
|
||||||
|
return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class PixelCropRect(
|
||||||
|
val left: Int,
|
||||||
|
val top: Int,
|
||||||
|
val right: Int,
|
||||||
|
val bottom: Int,
|
||||||
|
) {
|
||||||
|
fun width() = right - left
|
||||||
|
fun height() = bottom - top
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NormalizedCropRect.toPixelRect(imageWidth: Int, imageHeight: Int): PixelCropRect {
|
||||||
|
val leftPx = (left * imageWidth).roundToInt().coerceIn(0, imageWidth - 1)
|
||||||
|
val topPx = (top * imageHeight).roundToInt().coerceIn(0, imageHeight - 1)
|
||||||
|
val rightPx = (right * imageWidth).roundToInt().coerceIn(leftPx + 1, imageWidth)
|
||||||
|
val bottomPx = (bottom * imageHeight).roundToInt().coerceIn(topPx + 1, imageHeight)
|
||||||
|
return PixelCropRect(
|
||||||
|
left = leftPx,
|
||||||
|
top = topPx,
|
||||||
|
right = rightPx,
|
||||||
|
bottom = bottomPx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compressFormat(mimeType: String) = when (mimeType) {
|
||||||
|
MimeTypes.Png -> Bitmap.CompressFormat.PNG
|
||||||
|
else -> Bitmap.CompressFormat.JPEG
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compressFileExtension(mimeType: String) = when (mimeType) {
|
||||||
|
MimeTypes.Png -> "png"
|
||||||
|
else -> "jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String?.isEditableStillImageMimeType(): Boolean {
|
||||||
|
return this != null &&
|
||||||
|
this.isMimeTypeImage() &&
|
||||||
|
!this.isMimeTypeAnimatedImage() &&
|
||||||
|
this != MimeTypes.Svg
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.preview.imageeditor
|
||||||
|
|
||||||
|
import androidx.annotation.FloatRange
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
|
|
||||||
|
private const val DEFAULT_CROP_MARGIN = 0f
|
||||||
|
private const val MIN_CROP_SIZE = 0.1f
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class AttachmentImageEditorState(
|
||||||
|
val localMedia: LocalMedia,
|
||||||
|
val edits: AttachmentImageEdits,
|
||||||
|
// For preview only
|
||||||
|
val previewDebug: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class AttachmentImageEdits(
|
||||||
|
val cropRect: NormalizedCropRect = NormalizedCropRect.default(),
|
||||||
|
val rotationQuarterTurns: Int = 0,
|
||||||
|
) {
|
||||||
|
val normalizedRotationQuarterTurns: Int
|
||||||
|
get() = rotationQuarterTurns % 4
|
||||||
|
|
||||||
|
val rotationDegrees: Int
|
||||||
|
get() = normalizedRotationQuarterTurns * 90
|
||||||
|
|
||||||
|
val hasChanges: Boolean
|
||||||
|
get() = cropRect != NormalizedCropRect.default() || normalizedRotationQuarterTurns != 0
|
||||||
|
|
||||||
|
fun rotateAntiClockwise(): AttachmentImageEdits {
|
||||||
|
return copy(
|
||||||
|
rotationQuarterTurns = (normalizedRotationQuarterTurns + 3) % 4,
|
||||||
|
// Also update the crop rect to keep the same selected area
|
||||||
|
cropRect = NormalizedCropRect(
|
||||||
|
left = cropRect.top,
|
||||||
|
top = 1f - cropRect.right,
|
||||||
|
right = cropRect.bottom,
|
||||||
|
bottom = 1f - cropRect.left,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class NormalizedCropRect(
|
||||||
|
@FloatRange(from = 0.0, to = 1.0) val left: Float,
|
||||||
|
@FloatRange(from = 0.0, to = 1.0) val top: Float,
|
||||||
|
@FloatRange(from = 0.0, to = 1.0) val right: Float,
|
||||||
|
@FloatRange(from = 0.0, to = 1.0) val bottom: Float,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(left in 0f..1f)
|
||||||
|
require(top in 0f..1f)
|
||||||
|
require(right in 0f..1f)
|
||||||
|
require(bottom in 0f..1f)
|
||||||
|
require(left < right)
|
||||||
|
require(top < bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
val width: Float
|
||||||
|
get() = right - left
|
||||||
|
|
||||||
|
val height: Float
|
||||||
|
get() = bottom - top
|
||||||
|
|
||||||
|
fun applyChange(
|
||||||
|
dragTarget: CropDragTarget,
|
||||||
|
deltaX: Float,
|
||||||
|
deltaY: Float,
|
||||||
|
): NormalizedCropRect = when (dragTarget) {
|
||||||
|
is CropDragTarget.Move -> translate(deltaX, deltaY)
|
||||||
|
is CropDragTarget.Corner -> dragWithCorner(dragTarget, deltaX, deltaY)
|
||||||
|
is CropDragTarget.Edge -> dragWithEdge(dragTarget, deltaX, deltaY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun translate(deltaX: Float, deltaY: Float): NormalizedCropRect {
|
||||||
|
val clampedLeft = (left + deltaX).coerceIn(0f, 1f - width)
|
||||||
|
val clampedTop = (top + deltaY).coerceIn(0f, 1f - height)
|
||||||
|
return copy(
|
||||||
|
left = clampedLeft,
|
||||||
|
top = clampedTop,
|
||||||
|
right = clampedLeft + width,
|
||||||
|
bottom = clampedTop + height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dragWithCorner(
|
||||||
|
dragTarget: CropDragTarget.Corner,
|
||||||
|
deltaX: Float,
|
||||||
|
deltaY: Float,
|
||||||
|
) = when (dragTarget) {
|
||||||
|
CropDragTarget.Corner.TopLeft -> copy(
|
||||||
|
left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
|
||||||
|
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
|
||||||
|
)
|
||||||
|
CropDragTarget.Corner.TopRight -> copy(
|
||||||
|
right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f),
|
||||||
|
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
|
||||||
|
)
|
||||||
|
CropDragTarget.Corner.BottomRight -> copy(
|
||||||
|
right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f),
|
||||||
|
bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f),
|
||||||
|
)
|
||||||
|
CropDragTarget.Corner.BottomLeft -> copy(
|
||||||
|
left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
|
||||||
|
bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dragWithEdge(
|
||||||
|
dragTarget: CropDragTarget.Edge,
|
||||||
|
deltaX: Float,
|
||||||
|
deltaY: Float,
|
||||||
|
) = when (dragTarget) {
|
||||||
|
CropDragTarget.Edge.Top -> copy(
|
||||||
|
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
|
||||||
|
)
|
||||||
|
CropDragTarget.Edge.Right -> copy(
|
||||||
|
right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f),
|
||||||
|
)
|
||||||
|
CropDragTarget.Edge.Bottom -> copy(
|
||||||
|
bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f),
|
||||||
|
)
|
||||||
|
CropDragTarget.Edge.Left -> copy(
|
||||||
|
left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun default() = NormalizedCropRect(
|
||||||
|
left = DEFAULT_CROP_MARGIN,
|
||||||
|
top = DEFAULT_CROP_MARGIN,
|
||||||
|
right = 1f - DEFAULT_CROP_MARGIN,
|
||||||
|
bottom = 1f - DEFAULT_CROP_MARGIN,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface CropDragTarget {
|
||||||
|
data object Move : CropDragTarget
|
||||||
|
|
||||||
|
sealed interface Corner : CropDragTarget {
|
||||||
|
data object TopLeft : Corner
|
||||||
|
data object TopRight : Corner
|
||||||
|
data object BottomRight : Corner
|
||||||
|
data object BottomLeft : Corner
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Edge : CropDragTarget {
|
||||||
|
data object Top : Edge
|
||||||
|
data object Right : Edge
|
||||||
|
data object Bottom : Edge
|
||||||
|
data object Left : Edge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.preview.imageeditor
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
|
|
||||||
|
open class AttachmentImageEditorStateProvider : PreviewParameterProvider<AttachmentImageEditorState> {
|
||||||
|
private val caterpillarCrop = NormalizedCropRect(
|
||||||
|
left = 0.3f,
|
||||||
|
top = 0.3f,
|
||||||
|
right = 0.8f,
|
||||||
|
bottom = 0.75f,
|
||||||
|
)
|
||||||
|
|
||||||
|
override val values: Sequence<AttachmentImageEditorState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
anAttachmentImageEditorState(
|
||||||
|
edits = AttachmentImageEdits(
|
||||||
|
// Cheat a bit so that the crop match the sample image size (1024 * 682)
|
||||||
|
cropRect = 0.17f.let { correction ->
|
||||||
|
NormalizedCropRect(
|
||||||
|
left = 0f,
|
||||||
|
top = correction,
|
||||||
|
right = 1f,
|
||||||
|
bottom = 1 - correction,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
anAttachmentImageEditorState(
|
||||||
|
edits = AttachmentImageEdits(
|
||||||
|
cropRect = caterpillarCrop,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
anAttachmentImageEditorState(
|
||||||
|
edits = AttachmentImageEdits(
|
||||||
|
cropRect = caterpillarCrop,
|
||||||
|
),
|
||||||
|
previewDebug = true,
|
||||||
|
),
|
||||||
|
anAttachmentImageEditorState(
|
||||||
|
edits = AttachmentImageEdits(
|
||||||
|
cropRect = caterpillarCrop,
|
||||||
|
).rotateAntiClockwise(),
|
||||||
|
),
|
||||||
|
// Small crop
|
||||||
|
anAttachmentImageEditorState(
|
||||||
|
edits = AttachmentImageEdits(
|
||||||
|
cropRect = NormalizedCropRect(
|
||||||
|
left = 0.3f,
|
||||||
|
top = 0.6f,
|
||||||
|
right = 0.4f,
|
||||||
|
bottom = 0.7f,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
previewDebug = true,
|
||||||
|
),
|
||||||
|
// Big crop
|
||||||
|
anAttachmentImageEditorState(
|
||||||
|
edits = AttachmentImageEdits(
|
||||||
|
cropRect = NormalizedCropRect(
|
||||||
|
left = 0.05f,
|
||||||
|
top = 0.05f,
|
||||||
|
right = 0.95f,
|
||||||
|
bottom = 0.95f,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
previewDebug = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun anAttachmentImageEditorState(
|
||||||
|
localMedia: LocalMedia = LocalMedia(
|
||||||
|
uri = "file://preview-image".toUri(),
|
||||||
|
info = anImageMediaInfo(),
|
||||||
|
),
|
||||||
|
edits: AttachmentImageEdits = AttachmentImageEdits(),
|
||||||
|
previewDebug: Boolean = false,
|
||||||
|
) = AttachmentImageEditorState(
|
||||||
|
localMedia = localMedia,
|
||||||
|
edits = edits,
|
||||||
|
previewDebug = previewDebug,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,647 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.preview.imageeditor
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.boundsInParent
|
||||||
|
import androidx.compose.ui.layout.onPlaced
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.heading
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.compose.AsyncImagePainter
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.features.messages.impl.R
|
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.text.toPx
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
private val minHandleTouchRadius = 16.dp
|
||||||
|
private val maxHandleTouchRadius = 56.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3539
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AttachmentImageEditorView(
|
||||||
|
state: AttachmentImageEditorState,
|
||||||
|
onCropRectChange: (NormalizedCropRect) -> Unit,
|
||||||
|
onRotateClick: () -> Unit,
|
||||||
|
onResetClick: () -> Unit,
|
||||||
|
onCancelClick: () -> Unit,
|
||||||
|
onDoneClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val rotateContentDescription = stringResource(R.string.screen_image_edition_a11y_rotate_to_the_left)
|
||||||
|
val rotationStateDescription = pluralStringResource(
|
||||||
|
R.plurals.screen_image_edition_a11y_rotation_state,
|
||||||
|
state.edits.rotationDegrees,
|
||||||
|
state.edits.rotationDegrees,
|
||||||
|
)
|
||||||
|
val rotateButtonBackground = ElementTheme.colors.bgCanvasDefault
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
BackButton(
|
||||||
|
imageVector = CompoundIcons.Close(),
|
||||||
|
onClick = onCancelClick,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.semantics {
|
||||||
|
heading()
|
||||||
|
},
|
||||||
|
text = stringResource(R.string.screen_image_edition_title),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(ElementTheme.colors.bgCanvasDefault)
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CropEditorCanvas(
|
||||||
|
state = state,
|
||||||
|
onCropRectChange = onCropRectChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
.widthIn(max = 360.dp)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(start = 20.dp, top = 18.dp, end = 20.dp, bottom = 18.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
text = stringResource(CommonStrings.action_reset),
|
||||||
|
destructive = true,
|
||||||
|
onClick = onResetClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onRotateClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
color = rotateButtonBackground,
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, CircleShape)
|
||||||
|
.clearAndSetSemantics {
|
||||||
|
contentDescription = rotateContentDescription
|
||||||
|
stateDescription = rotationStateDescription
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(22.dp),
|
||||||
|
imageVector = CompoundIcons.RotateLeft(),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentAlignment = Alignment.CenterEnd,
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
text = stringResource(CommonStrings.action_done),
|
||||||
|
onClick = onDoneClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BoxScope.CropEditorCanvas(
|
||||||
|
state: AttachmentImageEditorState,
|
||||||
|
onCropRectChange: (NormalizedCropRect) -> Unit,
|
||||||
|
) {
|
||||||
|
var imageSize by remember(state.localMedia.uri) { mutableStateOf(IntSize.Zero) }
|
||||||
|
val rotationQuarterTurns = state.edits.normalizedRotationQuarterTurns
|
||||||
|
|
||||||
|
var imageRect by remember { mutableStateOf(Rect.Zero) }
|
||||||
|
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(20.dp),
|
||||||
|
) {
|
||||||
|
val displayedSize = remember(maxWidth, maxHeight, imageSize, rotationQuarterTurns) {
|
||||||
|
val sourceWidth = imageSize.width.takeIf { it > 0 } ?: 1
|
||||||
|
val sourceHeight = imageSize.height.takeIf { it > 0 } ?: 1
|
||||||
|
val aspectRatio = if (rotationQuarterTurns % 2 == 0) {
|
||||||
|
sourceWidth.toFloat() / sourceHeight.toFloat()
|
||||||
|
} else {
|
||||||
|
sourceHeight.toFloat() / sourceWidth.toFloat()
|
||||||
|
}
|
||||||
|
fitSize(
|
||||||
|
containerWidth = constraints.maxWidth.toFloat(),
|
||||||
|
containerHeight = constraints.maxHeight.toFloat(),
|
||||||
|
aspectRatio = aspectRatio,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val displayedWidthDp = with(density) { displayedSize.width.toDp() }
|
||||||
|
val displayedHeightDp = with(density) { displayedSize.height.toDp() }
|
||||||
|
val imageLayoutSize = remember(displayedSize, rotationQuarterTurns) {
|
||||||
|
if (rotationQuarterTurns % 2 == 0) {
|
||||||
|
displayedSize
|
||||||
|
} else {
|
||||||
|
Size(
|
||||||
|
width = displayedSize.height,
|
||||||
|
height = displayedSize.width,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val imageLayoutWidthDp = with(density) { imageLayoutSize.width.toDp() }
|
||||||
|
val imageLayoutHeightDp = with(density) { imageLayoutSize.height.toDp() }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(displayedWidthDp, displayedHeightDp)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.onPlaced {
|
||||||
|
imageRect = it.boundsInParent()
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (LocalInspectionMode.current) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = CommonDrawables.sample_background),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
|
||||||
|
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AsyncImage(
|
||||||
|
model = state.localMedia.uri,
|
||||||
|
contentDescription = stringResource(CommonStrings.common_image),
|
||||||
|
modifier = Modifier
|
||||||
|
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
|
||||||
|
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
onState = { painterState ->
|
||||||
|
if (painterState is AsyncImagePainter.State.Success) {
|
||||||
|
imageSize = IntSize(
|
||||||
|
width = painterState.result.image.width,
|
||||||
|
height = painterState.result.image.height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val minHandleTouchRadiusPx = minHandleTouchRadius.toPx()
|
||||||
|
val maxHandleTouchRadiusPx = maxHandleTouchRadius.toPx()
|
||||||
|
val touchRadiusPx by rememberUpdatedState(
|
||||||
|
(min(
|
||||||
|
state.edits.cropRect.width * imageRect.width,
|
||||||
|
state.edits.cropRect.height * imageRect.height,
|
||||||
|
) / 4f).coerceIn(
|
||||||
|
minHandleTouchRadiusPx,
|
||||||
|
maxHandleTouchRadiusPx,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
var dragTarget by remember { mutableStateOf<CropDragTarget?>(null) }
|
||||||
|
val latestCropRect by rememberUpdatedState(state.edits.cropRect)
|
||||||
|
val drawGuidelines = dragTarget == CropDragTarget.Move || state.previewDebug
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = { offset ->
|
||||||
|
dragTarget = detectDragTarget(
|
||||||
|
touchPoint = offset,
|
||||||
|
imageOffset = imageRect.topLeft,
|
||||||
|
cropRect = latestCropRect,
|
||||||
|
canvasSize = Size(imageRect.width, imageRect.height),
|
||||||
|
handleTouchRadius = touchRadiusPx,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
dragTarget = null
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
dragTarget = null
|
||||||
|
},
|
||||||
|
) { change, dragAmount ->
|
||||||
|
val activeTarget = dragTarget ?: return@detectDragGestures
|
||||||
|
change.consume()
|
||||||
|
onCropRectChange(
|
||||||
|
latestCropRect.applyChange(
|
||||||
|
dragTarget = activeTarget,
|
||||||
|
deltaX = dragAmount.x / size.width.toFloat(),
|
||||||
|
deltaY = dragAmount.y / size.height.toFloat(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CropOverlay(
|
||||||
|
imageSize = DpSize(displayedWidthDp, displayedHeightDp),
|
||||||
|
cropRect = state.edits.cropRect,
|
||||||
|
drawGuidelines = drawGuidelines,
|
||||||
|
previewDebug = state.previewDebug,
|
||||||
|
touchRadiusPx = touchRadiusPx,
|
||||||
|
dragTarget = dragTarget,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CropOverlay(
|
||||||
|
imageSize: DpSize,
|
||||||
|
cropRect: NormalizedCropRect,
|
||||||
|
drawGuidelines: Boolean,
|
||||||
|
previewDebug: Boolean,
|
||||||
|
touchRadiusPx: Float,
|
||||||
|
dragTarget: CropDragTarget?,
|
||||||
|
) {
|
||||||
|
val borderColor = ElementTheme.colors.iconPrimary
|
||||||
|
val guideColor = ElementTheme.colors.iconPrimary
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier.size(imageSize.width, imageSize.height)
|
||||||
|
) {
|
||||||
|
val cropLeft = cropRect.left * size.width
|
||||||
|
val cropTop = cropRect.top * size.height
|
||||||
|
val cropRight = cropRect.right * size.width
|
||||||
|
val cropBottom = cropRect.bottom * size.height
|
||||||
|
// Hardcoded black: the crop overlay must always darken the image regardless of theme.
|
||||||
|
// No semantic token exists for this use case in the Compound design system.
|
||||||
|
val overlayColor = Color.Black.copy(alpha = 0.48f)
|
||||||
|
// Overlay above the crop area
|
||||||
|
drawRect(
|
||||||
|
color = overlayColor,
|
||||||
|
topLeft = Offset.Zero,
|
||||||
|
size = Size(width = size.width, height = cropTop),
|
||||||
|
)
|
||||||
|
// Overlay on the left of the crop area
|
||||||
|
drawRect(
|
||||||
|
color = overlayColor,
|
||||||
|
topLeft = Offset(0f, cropTop),
|
||||||
|
size = Size(width = cropLeft, height = cropBottom - cropTop),
|
||||||
|
)
|
||||||
|
// Overlay on the right of the crop area
|
||||||
|
drawRect(
|
||||||
|
color = overlayColor,
|
||||||
|
topLeft = Offset(cropRight, cropTop),
|
||||||
|
size = Size(width = size.width - cropRight, height = cropBottom - cropTop),
|
||||||
|
)
|
||||||
|
// Overlay below the crop area
|
||||||
|
drawRect(
|
||||||
|
color = overlayColor,
|
||||||
|
topLeft = Offset(0f, cropBottom),
|
||||||
|
size = Size(width = size.width, height = size.height - cropBottom),
|
||||||
|
)
|
||||||
|
// Main frame of the crop area
|
||||||
|
drawRect(
|
||||||
|
color = borderColor,
|
||||||
|
topLeft = Offset(cropLeft, cropTop),
|
||||||
|
size = Size(width = cropRight - cropLeft, height = cropBottom - cropTop),
|
||||||
|
style = Stroke(width = 1.dp.toPx()),
|
||||||
|
)
|
||||||
|
// Guidelines dividing the crop area into 9 equal parts
|
||||||
|
if (drawGuidelines) {
|
||||||
|
val thirdWidth = (cropRight - cropLeft) / 3f
|
||||||
|
val thirdHeight = (cropBottom - cropTop) / 3f
|
||||||
|
for (index in 1..2) {
|
||||||
|
val offsetX = cropLeft + thirdWidth * index
|
||||||
|
val offsetY = cropTop + thirdHeight * index
|
||||||
|
// Vertical guide line
|
||||||
|
drawLine(
|
||||||
|
color = guideColor,
|
||||||
|
start = Offset(offsetX, cropTop),
|
||||||
|
end = Offset(offsetX, cropBottom),
|
||||||
|
strokeWidth = 1.dp.toPx(),
|
||||||
|
)
|
||||||
|
// Horizontal guide line
|
||||||
|
drawLine(
|
||||||
|
color = guideColor,
|
||||||
|
start = Offset(cropLeft, offsetY),
|
||||||
|
end = Offset(cropRight, offsetY),
|
||||||
|
strokeWidth = 1.dp.toPx(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Corner handles
|
||||||
|
val handleLength = 18.dp.toPx()
|
||||||
|
val handleOffset = 2.dp.toPx()
|
||||||
|
// Top left corner
|
||||||
|
drawCornerHandle(
|
||||||
|
x = cropLeft - handleOffset,
|
||||||
|
y = cropTop - handleOffset,
|
||||||
|
handleLength = handleLength,
|
||||||
|
color = borderColor,
|
||||||
|
position = CropDragTarget.Corner.TopLeft,
|
||||||
|
)
|
||||||
|
// Top right corner
|
||||||
|
drawCornerHandle(
|
||||||
|
x = cropRight + handleOffset,
|
||||||
|
y = cropTop - handleOffset,
|
||||||
|
handleLength = handleLength,
|
||||||
|
color = borderColor,
|
||||||
|
position = CropDragTarget.Corner.TopRight,
|
||||||
|
)
|
||||||
|
// Bottom left corner
|
||||||
|
drawCornerHandle(
|
||||||
|
x = cropLeft - handleOffset,
|
||||||
|
y = cropBottom + handleOffset,
|
||||||
|
handleLength = handleLength,
|
||||||
|
color = borderColor,
|
||||||
|
position = CropDragTarget.Corner.BottomLeft,
|
||||||
|
)
|
||||||
|
// Bottom right corner
|
||||||
|
drawCornerHandle(
|
||||||
|
x = cropRight + handleOffset,
|
||||||
|
y = cropBottom + handleOffset,
|
||||||
|
handleLength = handleLength,
|
||||||
|
color = borderColor,
|
||||||
|
position = CropDragTarget.Corner.BottomRight,
|
||||||
|
)
|
||||||
|
val handleColor = borderColor
|
||||||
|
// Top handle
|
||||||
|
drawEdgeHandle(
|
||||||
|
center = Offset((cropLeft + cropRight) / 2f, cropTop - handleOffset),
|
||||||
|
horizontal = true,
|
||||||
|
handleLength = handleLength,
|
||||||
|
color = handleColor,
|
||||||
|
)
|
||||||
|
// Right handle
|
||||||
|
drawEdgeHandle(
|
||||||
|
center = Offset(cropRight + handleOffset, (cropTop + cropBottom) / 2f),
|
||||||
|
horizontal = false,
|
||||||
|
handleLength = handleLength,
|
||||||
|
color = handleColor,
|
||||||
|
)
|
||||||
|
// Bottom handle
|
||||||
|
drawEdgeHandle(
|
||||||
|
center = Offset((cropLeft + cropRight) / 2f, cropBottom + handleOffset),
|
||||||
|
horizontal = true,
|
||||||
|
handleLength = handleLength,
|
||||||
|
color = handleColor,
|
||||||
|
)
|
||||||
|
// Left handle
|
||||||
|
drawEdgeHandle(
|
||||||
|
center = Offset(cropLeft - handleOffset, (cropTop + cropBottom) / 2f),
|
||||||
|
horizontal = false,
|
||||||
|
handleLength = handleLength,
|
||||||
|
color = handleColor,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (previewDebug) {
|
||||||
|
// Draw disk around touchable area
|
||||||
|
listOf(
|
||||||
|
CropDragTarget.Edge.Top,
|
||||||
|
CropDragTarget.Edge.Right,
|
||||||
|
CropDragTarget.Edge.Bottom,
|
||||||
|
CropDragTarget.Edge.Left,
|
||||||
|
CropDragTarget.Corner.TopLeft,
|
||||||
|
CropDragTarget.Corner.TopRight,
|
||||||
|
CropDragTarget.Corner.BottomRight,
|
||||||
|
CropDragTarget.Corner.BottomLeft,
|
||||||
|
CropDragTarget.Move,
|
||||||
|
).forEach { target ->
|
||||||
|
val color = when (target) {
|
||||||
|
is CropDragTarget.Move -> Color.Red
|
||||||
|
is CropDragTarget.Corner -> Color.Blue
|
||||||
|
is CropDragTarget.Edge -> Color.Green
|
||||||
|
}.copy(alpha = if (dragTarget == target) 9f else 0.5f)
|
||||||
|
drawCircle(
|
||||||
|
color = color,
|
||||||
|
radius = touchRadiusPx,
|
||||||
|
center = computeOffset(target, cropRect, Size(size.width, size.height)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fitSize(
|
||||||
|
containerWidth: Float,
|
||||||
|
containerHeight: Float,
|
||||||
|
aspectRatio: Float,
|
||||||
|
): Size {
|
||||||
|
val widthBasedHeight = containerWidth / aspectRatio
|
||||||
|
return if (widthBasedHeight <= containerHeight) {
|
||||||
|
Size(width = containerWidth, height = widthBasedHeight)
|
||||||
|
} else {
|
||||||
|
Size(width = containerHeight * aspectRatio, height = containerHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detectDragTarget(
|
||||||
|
touchPoint: Offset,
|
||||||
|
imageOffset: Offset,
|
||||||
|
cropRect: NormalizedCropRect,
|
||||||
|
canvasSize: Size,
|
||||||
|
handleTouchRadius: Float,
|
||||||
|
): CropDragTarget? {
|
||||||
|
// Give priority on Move (extra detection of the center of crop area)
|
||||||
|
// to ensure that user can move a small crop, then to corners and at last to edges.
|
||||||
|
val handlesArea = mapOf(
|
||||||
|
CropDragTarget.Move to computeOffset(CropDragTarget.Move, cropRect, canvasSize),
|
||||||
|
CropDragTarget.Corner.TopLeft to computeOffset(CropDragTarget.Corner.TopLeft, cropRect, canvasSize),
|
||||||
|
CropDragTarget.Corner.TopRight to computeOffset(CropDragTarget.Corner.TopRight, cropRect, canvasSize),
|
||||||
|
CropDragTarget.Corner.BottomRight to computeOffset(CropDragTarget.Corner.BottomRight, cropRect, canvasSize),
|
||||||
|
CropDragTarget.Corner.BottomLeft to computeOffset(CropDragTarget.Corner.BottomLeft, cropRect, canvasSize),
|
||||||
|
CropDragTarget.Edge.Top to computeOffset(CropDragTarget.Edge.Top, cropRect, canvasSize),
|
||||||
|
CropDragTarget.Edge.Right to computeOffset(CropDragTarget.Edge.Right, cropRect, canvasSize),
|
||||||
|
CropDragTarget.Edge.Bottom to computeOffset(CropDragTarget.Edge.Bottom, cropRect, canvasSize),
|
||||||
|
CropDragTarget.Edge.Left to computeOffset(CropDragTarget.Edge.Left, cropRect, canvasSize),
|
||||||
|
)
|
||||||
|
handlesArea.forEach { (target, corner) ->
|
||||||
|
if ((corner - touchPoint + imageOffset).getDistance() <= handleTouchRadius) {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val cropLeft = imageOffset.x + cropRect.left * canvasSize.width
|
||||||
|
val cropTop = imageOffset.y + cropRect.top * canvasSize.height
|
||||||
|
val cropRight = imageOffset.x + cropRect.right * canvasSize.width
|
||||||
|
val cropBottom = imageOffset.y + cropRect.bottom * canvasSize.height
|
||||||
|
return if (touchPoint.x in cropLeft..cropRight && touchPoint.y in cropTop..cropBottom) {
|
||||||
|
CropDragTarget.Move
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeOffset(
|
||||||
|
target: CropDragTarget,
|
||||||
|
cropRect: NormalizedCropRect,
|
||||||
|
canvasSize: Size,
|
||||||
|
) = when (target) {
|
||||||
|
CropDragTarget.Move -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f)
|
||||||
|
CropDragTarget.Corner.TopLeft -> Offset(cropRect.left * canvasSize.width, cropRect.top * canvasSize.height)
|
||||||
|
CropDragTarget.Edge.Top -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.top * canvasSize.height)
|
||||||
|
CropDragTarget.Corner.TopRight -> Offset(cropRect.right * canvasSize.width, cropRect.top * canvasSize.height)
|
||||||
|
CropDragTarget.Edge.Right -> Offset(cropRect.right * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f)
|
||||||
|
CropDragTarget.Corner.BottomRight -> Offset(cropRect.right * canvasSize.width, cropRect.bottom * canvasSize.height)
|
||||||
|
CropDragTarget.Edge.Bottom -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.bottom * canvasSize.height)
|
||||||
|
CropDragTarget.Corner.BottomLeft -> Offset(cropRect.left * canvasSize.width, cropRect.bottom * canvasSize.height)
|
||||||
|
CropDragTarget.Edge.Left -> Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// x and y are the coordinates of the corner
|
||||||
|
private fun DrawScope.drawCornerHandle(
|
||||||
|
x: Float,
|
||||||
|
y: Float,
|
||||||
|
handleLength: Float,
|
||||||
|
color: Color,
|
||||||
|
position: CropDragTarget.Corner,
|
||||||
|
) {
|
||||||
|
val strokeWidth = 4.dp.toPx()
|
||||||
|
val correction = strokeWidth / 2
|
||||||
|
val horizontalCorrection = if (position.isLeft()) -correction else correction
|
||||||
|
val horizontalEndX = if (position.isLeft()) x + handleLength else x - handleLength
|
||||||
|
val verticalEndY = if (position.isTop()) y + handleLength else y - handleLength
|
||||||
|
val verticalCorrection = if (position.isTop()) -correction else correction
|
||||||
|
// Horizontal line
|
||||||
|
drawLine(
|
||||||
|
color = color,
|
||||||
|
start = Offset(x + horizontalCorrection, y),
|
||||||
|
end = Offset(horizontalEndX + horizontalCorrection, y),
|
||||||
|
strokeWidth = strokeWidth,
|
||||||
|
)
|
||||||
|
// Vertical line
|
||||||
|
drawLine(
|
||||||
|
color = color,
|
||||||
|
start = Offset(x, y + verticalCorrection),
|
||||||
|
end = Offset(x, verticalEndY + verticalCorrection),
|
||||||
|
strokeWidth = strokeWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CropDragTarget.Corner.isLeft() = this == CropDragTarget.Corner.TopLeft || this == CropDragTarget.Corner.BottomLeft
|
||||||
|
private fun CropDragTarget.Corner.isTop() = this == CropDragTarget.Corner.TopLeft || this == CropDragTarget.Corner.TopRight
|
||||||
|
|
||||||
|
private fun DrawScope.drawEdgeHandle(
|
||||||
|
center: Offset,
|
||||||
|
horizontal: Boolean,
|
||||||
|
handleLength: Float,
|
||||||
|
color: Color,
|
||||||
|
) {
|
||||||
|
val start = if (horizontal) {
|
||||||
|
Offset(center.x - handleLength / 2f, center.y)
|
||||||
|
} else {
|
||||||
|
Offset(center.x, center.y - handleLength / 2f)
|
||||||
|
}
|
||||||
|
val end = if (horizontal) {
|
||||||
|
Offset(center.x + handleLength / 2f, center.y)
|
||||||
|
} else {
|
||||||
|
Offset(center.x, center.y + handleLength / 2f)
|
||||||
|
}
|
||||||
|
drawLine(
|
||||||
|
color = color,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
strokeWidth = 4.dp.toPx(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only preview in dark, dark theme is forced on the Node.
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun AttachmentImageEditorViewPreview(
|
||||||
|
@PreviewParameter(AttachmentImageEditorStateProvider::class) state: AttachmentImageEditorState,
|
||||||
|
) = ElementPreviewDark {
|
||||||
|
AttachmentImageEditorView(
|
||||||
|
state = state,
|
||||||
|
onCropRectChange = {},
|
||||||
|
onRotateClick = {},
|
||||||
|
onResetClick = {},
|
||||||
|
onCancelClick = {},
|
||||||
|
onDoneClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,12 @@
|
||||||
<string name="emoji_picker_category_places">"Travel & Places"</string>
|
<string name="emoji_picker_category_places">"Travel & Places"</string>
|
||||||
<string name="emoji_picker_category_recent">"Recent emojis"</string>
|
<string name="emoji_picker_category_recent">"Recent emojis"</string>
|
||||||
<string name="emoji_picker_category_symbols">"Symbols"</string>
|
<string name="emoji_picker_category_symbols">"Symbols"</string>
|
||||||
|
<string name="screen_image_edition_a11y_rotate_to_the_left">"Rotate the image to the left"</string>
|
||||||
|
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||||
|
<item quantity="one">"%1$d degree"</item>
|
||||||
|
<item quantity="other">"%1$d degrees"</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="screen_image_edition_title">"Edit photo"</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_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_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_could_not_be_uploaded">"The file could not be uploaded."</string>
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,17 @@
|
||||||
package io.element.android.features.messages.impl.attachments
|
package io.element.android.features.messages.impl.attachments
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvent
|
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvent
|
||||||
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.preview.imageeditor.AttachmentImageEditor
|
||||||
|
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEdits
|
||||||
|
import io.element.android.features.messages.impl.attachments.preview.imageeditor.EditedLocalMedia
|
||||||
|
import io.element.android.features.messages.impl.attachments.preview.imageeditor.NormalizedCropRect
|
||||||
|
import io.element.android.features.messages.impl.attachments.preview.imageeditor.assertIsSimilarTo
|
||||||
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
|
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
|
||||||
import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector
|
import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector
|
||||||
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
|
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
|
||||||
|
|
@ -73,6 +79,7 @@ import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import kotlin.io.path.createTempFile
|
||||||
|
|
||||||
@Suppress("LargeClass")
|
@Suppress("LargeClass")
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
|
@ -551,6 +558,92 @@ class AttachmentsPreviewPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
fun `present - applying image edits updates the attachment`() = runTest {
|
||||||
|
val editedUri = Uri.parse("file:///tmp/edited.jpeg")
|
||||||
|
val presenter = createAttachmentsPreviewPresenter(
|
||||||
|
displayMediaQualitySelectorViews = true,
|
||||||
|
attachmentImageEditor = FakeAttachmentImageEditor {
|
||||||
|
Result.success(
|
||||||
|
EditedLocalMedia(
|
||||||
|
localMedia = aLocalMedia(uri = editedUri),
|
||||||
|
file = File("/tmp/edited.jpeg"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
|
||||||
|
val editorState = awaitItem()
|
||||||
|
assertThat(editorState.imageEditorState).isNotNull()
|
||||||
|
|
||||||
|
editorState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft)
|
||||||
|
val rotatedState = awaitItem()
|
||||||
|
assertThat(rotatedState.imageEditorState?.edits?.rotationQuarterTurns).isEqualTo(3)
|
||||||
|
|
||||||
|
rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
|
||||||
|
assertThat(awaitItem().isApplyingImageEdits).isTrue()
|
||||||
|
|
||||||
|
val appliedState = awaitItem()
|
||||||
|
assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
|
||||||
|
assertThat(appliedState.imageEditorState).isNull()
|
||||||
|
assertThat(appliedState.isApplyingImageEdits).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - reopening image editor keeps original media and previous edits`() = runTest {
|
||||||
|
val editedUri = Uri.parse("file:///tmp/edited.jpeg")
|
||||||
|
val originalLocalMedia = aLocalMedia(uri = mockMediaUrl)
|
||||||
|
val cropRect = NormalizedCropRect(
|
||||||
|
left = 0.2f,
|
||||||
|
top = 0.15f,
|
||||||
|
right = 0.85f,
|
||||||
|
bottom = 0.9f,
|
||||||
|
)
|
||||||
|
val presenter = createAttachmentsPreviewPresenter(
|
||||||
|
localMedia = originalLocalMedia,
|
||||||
|
displayMediaQualitySelectorViews = true,
|
||||||
|
attachmentImageEditor = FakeAttachmentImageEditor {
|
||||||
|
Result.success(
|
||||||
|
EditedLocalMedia(
|
||||||
|
localMedia = aLocalMedia(uri = editedUri),
|
||||||
|
file = File("/tmp/edited.jpeg"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
|
||||||
|
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
|
||||||
|
|
||||||
|
editorState.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect))
|
||||||
|
val croppedState = awaitItem()
|
||||||
|
croppedState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft)
|
||||||
|
val rotatedState = awaitItem()
|
||||||
|
rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
|
||||||
|
|
||||||
|
val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last()
|
||||||
|
assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
|
||||||
|
|
||||||
|
appliedState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
|
||||||
|
val reopenedState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
|
||||||
|
assertThat(reopenedState.imageEditorState!!.localMedia.uri).isEqualTo(originalLocalMedia.uri)
|
||||||
|
val rotatedCropRect = NormalizedCropRect(
|
||||||
|
left = cropRect.top,
|
||||||
|
top = 1f - cropRect.right,
|
||||||
|
right = cropRect.bottom,
|
||||||
|
bottom = 1f - cropRect.left,
|
||||||
|
)
|
||||||
|
reopenedState.imageEditorState.edits.cropRect.assertIsSimilarTo(rotatedCropRect)
|
||||||
|
assertThat(reopenedState.imageEditorState.edits.rotationQuarterTurns).isEqualTo(3)
|
||||||
|
assertThat(reopenedState.imageEditorState.edits.rotationDegrees).isEqualTo(270)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun `present - sendAsFile attachment is pre-processed without image compression`() = runTest {
|
fun `present - sendAsFile attachment is pre-processed without image compression`() = runTest {
|
||||||
// Even though the user has enabled "Optimize media quality" globally, picking the file
|
// Even though the user has enabled "Optimize media quality" globally, picking the file
|
||||||
// through the Files picker (sendAsFile = true) must skip compression. Regression test
|
// through the Files picker (sendAsFile = true) must skip compression. Regression test
|
||||||
|
|
@ -581,6 +674,121 @@ class AttachmentsPreviewPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - sending edited media keeps the edited file available until upload starts`() = runTest {
|
||||||
|
val editedFile = createTempFile(suffix = ".jpeg").toFile().apply {
|
||||||
|
writeText("edited-media")
|
||||||
|
}
|
||||||
|
val sendFileResult =
|
||||||
|
lambdaRecorder<File, FileInfo, String?, String?, EventId?, Result<FakeMediaUploadHandler>> { file, _, _, _, _ ->
|
||||||
|
assertThat(file.exists()).isTrue()
|
||||||
|
Result.success(FakeMediaUploadHandler())
|
||||||
|
}
|
||||||
|
val room = FakeJoinedRoom(
|
||||||
|
liveTimeline = FakeTimeline().apply {
|
||||||
|
sendFileLambda = sendFileResult
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val presenter = createAttachmentsPreviewPresenter(
|
||||||
|
room = room,
|
||||||
|
displayMediaQualitySelectorViews = true,
|
||||||
|
onDoneListener = OnDoneListener {},
|
||||||
|
mediaPreProcessor = FakeMediaPreProcessor().apply {
|
||||||
|
givenResult(
|
||||||
|
Result.success(
|
||||||
|
MediaUploadInfo.AnyFile(
|
||||||
|
file = editedFile,
|
||||||
|
fileInfo = FileInfo(
|
||||||
|
mimetype = MimeTypes.Jpeg,
|
||||||
|
size = editedFile.length(),
|
||||||
|
thumbnailInfo = null,
|
||||||
|
thumbnailSource = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
attachmentImageEditor = FakeAttachmentImageEditor {
|
||||||
|
Result.success(
|
||||||
|
EditedLocalMedia(
|
||||||
|
localMedia = aLocalMedia(uri = editedFile.toUri()),
|
||||||
|
file = editedFile,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
|
||||||
|
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
|
||||||
|
|
||||||
|
editorState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
|
||||||
|
val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last()
|
||||||
|
|
||||||
|
appliedState.eventSink(AttachmentsPreviewEvent.SendAttachment)
|
||||||
|
consumeItemsUntilPredicate { it.sendActionState == SendActionState.Done }
|
||||||
|
|
||||||
|
sendFileResult.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - image with generic mime type and png extension is still editable`() = runTest {
|
||||||
|
val localMedia = aLocalMedia(
|
||||||
|
uri = mockMediaUrl,
|
||||||
|
mediaInfo = anImageMediaInfo().copy(
|
||||||
|
mimeType = MimeTypes.OctetStream,
|
||||||
|
filename = "Screenshot.png",
|
||||||
|
fileExtension = "png",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val presenter = createAttachmentsPreviewPresenter(localMedia = localMedia)
|
||||||
|
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.canEditImage).isTrue()
|
||||||
|
|
||||||
|
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
|
||||||
|
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
|
||||||
|
assertThat(editorState.imageEditorState).isNotNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - image can still be edited when editor can decode it despite generic media info`() = runTest {
|
||||||
|
val localMedia = aLocalMedia(
|
||||||
|
uri = mockMediaUrl,
|
||||||
|
mediaInfo = anImageMediaInfo().copy(
|
||||||
|
mimeType = MimeTypes.OctetStream,
|
||||||
|
filename = "",
|
||||||
|
fileExtension = "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val presenter = createAttachmentsPreviewPresenter(
|
||||||
|
localMedia = localMedia,
|
||||||
|
attachmentImageEditor = FakeAttachmentImageEditor(
|
||||||
|
canEditResult = true,
|
||||||
|
) {
|
||||||
|
Result.success(
|
||||||
|
EditedLocalMedia(
|
||||||
|
localMedia = localMedia.copy(uri = Uri.parse("file:///tmp/decoded.jpeg")),
|
||||||
|
file = File("/tmp/decoded.jpeg"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
presenter.test {
|
||||||
|
val initialState = consumeItemsUntilPredicate { it.canEditImage }.last()
|
||||||
|
assertThat(initialState.canEditImage).isTrue()
|
||||||
|
|
||||||
|
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
|
||||||
|
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
|
||||||
|
assertThat(editorState.imageEditorState).isNotNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - sendAsFile video is pre-processed with best fitting preset`() = runTest {
|
fun `present - sendAsFile video is pre-processed with best fitting preset`() = runTest {
|
||||||
val mediaPreProcessor = FakeMediaPreProcessor()
|
val mediaPreProcessor = FakeMediaPreProcessor()
|
||||||
|
|
@ -652,6 +860,14 @@ class AttachmentsPreviewPresenterTest {
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||||
|
attachmentImageEditor: AttachmentImageEditor = FakeAttachmentImageEditor {
|
||||||
|
Result.success(
|
||||||
|
EditedLocalMedia(
|
||||||
|
localMedia = localMedia.copy(uri = Uri.parse("file:///tmp/default-edited.jpeg")),
|
||||||
|
file = File("/tmp/default-edited.jpeg"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
|
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
|
||||||
): AttachmentsPreviewPresenter {
|
): AttachmentsPreviewPresenter {
|
||||||
return AttachmentsPreviewPresenter(
|
return AttachmentsPreviewPresenter(
|
||||||
|
|
@ -669,6 +885,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
},
|
},
|
||||||
permalinkBuilder = permalinkBuilder,
|
permalinkBuilder = permalinkBuilder,
|
||||||
temporaryUriDeleter = temporaryUriDeleter,
|
temporaryUriDeleter = temporaryUriDeleter,
|
||||||
|
attachmentImageEditor = attachmentImageEditor,
|
||||||
sessionCoroutineScope = this,
|
sessionCoroutineScope = this,
|
||||||
dispatchers = testCoroutineDispatchers(),
|
dispatchers = testCoroutineDispatchers(),
|
||||||
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
|
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
|
||||||
|
|
@ -679,6 +896,22 @@ class AttachmentsPreviewPresenterTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class FakeAttachmentImageEditor(
|
||||||
|
private val canEditResult: Boolean = true,
|
||||||
|
private val result: () -> Result<EditedLocalMedia>,
|
||||||
|
) : AttachmentImageEditor {
|
||||||
|
override suspend fun canEdit(localMedia: LocalMedia): Boolean {
|
||||||
|
return canEditResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun exportEdits(
|
||||||
|
localMedia: LocalMedia,
|
||||||
|
edits: AttachmentImageEdits,
|
||||||
|
): Result<EditedLocalMedia> {
|
||||||
|
return result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val mediaUploadInfo = MediaUploadInfo.AnyFile(
|
private val mediaUploadInfo = MediaUploadInfo.AnyFile(
|
||||||
File("test"),
|
File("test"),
|
||||||
FileInfo(
|
FileInfo(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.preview.imageeditor
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AttachmentImageEditsTest {
|
||||||
|
@Test
|
||||||
|
fun `rotate normalizes after a full turn`() {
|
||||||
|
var edits = AttachmentImageEdits()
|
||||||
|
repeat(4) {
|
||||||
|
edits = edits.rotateAntiClockwise()
|
||||||
|
}
|
||||||
|
assertThat(edits.normalizedRotationQuarterTurns).isEqualTo(0)
|
||||||
|
assertThat(edits.rotationDegrees).isEqualTo(0)
|
||||||
|
assertThat(edits.hasChanges).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rotate updates rotation and crop`() {
|
||||||
|
val sut = AttachmentImageEdits(
|
||||||
|
cropRect = NormalizedCropRect(
|
||||||
|
left = 0.2f,
|
||||||
|
top = 0.3f,
|
||||||
|
right = 0.8f,
|
||||||
|
bottom = 0.9f,
|
||||||
|
),
|
||||||
|
rotationQuarterTurns = 0,
|
||||||
|
)
|
||||||
|
val result = sut.rotateAntiClockwise()
|
||||||
|
assertThat(result.normalizedRotationQuarterTurns).isEqualTo(3)
|
||||||
|
assertThat(result.rotationDegrees).isEqualTo(270)
|
||||||
|
assertThat(result.cropRect.left).isWithin(0.0001f).of(0.3f)
|
||||||
|
assertThat(result.cropRect.top).isWithin(0.0001f).of(0.2f)
|
||||||
|
assertThat(result.cropRect.right).isWithin(0.0001f).of(0.9f)
|
||||||
|
assertThat(result.cropRect.bottom).isWithin(0.0001f).of(0.8f)
|
||||||
|
assertThat(result.hasChanges).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.preview.imageeditor
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DefaultAttachmentImageEditorTest {
|
||||||
|
@Test
|
||||||
|
fun `exported mime type preserves png`() {
|
||||||
|
assertThat(exportedMimeTypeFor(MimeTypes.Png)).isEqualTo(MimeTypes.Png)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exported mime type normalizes non-png images to jpeg`() {
|
||||||
|
assertThat(exportedMimeTypeFor("image/heic")).isEqualTo(MimeTypes.Jpeg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.preview.imageeditor
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class NormalizedCropRectTest {
|
||||||
|
private val rect = NormalizedCropRect(
|
||||||
|
left = 0.1f,
|
||||||
|
top = 0.2f,
|
||||||
|
right = 0.7f,
|
||||||
|
bottom = 0.8f,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applyChange with top handle only updates the top edge`() {
|
||||||
|
val result = rect.applyChange(
|
||||||
|
dragTarget = CropDragTarget.Edge.Top,
|
||||||
|
deltaX = 0.3f,
|
||||||
|
deltaY = 0.1f,
|
||||||
|
)
|
||||||
|
result.assertIsSimilarTo(
|
||||||
|
NormalizedCropRect(
|
||||||
|
left = rect.left,
|
||||||
|
top = 0.3f,
|
||||||
|
right = rect.right,
|
||||||
|
bottom = rect.bottom,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applyChange with left handle only updates the left edge`() {
|
||||||
|
val result = rect.applyChange(
|
||||||
|
dragTarget = CropDragTarget.Edge.Left,
|
||||||
|
deltaX = 0.1f,
|
||||||
|
deltaY = 0.3f,
|
||||||
|
)
|
||||||
|
result.assertIsSimilarTo(
|
||||||
|
NormalizedCropRect(
|
||||||
|
left = 0.2f,
|
||||||
|
top = rect.top,
|
||||||
|
right = rect.right,
|
||||||
|
bottom = rect.bottom,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applyChange with right handle only updates the right edge`() {
|
||||||
|
val result = rect.applyChange(
|
||||||
|
dragTarget = CropDragTarget.Edge.Right,
|
||||||
|
deltaX = -0.1f,
|
||||||
|
deltaY = 0.3f,
|
||||||
|
)
|
||||||
|
result.assertIsSimilarTo(
|
||||||
|
NormalizedCropRect(
|
||||||
|
left = rect.left,
|
||||||
|
top = rect.top,
|
||||||
|
right = 0.6f,
|
||||||
|
bottom = rect.bottom,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applyChange with bottom handle target only updates the bottem edge`() {
|
||||||
|
val result = rect.applyChange(
|
||||||
|
dragTarget = CropDragTarget.Edge.Bottom,
|
||||||
|
deltaX = -0.1f,
|
||||||
|
deltaY = -0.3f,
|
||||||
|
)
|
||||||
|
result.assertIsSimilarTo(
|
||||||
|
NormalizedCropRect(
|
||||||
|
left = rect.left,
|
||||||
|
top = rect.top,
|
||||||
|
right = rect.right,
|
||||||
|
bottom = 0.5f,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applyChange with top left handle updates the top and left bottem edge`() {
|
||||||
|
val result = rect.applyChange(
|
||||||
|
dragTarget = CropDragTarget.Corner.TopLeft,
|
||||||
|
deltaX = 0.1f,
|
||||||
|
deltaY = 0.1f,
|
||||||
|
)
|
||||||
|
result.assertIsSimilarTo(
|
||||||
|
NormalizedCropRect(
|
||||||
|
left = 0.2f,
|
||||||
|
top = 0.3f,
|
||||||
|
right = rect.right,
|
||||||
|
bottom = rect.bottom,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applyChange with top right handle updates the top and right bottem edge`() {
|
||||||
|
val result = rect.applyChange(
|
||||||
|
dragTarget = CropDragTarget.Corner.TopRight,
|
||||||
|
deltaX = -0.1f,
|
||||||
|
deltaY = 0.1f,
|
||||||
|
)
|
||||||
|
result.assertIsSimilarTo(
|
||||||
|
NormalizedCropRect(
|
||||||
|
left = rect.left,
|
||||||
|
top = 0.3f,
|
||||||
|
right = 0.6f,
|
||||||
|
bottom = rect.bottom,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applyChange with bottom left handle updates the bottom and left bottem edge`() {
|
||||||
|
val result = rect.applyChange(
|
||||||
|
dragTarget = CropDragTarget.Corner.BottomLeft,
|
||||||
|
deltaX = 0.1f,
|
||||||
|
deltaY = -0.1f,
|
||||||
|
)
|
||||||
|
result.assertIsSimilarTo(
|
||||||
|
NormalizedCropRect(
|
||||||
|
left = 0.2f,
|
||||||
|
top = rect.top,
|
||||||
|
right = rect.right,
|
||||||
|
bottom = 0.7f,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applyChange with bottom right handle updates the bottom and right bottem edge`() {
|
||||||
|
val result = rect.applyChange(
|
||||||
|
dragTarget = CropDragTarget.Corner.BottomRight,
|
||||||
|
deltaX = -0.1f,
|
||||||
|
deltaY = -0.1f,
|
||||||
|
)
|
||||||
|
result.assertIsSimilarTo(
|
||||||
|
NormalizedCropRect(
|
||||||
|
left = rect.left,
|
||||||
|
top = rect.top,
|
||||||
|
right = 0.6f,
|
||||||
|
bottom = 0.7f,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `translate keeps the crop rect inside bounds`() {
|
||||||
|
val result = rect.applyChange(
|
||||||
|
dragTarget = CropDragTarget.Move,
|
||||||
|
deltaX = 0.6f,
|
||||||
|
deltaY = 0.6f,
|
||||||
|
)
|
||||||
|
result.assertIsSimilarTo(
|
||||||
|
NormalizedCropRect(
|
||||||
|
left = 0.4f,
|
||||||
|
top = 0.4f,
|
||||||
|
right = 1.0f,
|
||||||
|
bottom = 1.0f,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun NormalizedCropRect.assertIsSimilarTo(expectedResult: NormalizedCropRect) {
|
||||||
|
assertThat(left).isWithin(0.0001f).of(expectedResult.left)
|
||||||
|
assertThat(top).isWithin(0.0001f).of(expectedResult.top)
|
||||||
|
assertThat(right).isWithin(0.0001f).of(expectedResult.right)
|
||||||
|
assertThat(bottom).isWithin(0.0001f).of(expectedResult.bottom)
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,9 @@
|
||||||
package io.element.android.libraries.mediaviewer.impl.local
|
package io.element.android.libraries.mediaviewer.impl.local
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
|
@ -17,6 +19,7 @@ import io.element.android.libraries.androidutils.file.getFileName
|
||||||
import io.element.android.libraries.androidutils.file.getFileSize
|
import io.element.android.libraries.androidutils.file.getFileSize
|
||||||
import io.element.android.libraries.androidutils.file.getMimeType
|
import io.element.android.libraries.androidutils.file.getMimeType
|
||||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||||
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
@ -85,8 +88,12 @@ class AndroidLocalMediaFactory(
|
||||||
waveform: List<Float>?,
|
waveform: List<Float>?,
|
||||||
duration: String?,
|
duration: String?,
|
||||||
): LocalMedia {
|
): LocalMedia {
|
||||||
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
|
|
||||||
val fileName = name ?: context.getFileName(uri) ?: ""
|
val fileName = name ?: context.getFileName(uri) ?: ""
|
||||||
|
val resolvedMimeType = resolveMimeType(
|
||||||
|
uri = uri,
|
||||||
|
mimeType = mimeType,
|
||||||
|
fileName = fileName,
|
||||||
|
)
|
||||||
val fileSize = context.getFileSize(uri)
|
val fileSize = context.getFileSize(uri)
|
||||||
val calculatedFormattedFileSize = formattedFileSize ?: fileSizeFormatter.format(fileSize)
|
val calculatedFormattedFileSize = formattedFileSize ?: fileSizeFormatter.format(fileSize)
|
||||||
val fileExtension = fileExtensionExtractor.extractFromName(fileName)
|
val fileExtension = fileExtensionExtractor.extractFromName(fileName)
|
||||||
|
|
@ -110,4 +117,36 @@ class AndroidLocalMediaFactory(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolveMimeType(
|
||||||
|
uri: Uri,
|
||||||
|
mimeType: String?,
|
||||||
|
fileName: String,
|
||||||
|
): String {
|
||||||
|
val explicitMimeType = mimeType.takeUnless { it.isNullOrBlank() || it == MimeTypes.OctetStream }
|
||||||
|
if (explicitMimeType != null) return explicitMimeType
|
||||||
|
|
||||||
|
val resolverMimeType = context.getMimeType(uri).takeUnless { it.isNullOrBlank() || it == MimeTypes.OctetStream }
|
||||||
|
if (resolverMimeType != null) return resolverMimeType
|
||||||
|
|
||||||
|
val decodedImageMimeType = decodeImageMimeType(uri)
|
||||||
|
if (decodedImageMimeType != null) return decodedImageMimeType
|
||||||
|
|
||||||
|
val extensionMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
|
||||||
|
fileExtensionExtractor.extractFromName(fileName)
|
||||||
|
)
|
||||||
|
if (!extensionMimeType.isNullOrBlank()) return extensionMimeType
|
||||||
|
|
||||||
|
return MimeTypes.OctetStream
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeImageMimeType(uri: Uri): String? {
|
||||||
|
return tryOrNull {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
|
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeStream(inputStream, null, options)
|
||||||
|
options.outMimeType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.impl.local
|
package io.element.android.libraries.mediaviewer.impl.local
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.androidutils.file.getMimeType
|
||||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
|
|
@ -22,9 +24,13 @@ import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.RuntimeEnvironment
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class AndroidLocalMediaFactoryTest {
|
class AndroidLocalMediaFactoryTest {
|
||||||
|
private val context = RuntimeEnvironment.getApplication()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test AndroidLocalMediaFactory`() {
|
fun `test AndroidLocalMediaFactory`() {
|
||||||
val sut = createAndroidLocalMediaFactory()
|
val sut = createAndroidLocalMediaFactory()
|
||||||
|
|
@ -58,13 +64,34 @@ class AndroidLocalMediaFactoryTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createFromUri detects image mime type from content when picker mime type is generic`() {
|
||||||
|
val imageFile = File(context.cacheDir, "picked-media").apply {
|
||||||
|
FileOutputStream(this).use { outputStream ->
|
||||||
|
Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
|
||||||
|
.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = createAndroidLocalMediaFactory().createFromUri(
|
||||||
|
uri = imageFile.toURI().toString().let(android.net.Uri::parse),
|
||||||
|
mimeType = MimeTypes.OctetStream,
|
||||||
|
name = imageFile.name,
|
||||||
|
formattedFileSize = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(context.getMimeType(result.uri)).isNull()
|
||||||
|
assertThat(result.info.mimeType).isEqualTo(MimeTypes.Png)
|
||||||
|
assertThat(result.info.fileExtension).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
private fun aMediaFile(): MediaFile {
|
private fun aMediaFile(): MediaFile {
|
||||||
return FakeMediaFile("aPath")
|
return FakeMediaFile("aPath")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createAndroidLocalMediaFactory(): AndroidLocalMediaFactory {
|
private fun createAndroidLocalMediaFactory(): AndroidLocalMediaFactory {
|
||||||
return AndroidLocalMediaFactory(
|
return AndroidLocalMediaFactory(
|
||||||
RuntimeEnvironment.getApplication(),
|
context,
|
||||||
FakeFileSizeFormatter(),
|
FakeFileSizeFormatter(),
|
||||||
FileExtensionExtractorWithoutValidation()
|
FileExtensionExtractorWithoutValidation()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:51f0b3f7e4bb16728f21055de37b7b2780fd2a1fc65b6bd4564334daeab20763
|
||||||
|
size 329042
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5febc6580e4f0bda75a27445157ace7f1acb620c17cba55ca5d2a9330743c1de
|
||||||
|
size 283397
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:34b6dfe4e65612615c3dc87e5f65bd0b160d97527c4a4749b496bf8d48819d96
|
||||||
|
size 256641
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d99c470fd5134e0a84b284ed32f4c93de01561e630ef32d7c22a4b476bb871b1
|
||||||
|
size 277852
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4b15a04a861812e3e4dee0a6a30c6afef4ab171c5261d1e5bd5a234bb7296d97
|
||||||
|
size 251908
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c32b4744750b9f18612bded2e292dde151e2bfdd8a69a36c88044f5ca3a76f8e
|
||||||
|
size 311315
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:22e3d682c4866bd5c519ab88d08290708929af805438a0bd093200cddcbd41b2
|
oid sha256:a14113653096095323ecb94cb3dd694985717b8585c8cfc6b7a45cf7a2483a79
|
||||||
size 399376
|
size 402427
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:02bb9e9de3b0ef480cedbed50483bdd3a899497ecc40ead72491106c6f6b6611
|
oid sha256:95c64e1e7055dd048b88f0e19a57fe696ee93505d74f96bc4f1915fcce769d7d
|
||||||
size 399030
|
size 402072
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:947ccb947f4a961ff7d17b936f2c866d66fea0361879d51ad4c65d18465c1a9f
|
oid sha256:4ee2ee54c694a45316bbdcfba7038391fe808f5292356748e9e66f46135a9ae8
|
||||||
size 59226
|
size 61457
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:22e3d682c4866bd5c519ab88d08290708929af805438a0bd093200cddcbd41b2
|
oid sha256:a14113653096095323ecb94cb3dd694985717b8585c8cfc6b7a45cf7a2483a79
|
||||||
size 399376
|
size 402427
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0d9e221ec2f4ee764967e94c32f52b1615b25dec8fc7697dd5bcd01fc4da8d69
|
oid sha256:44c7b0e86781ff6112c3be5b575b548c5ae6e9530f6f27a070ebb008d606a20c
|
||||||
size 59098
|
size 61326
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:547bf4e3bec05c219f5a72cfd8d506eb7c39429970cced5c2b8f2999ae390265
|
oid sha256:ccedfe061af8a77da7ddf6b20d23899bfff986fd6c74b87480b588e148ddd8de
|
||||||
size 86149
|
size 89013
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:6d326595038160376db620a07a180de7af37ebfc76d4927ed1176ef6f4370aab
|
oid sha256:f52a267ee2aa300185191aa3e610787c58d7222bc8e4a7570879d4c5fd37133d
|
||||||
size 72700
|
size 328936
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a8e8bcb6fdffb2d8673e4ee4e16e21672ffe99e717c48fd35b8411a8aa0530e9
|
oid sha256:1feeb32f9dce486c54db41727d9d9801f606353008945988331c2ce391dec451
|
||||||
size 405064
|
size 75364
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3caecb171d3095ef5c3593c6289b2d99e6cd6a6c635d822ad24061e502bdeb2e
|
oid sha256:2bfda29fcaade9508e0d742b0de7ac230d41a358e98755e4b08f1ed7d5affe30
|
||||||
size 82790
|
size 408106
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1fdc4e2d82297927d2062586398fda3f3b7f6febfde8888a062229caf713ff54
|
||||||
|
size 85154
|
||||||
|
|
@ -273,6 +273,7 @@
|
||||||
"screen_room_timeline.*",
|
"screen_room_timeline.*",
|
||||||
"screen\\.room_timeline.*",
|
"screen\\.room_timeline.*",
|
||||||
"screen_room_typing.*",
|
"screen_room_typing.*",
|
||||||
|
"screen\\.image_edition\\..*",
|
||||||
"screen\\.media_upload.*"
|
"screen\\.media_upload.*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue