Merge pull request #7020 from bxdxnn/misc/svg-support

Add full SVG support
This commit is contained in:
Benoit Marty
2026-07-08 10:57:12 +02:00
committed by GitHub
11 changed files with 181 additions and 10 deletions
+1
View File
@@ -192,6 +192,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" }
@@ -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
@@ -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(
@@ -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)
@@ -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<FetchResult> {
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 {
@@ -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())
@@ -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<MediaUploadInfo> = 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 {
@@ -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
}
}
}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:97a22915f50ba62605a8e3f6ba8e8099c1d601d2aa929c2879197b41531ce4dc
size 210
@@ -399,6 +399,54 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
)
}
@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(
@@ -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,
)