Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,7 +39,7 @@ class RustMediaLoader(
source.toRustMediaSource().use { source ->
innerClient.getMediaContent(source)
}
}
}.mapFailure { it.mapClientException() }
}

override suspend fun loadMediaThumbnail(
Expand All @@ -54,7 +56,7 @@ class RustMediaLoader(
height = height.toULong()
)
}
}
}.mapFailure { it.mapClientException() }
}

override suspend fun downloadMediaFile(
Expand Down
1 change: 1 addition & 0 deletions libraries/matrixmedia/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
)
}
Comment thread
bxdxnn marked this conversation as resolved.
is MediaRequestData.Kind.File -> fetchFile(mediaSource, kind)
}
}
Expand Down Expand Up @@ -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,
Expand All @@ -83,7 +95,7 @@ internal class CoilMediaFetcher(
byteArray.asSourceResult()
}.onFailure {
Timber.e(it)
}.getOrNull()
}
}

private fun ByteArray.asSourceResult(): SourceFetchResult {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Comment thread
bxdxnn marked this conversation as resolved.
}
mimeType == MimeTypes.Svg -> processSvgImage(uri, mimeType)
mimeType.isMimeTypeImage() -> {
val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes
processImage(uri, mimeType, shouldBeCompressed)
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
3 changes: 3 additions & 0 deletions libraries/mediaupload/impl/src/test/assets/image.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Loading