From 88d419c03fa3badc9a46029086c6a7476b23575a Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:06:17 +0000 Subject: [PATCH 1/4] Revert "Send SVG images as files (#4595)" --- .../io/element/android/libraries/core/mimetype/MimeTypes.kt | 1 - .../libraries/mediaupload/impl/AndroidMediaPreProcessor.kt | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt index e7962f8bd66..73c05ffcef8 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt @@ -26,7 +26,6 @@ object MimeTypes { const val Jpeg = "image/jpeg" const val Gif = "image/gif" const val WebP = "image/webp" - const val Svg = "image/svg+xml" const val Videos = "video/*" const val Mp4 = "video/mp4" diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt index 2ab1cd06199..8c5bab459fd 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt @@ -67,7 +67,7 @@ class AndroidMediaPreProcessor( */ private const val IMAGE_SCALE_REF_SIZE = 640 - private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP, MimeTypes.Svg) + private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP) } private val contentResolver = context.contentResolver @@ -83,10 +83,6 @@ class AndroidMediaPreProcessor( ): Result = withContext(coroutineDispatchers.computation) { runCatchingExceptions { val result = when { - // Special case for SVG, since Android can't read its metadata or create a thumbnail, it must be sent as a file - mimeType == MimeTypes.Svg -> { - processFile(uri, mimeType) - } mimeType.isMimeTypeImage() -> { val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes processImage(uri, mimeType, shouldBeCompressed) From 5b53686c3e1ab23d04f71ce721b32a97e017e754 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:06:17 +0000 Subject: [PATCH 2/4] Add full SVG support --- gradle/libs.versions.toml | 1 + .../libraries/core/mimetype/MimeTypes.kt | 1 + .../matrix/api/exception/ClientException.kt | 3 + .../matrix/impl/media/RustMediaLoader.kt | 6 +- libraries/matrixmedia/impl/build.gradle.kts | 1 + .../matrix/ui/media/CoilMediaFetcher.kt | 19 ++++- .../matrix/ui/media/ImageLoaderFactories.kt | 4 + .../impl/AndroidMediaPreProcessor.kt | 82 ++++++++++++++++++- .../impl/src/test/assets/image.svg | 3 + .../impl/AndroidMediaPreProcessorTest.kt | 48 +++++++++++ .../libraries/mediaupload/impl/Asset.kt | 11 +++ 11 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 libraries/mediaupload/impl/src/test/assets/image.svg diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 163a583597c..2cdda307c20 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -190,6 +190,7 @@ coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } coil_network_okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil_gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } +coil_svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } coil_test = { module = "io.coil-kt.coil3:coil-test", version.ref = "coil" } datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" } diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt index 73c05ffcef8..e7962f8bd66 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt @@ -26,6 +26,7 @@ object MimeTypes { const val Jpeg = "image/jpeg" const val Gif = "image/gif" const val WebP = "image/webp" + const val Svg = "image/svg+xml" const val Videos = "video/*" const val Mp4 = "video/mp4" diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt index 565dc596143..3a1f0825f40 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt @@ -22,3 +22,6 @@ sealed class ClientException(message: String, val details: String?, cause: Throw fun ClientException.isNetworkError(): Boolean { return this is ClientException.Generic && message?.contains("error sending request for url", ignoreCase = true) == true } + +fun Throwable.isNetworkError(): Boolean = + (this as? ClientException)?.isNetworkError() == true diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt index 81946980e3a..d1401d26247 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -9,11 +9,13 @@ package io.element.android.libraries.matrix.impl.media import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.impl.exception.mapClientException import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.use @@ -37,7 +39,7 @@ class RustMediaLoader( source.toRustMediaSource().use { source -> innerClient.getMediaContent(source) } - } + }.mapFailure { it.mapClientException() } } override suspend fun loadMediaThumbnail( @@ -54,7 +56,7 @@ class RustMediaLoader( height = height.toULong() ) } - } + }.mapFailure { it.mapClientException() } } override suspend fun downloadMediaFile( diff --git a/libraries/matrixmedia/impl/build.gradle.kts b/libraries/matrixmedia/impl/build.gradle.kts index 56ccc79afcc..62799b5eaab 100644 --- a/libraries/matrixmedia/impl/build.gradle.kts +++ b/libraries/matrixmedia/impl/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation(projects.libraries.designsystem) implementation(libs.coil.compose) implementation(libs.coil.gif) + implementation(libs.coil.svg) implementation(libs.coil.network.okhttp) testCommonDependencies(libs, true) diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt index 39f67b41d7a..b1f72976e06 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt @@ -13,6 +13,7 @@ import coil3.decode.ImageSource import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.SourceFetchResult +import io.element.android.libraries.matrix.api.exception.isNetworkError import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.toFile @@ -34,7 +35,19 @@ internal class CoilMediaFetcher( } return when (val kind = mediaData.kind) { is MediaRequestData.Kind.Content -> fetchContent(mediaSource) - is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(mediaSource, kind) + is MediaRequestData.Kind.Thumbnail -> { + val thumbnailResult = fetchThumbnail(mediaSource, kind) + if (thumbnailResult.isSuccess) { + thumbnailResult.getOrThrow() + } else { + val error = thumbnailResult.exceptionOrNull() + if (error?.isNetworkError() == true) { + null + } else { + fetchContent(mediaSource) + } + } + } is MediaRequestData.Kind.File -> fetchFile(mediaSource, kind) } } @@ -74,7 +87,7 @@ internal class CoilMediaFetcher( }.getOrNull() } - private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): FetchResult? { + private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): Result { return mediaLoader.loadMediaThumbnail( source = mediaSource, width = kind.width, @@ -83,7 +96,7 @@ internal class CoilMediaFetcher( byteArray.asSourceResult() }.onFailure { Timber.e(it) - }.getOrNull() + } } private fun ByteArray.asSourceResult(): SourceFetchResult { diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt index ef654a4cf73..c74a819ecdf 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt @@ -14,6 +14,7 @@ import coil3.ImageLoader import coil3.gif.AnimatedImageDecoder import coil3.gif.GifDecoder import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import coil3.svg.SvgDecoder import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.di.annotations.ApplicationContext @@ -41,6 +42,7 @@ class DefaultImageLoaderFactory( return ImageLoader.Builder(context) .components { add(okHttpNetworkFetcherFactory) + add(SvgDecoder.Factory()) } .build() } @@ -49,6 +51,8 @@ class DefaultImageLoaderFactory( return ImageLoader.Builder(context) .components { add(okHttpNetworkFetcherFactory) + // Add svg support + add(SvgDecoder.Factory()) // Add gif support if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { add(AnimatedImageDecoder.Factory()) diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt index 8c5bab459fd..11e5e7e65ed 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt @@ -12,6 +12,7 @@ import android.content.Context import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri +import android.util.Xml import androidx.exifinterface.media.ExifInterface import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -42,6 +43,7 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext +import org.xmlpull.v1.XmlPullParser import timber.log.Timber import java.io.File import java.io.InputStream @@ -67,7 +69,10 @@ class AndroidMediaPreProcessor( */ private const val IMAGE_SCALE_REF_SIZE = 640 - private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP) + private const val SVG_DEFAULT_WIDTH = 640L + private const val SVG_DEFAULT_HEIGHT = 480L + + private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP, MimeTypes.Svg) } private val contentResolver = context.contentResolver @@ -156,6 +161,11 @@ class AndroidMediaPreProcessor( private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo { Timber.d("Processing image ${uri.path.orEmpty().hash()}") + + if (mimeType == MimeTypes.Svg) { + return processSvgImage(uri, mimeType) + } + suspend fun processImageWithCompression(): MediaUploadInfo { // Read the orientation metadata from its own stream. Trying to reuse this stream for compression will fail. val orientation = contentResolver.openInputStream(uri).use { input -> @@ -216,6 +226,76 @@ class AndroidMediaPreProcessor( } } + private suspend fun processSvgImage(uri: Uri, mimeType: String): MediaUploadInfo { + Timber.d("Processing SVG image ${uri.path.orEmpty().hash()}") + val file = copyToTmpFile(uri) + val (width, height) = extractSvgDimensions(file) + val imageInfo = ImageInfo( + width = width, + height = height, + mimetype = mimeType, + size = file.length(), + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ) + return MediaUploadInfo.Image( + file = file, + imageInfo = imageInfo, + thumbnailFile = null, + ) + } + + private fun extractSvgDimensions(file: File): Pair { + return file.inputStream().use { inputStream -> + try { + val parser = Xml.newPullParser() + parser.setInput(inputStream, null) + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG && parser.name.equals("svg", ignoreCase = true)) { + val width = parser.getAttributeValue(null, "width") + val height = parser.getAttributeValue(null, "height") + val viewBox = parser.getAttributeValue(null, "viewBox") + + val parsedWidth = width?.let { parseSvgLength(it) } + val parsedHeight = height?.let { parseSvgLength(it) } + + if (parsedWidth != null && parsedHeight != null) { + return parsedWidth to parsedHeight + } + + if (viewBox != null) { + val parts = viewBox.trim().split("\\s+".toRegex()).map { it.toFloatOrNull() } + if (parts.size == 4 && parts[2] != null && parts[3] != null) { + val vbWidth = parts[2]!!.toLong().coerceAtLeast(1) + val vbHeight = parts[3]!!.toLong().coerceAtLeast(1) + return vbWidth to vbHeight + } + } + + return (parsedWidth ?: SVG_DEFAULT_WIDTH) to (parsedHeight ?: SVG_DEFAULT_HEIGHT) + } + eventType = parser.next() + } + SVG_DEFAULT_WIDTH to SVG_DEFAULT_HEIGHT + } catch (_: Exception) { + SVG_DEFAULT_WIDTH to SVG_DEFAULT_HEIGHT + } + } + } + + private fun parseSvgLength(value: String): Long? { + val trimmed = value.trim() + if (trimmed.isEmpty()) return null + return try { + val numericPart = trimmed.replace(Regex("[^\\d.]"), "") + if (numericPart.isEmpty()) null else numericPart.toFloat().toLong().coerceAtLeast(1) + } catch (_: NumberFormatException) { + null + } + } + private suspend fun processVideo(uri: Uri, mimeType: String?, videoCompressionPreset: VideoCompressionPreset): MediaUploadInfo { Timber.d("Processing video ${uri.path.orEmpty().hash()}") val resultFile = runCatchingExceptions { diff --git a/libraries/mediaupload/impl/src/test/assets/image.svg b/libraries/mediaupload/impl/src/test/assets/image.svg new file mode 100644 index 00000000000..03a3fb275bf --- /dev/null +++ b/libraries/mediaupload/impl/src/test/assets/image.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97a22915f50ba62605a8e3f6ba8e8099c1d601d2aa929c2879197b41531ce4dc +size 210 diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt index 57726ac5a2b..cf82f4f7ae3 100644 --- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt @@ -401,6 +401,54 @@ class AndroidMediaPreProcessorTest { ) } + @Test + fun `test processing svg`() = runTest { + val mediaUploadInfo = process( + asset = assetImageSvg, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + width = 800, + height = 600, + mimetype = MimeTypes.Svg, + size = 210, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ) + ) + assertThat(info.thumbnailFile).isNull() + } + + @Test + fun `test processing svg without compression`() = runTest { + val mediaUploadInfo = process( + asset = assetImageSvg, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + width = 800, + height = 600, + mimetype = MimeTypes.Svg, + size = 210, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ) + ) + assertThat(info.thumbnailFile).isNull() + } + @Test fun `test processing audio`() = runTest { val mediaUploadInfo = process( diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/Asset.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/Asset.kt index 4dad31a7842..7b702448521 100644 --- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/Asset.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/Asset.kt @@ -83,3 +83,14 @@ val assetAnimatedGif = Asset( width = 800, height = 600, ) + +/** + * "image.svg" is an 800 x 600 SVG image with a size of 210 bytes. + */ +val assetImageSvg = Asset( + filename = "image.svg", + mimeType = MimeTypes.Svg, + size = 210, + width = 800, + height = 600, +) From c4c0c482f0c6a9620a3fd62466fe870b46b68ca8 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:02:15 +0000 Subject: [PATCH 3/4] Address review --- .../matrix/ui/media/CoilMediaFetcher.kt | 19 +++--- .../matrix/ui/media/ImageLoaderFactories.kt | 1 - .../impl/AndroidMediaPreProcessor.kt | 66 ++---------------- .../mediaupload/impl/SvgDimensionExtractor.kt | 68 +++++++++++++++++++ 4 files changed, 82 insertions(+), 72 deletions(-) create mode 100644 libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt index b1f72976e06..713057ecda7 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt @@ -36,17 +36,16 @@ internal class CoilMediaFetcher( return when (val kind = mediaData.kind) { is MediaRequestData.Kind.Content -> fetchContent(mediaSource) is MediaRequestData.Kind.Thumbnail -> { - val thumbnailResult = fetchThumbnail(mediaSource, kind) - if (thumbnailResult.isSuccess) { - thumbnailResult.getOrThrow() - } else { - val error = thumbnailResult.exceptionOrNull() - if (error?.isNetworkError() == true) { - null - } else { - fetchContent(mediaSource) + fetchThumbnail(mediaSource, kind).fold( + onSuccess = { it }, + onFailure = { error -> + if (error.isNetworkError()) { + null + } else { + fetchContent(mediaSource) + } } - } + ) } is MediaRequestData.Kind.File -> fetchFile(mediaSource, kind) } diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt index c74a819ecdf..b414560fc94 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt @@ -42,7 +42,6 @@ class DefaultImageLoaderFactory( return ImageLoader.Builder(context) .components { add(okHttpNetworkFetcherFactory) - add(SvgDecoder.Factory()) } .build() } diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt index 11e5e7e65ed..406f4e89bff 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt @@ -12,7 +12,6 @@ import android.content.Context import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri -import android.util.Xml import androidx.exifinterface.media.ExifInterface import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -43,7 +42,6 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext -import org.xmlpull.v1.XmlPullParser import timber.log.Timber import java.io.File import java.io.InputStream @@ -69,10 +67,7 @@ class AndroidMediaPreProcessor( */ private const val IMAGE_SCALE_REF_SIZE = 640 - private const val SVG_DEFAULT_WIDTH = 640L - private const val SVG_DEFAULT_HEIGHT = 480L - - private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP, MimeTypes.Svg) + private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP) } private val contentResolver = context.contentResolver @@ -88,6 +83,7 @@ class AndroidMediaPreProcessor( ): Result = withContext(coroutineDispatchers.computation) { runCatchingExceptions { val result = when { + mimeType == MimeTypes.Svg -> processSvgImage(uri, mimeType) mimeType.isMimeTypeImage() -> { val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes processImage(uri, mimeType, shouldBeCompressed) @@ -162,10 +158,6 @@ class AndroidMediaPreProcessor( private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo { Timber.d("Processing image ${uri.path.orEmpty().hash()}") - if (mimeType == MimeTypes.Svg) { - return processSvgImage(uri, mimeType) - } - suspend fun processImageWithCompression(): MediaUploadInfo { // Read the orientation metadata from its own stream. Trying to reuse this stream for compression will fail. val orientation = contentResolver.openInputStream(uri).use { input -> @@ -226,10 +218,12 @@ class AndroidMediaPreProcessor( } } + private val svgDimensionExtractor = SvgDimensionExtractor() + private suspend fun processSvgImage(uri: Uri, mimeType: String): MediaUploadInfo { Timber.d("Processing SVG image ${uri.path.orEmpty().hash()}") val file = copyToTmpFile(uri) - val (width, height) = extractSvgDimensions(file) + val (width, height) = svgDimensionExtractor.extractDimensions(file) val imageInfo = ImageInfo( width = width, height = height, @@ -246,56 +240,6 @@ class AndroidMediaPreProcessor( ) } - private fun extractSvgDimensions(file: File): Pair { - return file.inputStream().use { inputStream -> - try { - val parser = Xml.newPullParser() - parser.setInput(inputStream, null) - var eventType = parser.eventType - while (eventType != XmlPullParser.END_DOCUMENT) { - if (eventType == XmlPullParser.START_TAG && parser.name.equals("svg", ignoreCase = true)) { - val width = parser.getAttributeValue(null, "width") - val height = parser.getAttributeValue(null, "height") - val viewBox = parser.getAttributeValue(null, "viewBox") - - val parsedWidth = width?.let { parseSvgLength(it) } - val parsedHeight = height?.let { parseSvgLength(it) } - - if (parsedWidth != null && parsedHeight != null) { - return parsedWidth to parsedHeight - } - - if (viewBox != null) { - val parts = viewBox.trim().split("\\s+".toRegex()).map { it.toFloatOrNull() } - if (parts.size == 4 && parts[2] != null && parts[3] != null) { - val vbWidth = parts[2]!!.toLong().coerceAtLeast(1) - val vbHeight = parts[3]!!.toLong().coerceAtLeast(1) - return vbWidth to vbHeight - } - } - - return (parsedWidth ?: SVG_DEFAULT_WIDTH) to (parsedHeight ?: SVG_DEFAULT_HEIGHT) - } - eventType = parser.next() - } - SVG_DEFAULT_WIDTH to SVG_DEFAULT_HEIGHT - } catch (_: Exception) { - SVG_DEFAULT_WIDTH to SVG_DEFAULT_HEIGHT - } - } - } - - private fun parseSvgLength(value: String): Long? { - val trimmed = value.trim() - if (trimmed.isEmpty()) return null - return try { - val numericPart = trimmed.replace(Regex("[^\\d.]"), "") - if (numericPart.isEmpty()) null else numericPart.toFloat().toLong().coerceAtLeast(1) - } catch (_: NumberFormatException) { - null - } - } - private suspend fun processVideo(uri: Uri, mimeType: String?, videoCompressionPreset: VideoCompressionPreset): MediaUploadInfo { Timber.d("Processing video ${uri.path.orEmpty().hash()}") val resultFile = runCatchingExceptions { diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt new file mode 100644 index 00000000000..5ef756a9040 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import android.util.Xml +import org.xmlpull.v1.XmlPullParser +import java.io.File + +class SvgDimensionExtractor( + private val defaultWidth: Long = 640L, + private val defaultHeight: Long = 480L, +) { + fun extractDimensions(file: File): Pair { + return file.inputStream().use { inputStream -> + try { + val parser = Xml.newPullParser() + parser.setInput(inputStream, null) + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG && parser.name.equals("svg", ignoreCase = true)) { + val width = parser.getAttributeValue(null, "width") + val height = parser.getAttributeValue(null, "height") + val viewBox = parser.getAttributeValue(null, "viewBox") + + val parsedWidth = width?.let { parseLength(it) } + val parsedHeight = height?.let { parseLength(it) } + + if (parsedWidth != null && parsedHeight != null) { + return parsedWidth to parsedHeight + } + + if (viewBox != null) { + val parts = viewBox.trim().split("\\s+".toRegex()).map { it.toFloatOrNull() } + if (parts.size == 4 && parts[2] != null && parts[3] != null) { + val vbWidth = parts[2]!!.toLong().coerceAtLeast(1) + val vbHeight = parts[3]!!.toLong().coerceAtLeast(1) + return vbWidth to vbHeight + } + } + + return (parsedWidth ?: defaultWidth) to (parsedHeight ?: defaultHeight) + } + eventType = parser.next() + } + defaultWidth to defaultHeight + } catch (_: Exception) { + defaultWidth to defaultHeight + } + } + } + + private fun parseLength(value: String): Long? { + val trimmed = value.trim() + if (trimmed.isEmpty()) return null + return try { + val numericPart = trimmed.replace(Regex("[^\\d.]"), "") + if (numericPart.isEmpty()) null else numericPart.toFloat().toLong().coerceAtLeast(1) + } catch (_: NumberFormatException) { + null + } + } +} From 4fd0be1fe5020ade909ecdea666058999509c697 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:46:42 +0000 Subject: [PATCH 4/4] Review --- .../mediaupload/impl/AndroidMediaPreProcessor.kt | 6 +++--- .../mediaupload/impl/SvgDimensionExtractor.kt | 15 +++++++-------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt index 406f4e89bff..b374e530f4d 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt @@ -223,10 +223,10 @@ class AndroidMediaPreProcessor( private suspend fun processSvgImage(uri: Uri, mimeType: String): MediaUploadInfo { Timber.d("Processing SVG image ${uri.path.orEmpty().hash()}") val file = copyToTmpFile(uri) - val (width, height) = svgDimensionExtractor.extractDimensions(file) + val size = svgDimensionExtractor.extractDimensions(file) val imageInfo = ImageInfo( - width = width, - height = height, + width = size.width.toLong(), + height = size.height.toLong(), mimetype = mimeType, size = file.length(), thumbnailInfo = null, diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt index 5ef756a9040..f32b596df19 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. + * 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. @@ -16,7 +15,7 @@ class SvgDimensionExtractor( private val defaultWidth: Long = 640L, private val defaultHeight: Long = 480L, ) { - fun extractDimensions(file: File): Pair { + fun extractDimensions(file: File): android.util.Size { return file.inputStream().use { inputStream -> try { val parser = Xml.newPullParser() @@ -32,7 +31,7 @@ class SvgDimensionExtractor( val parsedHeight = height?.let { parseLength(it) } if (parsedWidth != null && parsedHeight != null) { - return parsedWidth to parsedHeight + return android.util.Size(parsedWidth.toInt(), parsedHeight.toInt()) } if (viewBox != null) { @@ -40,17 +39,17 @@ class SvgDimensionExtractor( if (parts.size == 4 && parts[2] != null && parts[3] != null) { val vbWidth = parts[2]!!.toLong().coerceAtLeast(1) val vbHeight = parts[3]!!.toLong().coerceAtLeast(1) - return vbWidth to vbHeight + return android.util.Size(vbWidth.toInt(), vbHeight.toInt()) } } - return (parsedWidth ?: defaultWidth) to (parsedHeight ?: defaultHeight) + return android.util.Size((parsedWidth ?: defaultWidth).toInt(), (parsedHeight ?: defaultHeight).toInt()) } eventType = parser.next() } - defaultWidth to defaultHeight + android.util.Size(defaultWidth.toInt(), defaultHeight.toInt()) } catch (_: Exception) { - defaultWidth to defaultHeight + android.util.Size(defaultWidth.toInt(), defaultHeight.toInt()) } } }