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>
This commit is contained in:
Gianluca Iavicoli 2026-05-21 15:08:26 +02:00 committed by Benoit Marty
parent 00efd9a01c
commit bcad1f9dce
17 changed files with 1517 additions and 49 deletions

View file

@ -9,7 +9,9 @@
package io.element.android.libraries.mediaviewer.impl.local
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope
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.getMimeType
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.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.UserId
@ -85,8 +88,12 @@ class AndroidLocalMediaFactory(
waveform: List<Float>?,
duration: String?,
): LocalMedia {
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
val fileName = name ?: context.getFileName(uri) ?: ""
val resolvedMimeType = resolveMimeType(
uri = uri,
mimeType = mimeType,
fileName = fileName,
)
val fileSize = context.getFileSize(uri)
val calculatedFormattedFileSize = formattedFileSize ?: fileSizeFormatter.format(fileSize)
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
}
}
}
}

View file

@ -8,7 +8,9 @@
package io.element.android.libraries.mediaviewer.impl.local
import android.graphics.Bitmap
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.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaFile
@ -22,9 +24,13 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.io.FileOutputStream
@RunWith(RobolectricTestRunner::class)
class AndroidLocalMediaFactoryTest {
private val context = RuntimeEnvironment.getApplication()
@Test
fun `test AndroidLocalMediaFactory`() {
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 {
return FakeMediaFile("aPath")
}
private fun createAndroidLocalMediaFactory(): AndroidLocalMediaFactory {
return AndroidLocalMediaFactory(
RuntimeEnvironment.getApplication(),
context,
FakeFileSizeFormatter(),
FileExtensionExtractorWithoutValidation()
)