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/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..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 @@ -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,18 @@ 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 -> { + fetchThumbnail(mediaSource, kind).fold( + onSuccess = { it }, + onFailure = { error -> + if (error.isNetworkError()) { + null + } else { + fetchContent(mediaSource) + } + } + ) + } is MediaRequestData.Kind.File -> fetchFile(mediaSource, kind) } } @@ -74,7 +86,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 +95,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..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 @@ -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 @@ -49,6 +50,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 2ab1cd06199..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 @@ -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,7 @@ 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 == MimeTypes.Svg -> processSvgImage(uri, mimeType) mimeType.isMimeTypeImage() -> { val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes processImage(uri, mimeType, shouldBeCompressed) @@ -160,6 +157,7 @@ class AndroidMediaPreProcessor( private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo { Timber.d("Processing image ${uri.path.orEmpty().hash()}") + 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 -> @@ -220,6 +218,28 @@ 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 size = svgDimensionExtractor.extractDimensions(file) + val imageInfo = ImageInfo( + width = size.width.toLong(), + height = size.height.toLong(), + mimetype = mimeType, + size = file.length(), + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ) + return MediaUploadInfo.Image( + file = file, + imageInfo = imageInfo, + thumbnailFile = 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..f32b596df19 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/SvgDimensionExtractor.kt @@ -0,0 +1,67 @@ +/* + * 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.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): android.util.Size { + 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 android.util.Size(parsedWidth.toInt(), parsedHeight.toInt()) + } + + 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 android.util.Size(vbWidth.toInt(), vbHeight.toInt()) + } + } + + return android.util.Size((parsedWidth ?: defaultWidth).toInt(), (parsedHeight ?: defaultHeight).toInt()) + } + eventType = parser.next() + } + android.util.Size(defaultWidth.toInt(), defaultHeight.toInt()) + } catch (_: Exception) { + android.util.Size(defaultWidth.toInt(), defaultHeight.toInt()) + } + } + } + + 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 + } + } +} 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, +)