mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
Add full SVG support
This commit is contained in:
@@ -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_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_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||||
coil_gif = { module = "io.coil-kt.coil3:coil-gif", 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" }
|
coil_test = { module = "io.coil-kt.coil3:coil-test", version.ref = "coil" }
|
||||||
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
|
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
|
||||||
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
|
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ object MimeTypes {
|
|||||||
const val Jpeg = "image/jpeg"
|
const val Jpeg = "image/jpeg"
|
||||||
const val Gif = "image/gif"
|
const val Gif = "image/gif"
|
||||||
const val WebP = "image/webp"
|
const val WebP = "image/webp"
|
||||||
|
const val Svg = "image/svg+xml"
|
||||||
|
|
||||||
const val Videos = "video/*"
|
const val Videos = "video/*"
|
||||||
const val Mp4 = "video/mp4"
|
const val Mp4 = "video/mp4"
|
||||||
|
|||||||
+3
@@ -22,3 +22,6 @@ sealed class ClientException(message: String, val details: String?, cause: Throw
|
|||||||
fun ClientException.isNetworkError(): Boolean {
|
fun ClientException.isNetworkError(): Boolean {
|
||||||
return this is ClientException.Generic && message?.contains("error sending request for url", ignoreCase = true) == true
|
return this is ClientException.Generic && message?.contains("error sending request for url", ignoreCase = true) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Throwable.isNetworkError(): Boolean =
|
||||||
|
(this as? ClientException)?.isNetworkError() == true
|
||||||
|
|||||||
+4
-2
@@ -9,11 +9,13 @@
|
|||||||
package io.element.android.libraries.matrix.impl.media
|
package io.element.android.libraries.matrix.impl.media
|
||||||
|
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
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.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
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.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import io.element.android.libraries.matrix.impl.exception.mapClientException
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.matrix.rustcomponents.sdk.Client
|
import org.matrix.rustcomponents.sdk.Client
|
||||||
import org.matrix.rustcomponents.sdk.use
|
import org.matrix.rustcomponents.sdk.use
|
||||||
@@ -37,7 +39,7 @@ class RustMediaLoader(
|
|||||||
source.toRustMediaSource().use { source ->
|
source.toRustMediaSource().use { source ->
|
||||||
innerClient.getMediaContent(source)
|
innerClient.getMediaContent(source)
|
||||||
}
|
}
|
||||||
}
|
}.mapFailure { it.mapClientException() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadMediaThumbnail(
|
override suspend fun loadMediaThumbnail(
|
||||||
@@ -54,7 +56,7 @@ class RustMediaLoader(
|
|||||||
height = height.toULong()
|
height = height.toULong()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}.mapFailure { it.mapClientException() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun downloadMediaFile(
|
override suspend fun downloadMediaFile(
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ dependencies {
|
|||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.gif)
|
implementation(libs.coil.gif)
|
||||||
|
implementation(libs.coil.svg)
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation(libs.coil.network.okhttp)
|
||||||
|
|
||||||
testCommonDependencies(libs, true)
|
testCommonDependencies(libs, true)
|
||||||
|
|||||||
+16
-3
@@ -13,6 +13,7 @@ import coil3.decode.ImageSource
|
|||||||
import coil3.fetch.FetchResult
|
import coil3.fetch.FetchResult
|
||||||
import coil3.fetch.Fetcher
|
import coil3.fetch.Fetcher
|
||||||
import coil3.fetch.SourceFetchResult
|
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.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.media.toFile
|
import io.element.android.libraries.matrix.api.media.toFile
|
||||||
@@ -34,7 +35,19 @@ internal class CoilMediaFetcher(
|
|||||||
}
|
}
|
||||||
return when (val kind = mediaData.kind) {
|
return when (val kind = mediaData.kind) {
|
||||||
is MediaRequestData.Kind.Content -> fetchContent(mediaSource)
|
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)
|
is MediaRequestData.Kind.File -> fetchFile(mediaSource, kind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +87,7 @@ internal class CoilMediaFetcher(
|
|||||||
}.getOrNull()
|
}.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(
|
return mediaLoader.loadMediaThumbnail(
|
||||||
source = mediaSource,
|
source = mediaSource,
|
||||||
width = kind.width,
|
width = kind.width,
|
||||||
@@ -83,7 +96,7 @@ internal class CoilMediaFetcher(
|
|||||||
byteArray.asSourceResult()
|
byteArray.asSourceResult()
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Timber.e(it)
|
Timber.e(it)
|
||||||
}.getOrNull()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ByteArray.asSourceResult(): SourceFetchResult {
|
private fun ByteArray.asSourceResult(): SourceFetchResult {
|
||||||
|
|||||||
+4
@@ -14,6 +14,7 @@ import coil3.ImageLoader
|
|||||||
import coil3.gif.AnimatedImageDecoder
|
import coil3.gif.AnimatedImageDecoder
|
||||||
import coil3.gif.GifDecoder
|
import coil3.gif.GifDecoder
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import coil3.svg.SvgDecoder
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||||
@@ -41,6 +42,7 @@ class DefaultImageLoaderFactory(
|
|||||||
return ImageLoader.Builder(context)
|
return ImageLoader.Builder(context)
|
||||||
.components {
|
.components {
|
||||||
add(okHttpNetworkFetcherFactory)
|
add(okHttpNetworkFetcherFactory)
|
||||||
|
add(SvgDecoder.Factory())
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
@@ -49,6 +51,8 @@ class DefaultImageLoaderFactory(
|
|||||||
return ImageLoader.Builder(context)
|
return ImageLoader.Builder(context)
|
||||||
.components {
|
.components {
|
||||||
add(okHttpNetworkFetcherFactory)
|
add(okHttpNetworkFetcherFactory)
|
||||||
|
// Add svg support
|
||||||
|
add(SvgDecoder.Factory())
|
||||||
// Add gif support
|
// Add gif support
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
add(AnimatedImageDecoder.Factory())
|
add(AnimatedImageDecoder.Factory())
|
||||||
|
|||||||
+81
-1
@@ -12,6 +12,7 @@ import android.content.Context
|
|||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.util.Xml
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
@@ -42,6 +43,7 @@ import kotlinx.coroutines.flow.filterIsInstance
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
@@ -67,7 +69,10 @@ class AndroidMediaPreProcessor(
|
|||||||
*/
|
*/
|
||||||
private const val IMAGE_SCALE_REF_SIZE = 640
|
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
|
private val contentResolver = context.contentResolver
|
||||||
@@ -156,6 +161,11 @@ class AndroidMediaPreProcessor(
|
|||||||
|
|
||||||
private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo {
|
private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo {
|
||||||
Timber.d("Processing image ${uri.path.orEmpty().hash()}")
|
Timber.d("Processing image ${uri.path.orEmpty().hash()}")
|
||||||
|
|
||||||
|
if (mimeType == MimeTypes.Svg) {
|
||||||
|
return processSvgImage(uri, mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun processImageWithCompression(): MediaUploadInfo {
|
suspend fun processImageWithCompression(): MediaUploadInfo {
|
||||||
// Read the orientation metadata from its own stream. Trying to reuse this stream for compression will fail.
|
// Read the orientation metadata from its own stream. Trying to reuse this stream for compression will fail.
|
||||||
val orientation = contentResolver.openInputStream(uri).use { input ->
|
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<Long, Long> {
|
||||||
|
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 {
|
private suspend fun processVideo(uri: Uri, mimeType: String?, videoCompressionPreset: VideoCompressionPreset): MediaUploadInfo {
|
||||||
Timber.d("Processing video ${uri.path.orEmpty().hash()}")
|
Timber.d("Processing video ${uri.path.orEmpty().hash()}")
|
||||||
val resultFile = runCatchingExceptions {
|
val resultFile = runCatchingExceptions {
|
||||||
|
|||||||
Binary file not shown.
+48
@@ -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
|
@Test
|
||||||
fun `test processing audio`() = runTest {
|
fun `test processing audio`() = runTest {
|
||||||
val mediaUploadInfo = process(
|
val mediaUploadInfo = process(
|
||||||
|
|||||||
+11
@@ -83,3 +83,14 @@ val assetAnimatedGif = Asset(
|
|||||||
width = 800,
|
width = 800,
|
||||||
height = 600,
|
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,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user