diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/AvifAnimatedDecoderFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/AvifAnimatedDecoderFactory.kt
new file mode 100644
index 0000000000..9e74681e1b
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/AvifAnimatedDecoderFactory.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.images
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+import coil3.ImageLoader
+import coil3.decode.Decoder
+import coil3.fetch.SourceFetchResult
+import coil3.gif.AnimatedImageDecoder
+import coil3.request.Options
+import okio.BufferedSource
+import okio.ByteString.Companion.encodeUtf8
+
+/**
+ * Coil [Decoder.Factory] for AVIF (still and animated).
+ *
+ * Coil's bundled [AnimatedImageDecoder.Factory] only accepts HEIF brands (msf1/hevc/hevx)
+ * at offset 8, so it misses AVIF, whose major brand at offset 8 is "avis" (animated AVIF
+ * Image Sequence), "avif" (still AVIF), or "avo1" (older single-image AVIF). Without this
+ * factory, animated AVIFs fall through to Coil's default static decoder and render as
+ * the first frame only.
+ *
+ * On API 31+, the platform [android.graphics.ImageDecoder] produces an
+ * [android.graphics.drawable.AnimatedImageDrawable] for animated AVIF. We delegate the
+ * actual decode to Coil's [AnimatedImageDecoder] — only the brand sniff is custom.
+ *
+ * Below API 31 the platform decoder cannot handle AVIF at all, so we decline and let
+ * other decoders try (they will also fail, and Coil falls through to its error slot,
+ * which is the documented behavior for unsupported formats on older API levels).
+ */
+class AvifAnimatedDecoderFactory : Decoder.Factory {
+ override fun create(
+ result: SourceFetchResult,
+ options: Options,
+ imageLoader: ImageLoader,
+ ): Decoder? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null // AVIF requires API 31+
+ if (!isAvif(result.source.source())) return null
+ return createAnimatedImageDecoder(result, options)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.P)
+ private fun createAnimatedImageDecoder(
+ result: SourceFetchResult,
+ options: Options,
+ ): Decoder = AnimatedImageDecoder(result.source, options)
+
+ private fun isAvif(source: BufferedSource): Boolean =
+ source.rangeEquals(4, FTYP) &&
+ (
+ source.rangeEquals(8, AVIS) ||
+ source.rangeEquals(8, AVIF) ||
+ source.rangeEquals(8, AVO1)
+ )
+
+ companion object {
+ private val FTYP = "ftyp".encodeUtf8()
+ private val AVIS = "avis".encodeUtf8() // animated AVIF Image Sequence
+ private val AVIF = "avif".encodeUtf8() // still AVIF
+ private val AVO1 = "avo1".encodeUtf8() // older single-image AVIF brand
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt
index da4d6829c5..bf00b0eaa8 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt
@@ -78,6 +78,9 @@ class ImageLoaderSetup {
.precision(Precision.INEXACT)
.logger(debugLogger)
.components {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ add(AvifAnimatedDecoderFactory()) // handle animated AVIF that Coil's AnimatedImageDecoder misses
+ }
add(gifFactory)
add(svgFactory)
add(VideoFrameDecoder.Factory())
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt
index 40d5c805e9..431bb0a354 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt
@@ -27,6 +27,7 @@ import androidx.core.net.toUri
import androidx.media3.common.MimeTypes
import com.davotoula.lightcompressor.video.GifToMp4Converter
import com.vitorpamplona.amethyst.service.checkNotInMainThread
+import com.vitorpamplona.amethyst.service.uploads.isAvif
import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
import com.vitorpamplona.quartz.utils.Log
import id.zelory.compressor.Compressor
@@ -78,7 +79,8 @@ class MediaCompressor {
contentType?.startsWith("image", ignoreCase = true) == true &&
!contentType.contains("gif") &&
- !contentType.contains("svg") -> {
+ !contentType.contains("svg") &&
+ !isAvif(contentType) -> {
compressImage(uri, contentType, applicationContext, mediaQuality)
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaMimeTypes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaMimeTypes.kt
new file mode 100644
index 0000000000..7de127608b
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaMimeTypes.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.uploads
+
+// RFC 9081 defines image/avif for both still and animated AVIF.
+// image/avif-sequence is NOT IANA-registered and is intentionally not handled.
+const val AVIF_MIME = "image/avif"
+const val AVIF_EXTENSION = "avif"
+
+fun isAvif(contentType: String?): Boolean = contentType?.equals(AVIF_MIME, ignoreCase = true) == true
+
+/**
+ * AVIF-only fallback for upload filename extensions.
+ *
+ * Returns `"avif"` when [contentType] is `image/avif`, `null` for everything else.
+ * Callers chain this after `MimeTypeMap.getSingleton().getExtensionFromMimeType(...)`
+ * because older Android versions of MimeTypeMap don't know AVIF, and some upload
+ * servers reject extension-less filenames. This helper is intentionally not a
+ * general MIME-to-extension utility — handle non-AVIF types via MimeTypeMap.
+ */
+fun extensionFromMimeType(contentType: String?): String? =
+ when {
+ isAvif(contentType) -> AVIF_EXTENSION
+ else -> null
+ }
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt
index 9a03633940..45ebd6ac1d 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt
@@ -32,6 +32,7 @@ import androidx.exifinterface.media.ExifInterface
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import java.io.File
+import java.io.InputStream
import java.nio.ByteBuffer
data class StrippingResult(
@@ -39,6 +40,20 @@ data class StrippingResult(
val stripped: Boolean,
)
+/**
+ * Raised when an AVIF file's EXIF metadata could not be confirmed safe.
+ *
+ * AVIF uses an HEIF/ISOBMFF container that `ExifInterface` cannot reliably rewrite,
+ * so we choose between (a) verifying the file is already clean (no sensitive tags)
+ * and passing it through unchanged, or (b) failing the upload. This exception
+ * signals case (b) and must bubble up past the generic image-stripping try/catch
+ * — callers should surface it to the user as an upload error.
+ */
+class AvifMetadataNotVerifiableException(
+ message: String,
+ cause: Throwable? = null,
+) : RuntimeException(message, cause)
+
object MetadataStripper {
private const val DEFAULT_REMUX_BUFFER_SIZE = 8 * 1024 * 1024
@@ -181,10 +196,37 @@ object MetadataStripper {
fun stripImageMetadata(
uri: Uri,
context: Context,
+ ): StrippingResult =
+ stripImageMetadata(uri, context) { stream ->
+ val exif = ExifInterface(stream)
+ exif::getAttribute
+ }
+
+ /**
+ * Internal overload used by production code (via the public single-arity wrapper) and by
+ * unit tests to substitute a fake [openAvifExif] without needing bytecode instrumentation
+ * of [ExifInterface].
+ *
+ * [openAvifExif] receives an already-opened input stream and must return a function that
+ * maps an EXIF tag name to its string value (or null if absent). The stream is consumed
+ * inside the factory call. For non-AVIF images this factory is never invoked.
+ */
+ internal fun stripImageMetadata(
+ uri: Uri,
+ context: Context,
+ openAvifExif: (InputStream) -> (String) -> String?,
): StrippingResult {
+ val mimeType = context.contentResolver.getType(uri) ?: ""
+
+ // AVIF takes a different path: ExifInterface cannot reliably rewrite AVIF
+ // containers, so we inspect-only. Clean AVIFs pass through unchanged;
+ // AVIFs with sensitive tags or unreadable EXIF throw fail-closed.
+ if (isAvif(mimeType)) {
+ return inspectAvifMetadata(uri, context, openAvifExif)
+ }
+
var tempFile: File? = null
return try {
- val mimeType = context.contentResolver.getType(uri) ?: ""
val extension =
when {
mimeType.endsWith("jpeg", ignoreCase = true) ||
@@ -230,6 +272,39 @@ object MetadataStripper {
}
}
+ private fun inspectAvifMetadata(
+ uri: Uri,
+ context: Context,
+ openAvifExif: (InputStream) -> (String) -> String?,
+ ): StrippingResult {
+ val stream =
+ context.contentResolver.openInputStream(uri)
+ ?: throw AvifMetadataNotVerifiableException("Cannot open AVIF input stream to inspect metadata")
+
+ try {
+ val getAttribute =
+ try {
+ stream.use { openAvifExif(it) }
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ throw AvifMetadataNotVerifiableException("Could not parse AVIF EXIF for inspection: ${e.message}", e)
+ }
+
+ val present = SENSITIVE_EXIF_TAGS.firstOrNull { tag -> getAttribute(tag) != null }
+ if (present != null) {
+ throw AvifMetadataNotVerifiableException(
+ "AVIF contains sensitive EXIF tag '$present' that cannot be safely stripped; upload refused",
+ )
+ }
+
+ Log.d("MetadataStripper", "AVIF EXIF inspection: no sensitive tags found, passing through")
+ return StrippingResult(uri, true)
+ } catch (e: Exception) {
+ if (e is CancellationException || e is AvifMetadataNotVerifiableException) throw e
+ throw AvifMetadataNotVerifiableException("Unexpected error inspecting AVIF metadata: ${e.message}", e)
+ }
+ }
+
fun stripVideoMetadata(
uri: Uri,
context: Context,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt
index b36cba1df5..12e44faade 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt
@@ -51,6 +51,8 @@ class MultiOrchestrator(
fun hasGif() = list.any { it.media.isGif() }
+ fun hasCompressible() = list.any { it.media.isCompressible() }
+
fun hasNonMedia() = list.any { it.media.isNotMedia() }
suspend fun upload(
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/PreviewMetadataCalculator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/PreviewMetadataCalculator.kt
index c85e13ade6..42ae8bbeb4 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/PreviewMetadataCalculator.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/PreviewMetadataCalculator.kt
@@ -23,14 +23,18 @@ package com.vitorpamplona.amethyst.service.uploads
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
+import android.graphics.ImageDecoder
import android.media.MediaMetadataRetriever
import android.net.Uri
+import android.os.Build
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
import com.vitorpamplona.amethyst.commons.thumbhash.toThumbhash
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
import com.vitorpamplona.amethyst.service.images.ThumbhashWrapper
+import com.vitorpamplona.amethyst.service.uploads.isAvif
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.Log
+import java.nio.ByteBuffer
/**
* Result of precomputing placeholder metadata during an upload. The bitmap or video thumbnail is
@@ -67,6 +71,11 @@ object PreviewMetadataCalculator {
dimPrecomputed: DimensionTag?,
): PreviewHashes =
when {
+ isAvif(mimeType) -> {
+ val bitmap = decodeAvifBytes(data)
+ processImage(bitmap, dimPrecomputed)
+ }
+
isImage(mimeType) -> {
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, createBitmapOptions())
processImage(bitmap, dimPrecomputed)
@@ -97,6 +106,11 @@ object PreviewMetadataCalculator {
return try {
when {
+ isAvif(mimeType) -> {
+ val bitmap = decodeAvifFromUri(context, uri)
+ processImage(bitmap, dimPrecomputed)
+ }
+
isImage(mimeType) -> {
context.contentResolver.openInputStream(uri)?.use { stream ->
val bitmap = BitmapFactory.decodeStream(stream, null, createBitmapOptions())
@@ -168,4 +182,49 @@ object PreviewMetadataCalculator {
val finalDim = if (dim?.hasSize() == true) dim else hashes.dim
return hashes.copy(dim = finalDim)
}
+
+ /**
+ * Decode AVIF bytes into a bitmap. Uses [ImageDecoder.ALLOCATOR_SOFTWARE] because hardware
+ * bitmaps reject `getPixels()`, which is required for blurhash computation.
+ */
+ private fun decodeAvifBytes(data: ByteArray): Bitmap? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
+ // ImageDecoder is API 28+; AVIF is API 31+ regardless.
+ // Older devices: skip preview metadata for AVIF.
+ Log.d(LOG_TAG, "AVIF preview metadata skipped on API < 28")
+ return null
+ }
+ if (data.isEmpty()) return null
+ return try {
+ val source = ImageDecoder.createSource(ByteBuffer.wrap(data))
+ ImageDecoder.decodeBitmap(source) { decoder, _, _ ->
+ decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
+ decoder.isMutableRequired = false
+ }
+ } catch (e: Exception) {
+ Log.w(LOG_TAG, "AVIF decode failed: ${e.message}", e)
+ null
+ }
+ }
+
+ private fun decodeAvifFromUri(
+ context: Context,
+ uri: Uri,
+ ): Bitmap? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
+ Log.d(LOG_TAG, "AVIF preview metadata skipped on API < 28")
+ return null
+ }
+ return try {
+ val source = ImageDecoder.createSource(context.contentResolver, uri)
+ ImageDecoder.decodeBitmap(source) { decoder, _, _ ->
+ // Same allocator rationale as decodeAvifBytes.
+ decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
+ decoder.isMutableRequired = false
+ }
+ } catch (e: Exception) {
+ Log.w(LOG_TAG, "AVIF decode failed: ${e.message}", e)
+ null
+ }
+ }
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt
index 3df83dd0b7..1ed9ca34c1 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt
@@ -317,7 +317,12 @@ class UploadOrchestrator {
// assume it stripped metadata successfully.
StrippingResult(compressed.uri, true)
} else {
- MetadataStripper.strip(compressed.uri, effectiveMimeType, context.applicationContext)
+ try {
+ MetadataStripper.strip(compressed.uri, effectiveMimeType, context.applicationContext)
+ } catch (e: AvifMetadataNotVerifiableException) {
+ error(R.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName)
+ return null
+ }
}
if (!strippingResult.stripped && !onStrippingFailed()) return null
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt
index 6dce744220..f5a792034d 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
+import com.vitorpamplona.amethyst.service.uploads.extensionFromMimeType
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
@@ -142,7 +143,9 @@ class BlossomUploader {
val fileName = baseFileName ?: RandomInstance.randomChars(16)
val extension =
- contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
+ contentType?.let {
+ MimeTypeMap.getSingleton().getExtensionFromMimeType(it) ?: extensionFromMimeType(it)
+ } ?: ""
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
@@ -241,7 +244,9 @@ class BlossomUploader {
context: Context,
): Boolean {
val extension =
- contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
+ contentType?.let {
+ MimeTypeMap.getSingleton().getExtensionFromMimeType(it) ?: extensionFromMimeType(it)
+ } ?: ""
val apiUrl = serverBaseUrl
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt
index b17e3ba8a2..c52095af2a 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt
@@ -30,6 +30,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
+import com.vitorpamplona.amethyst.service.uploads.AVIF_EXTENSION
+import com.vitorpamplona.amethyst.service.uploads.AVIF_MIME
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
import com.vitorpamplona.amethyst.ui.stringRes
@@ -234,15 +236,17 @@ class Nip96Uploader {
fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://")
- // Android's MimeTypeMap does not know every MIME we upload (notably HLS playlist types).
- // When it returns null we fall back to a small static table so the multipart filename still
- // carries a real extension — otherwise the server gets "name." and echoes it back, which
- // breaks HLS URL rewriting.
- private fun fallbackExtensionForMimeType(mimeType: String): String? =
+ // Android's MimeTypeMap does not know every MIME we upload (notably HLS playlist types
+ // and AVIF on older Android). When it returns null we fall back to a small static table
+ // so the multipart filename still carries a real extension — otherwise the server gets
+ // "name." and echoes it back, which breaks HLS URL rewriting (and rejects extension-less
+ // uploads on some NIP-96 servers).
+ internal fun fallbackExtensionForMimeType(mimeType: String): String? =
when (mimeType.lowercase()) {
"application/vnd.apple.mpegurl", "application/x-mpegurl", "audio/x-mpegurl", "audio/mpegurl" -> "m3u8"
"video/mp2t" -> "ts"
"video/iso.segment", "video/mp4" -> "mp4"
+ AVIF_MIME -> AVIF_EXTENSION
else -> null
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt
index 0de2571397..4982f1ed31 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt
@@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.service.uploads.isAvif
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
@@ -65,6 +66,27 @@ class SelectedMedia(
} ?: true
fun isDocument() = mimeType == "application/pdf"
+
+ /**
+ * Returns true if [MediaCompressor.compress] would actually compress this file when a
+ * non-UNCOMPRESSED quality is selected. AVIF, GIF, SVG, and unknown MIME types pass
+ * through MediaCompressor unchanged — the compression-quality slider has no effect on
+ * them, so the UI hides the slider when no selected files are compressible.
+ *
+ * Keep this in sync with the branching in MediaCompressor.compress() — if either side
+ * drifts, the UI will lie to the user.
+ */
+ fun isCompressible(): Boolean {
+ val mt = mimeType?.lowercase() ?: return false
+ return when {
+ mt.startsWith("video") -> true
+ mt.startsWith("image") ->
+ !mt.contains("gif") &&
+ !mt.contains("svg") &&
+ !isAvif(mt)
+ else -> false
+ }
+ }
}
@Composable
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt
index 85cdb44030..9937d8ef77 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt
@@ -157,4 +157,7 @@ fun MyAsyncImage(
fun isGifUrl(url: String): Boolean =
url.endsWith(".gif", ignoreCase = true) ||
url.contains(".gif?", ignoreCase = true) ||
- url.contains(".gif#", ignoreCase = true)
+ url.contains(".gif#", ignoreCase = true) ||
+ url.endsWith(".avif", ignoreCase = true) ||
+ url.contains(".avif?", ignoreCase = true) ||
+ url.contains(".avif#", ignoreCase = true)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt
index 21aaca8d8a..7c99c26ab1 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt
@@ -697,9 +697,19 @@ fun ShowHash(content: MediaUrlContent) {
fun BaseMediaContent.isGif(): Boolean =
if (this is MediaUrlContent) {
- mimeType == "image/gif" || url.endsWith(".gif", ignoreCase = true) || url.contains(".gif?", ignoreCase = true) || url.contains(".gif#", ignoreCase = true)
+ mimeType == "image/gif" ||
+ mimeType == "image/avif" ||
+ url.endsWith(".gif", ignoreCase = true) ||
+ url.contains(".gif?", ignoreCase = true) ||
+ url.contains(".gif#", ignoreCase = true) ||
+ url.endsWith(".avif", ignoreCase = true) ||
+ url.contains(".avif?", ignoreCase = true) ||
+ url.contains(".avif#", ignoreCase = true)
} else if (this is MediaPreloadedContent) {
- mimeType == "image/gif" || localFile?.name?.endsWith(".gif", ignoreCase = true) == true
+ mimeType == "image/gif" ||
+ mimeType == "image/avif" ||
+ localFile?.name?.endsWith(".gif", ignoreCase = true) == true ||
+ localFile?.name?.endsWith(".avif", ignoreCase = true) == true
} else {
false
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt
index 4f75143dea..64af45dffc 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt
@@ -352,9 +352,7 @@ fun ImageVideoDescription(
)
}
- val firstMedia = uris.first().media
-
- if ((firstMedia.isVideo() == true || firstMedia.isImage() == true) && !convertGifToMp4) {
+ if (uris.hasCompressible() && !convertGifToMp4) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index d530977bde..dec0899393 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -1807,6 +1807,7 @@
Upload anyway
Failed to strip private metadata from media. Upload cancelled.
Upload cancelled
+ Cannot strip metadata from AVIF: %1$s
Edit draft
diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/MediaMimeTypesTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/MediaMimeTypesTest.kt
new file mode 100644
index 0000000000..bbe0a2c08e
--- /dev/null
+++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/MediaMimeTypesTest.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.uploads
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MediaMimeTypesTest {
+ @Test
+ fun `isAvif matches image avif exactly`() {
+ assertTrue(isAvif("image/avif"))
+ }
+
+ @Test
+ fun `isAvif is case insensitive`() {
+ assertTrue(isAvif("IMAGE/AVIF"))
+ assertTrue(isAvif("Image/Avif"))
+ }
+
+ @Test
+ fun `isAvif rejects null`() {
+ assertFalse(isAvif(null))
+ }
+
+ @Test
+ fun `isAvif rejects empty string`() {
+ assertFalse(isAvif(""))
+ }
+
+ @Test
+ fun `isAvif rejects image avif-sequence`() {
+ // image/avif-sequence is intentionally not handled (not IANA-registered).
+ assertFalse(isAvif("image/avif-sequence"))
+ }
+
+ @Test
+ fun `isAvif rejects unrelated image types`() {
+ assertFalse(isAvif("image/jpeg"))
+ assertFalse(isAvif("image/png"))
+ assertFalse(isAvif("image/webp"))
+ assertFalse(isAvif("image/gif"))
+ }
+
+ @Test
+ fun `isAvif rejects substring containment`() {
+ // Defensively guard against future code that might accept partial matches.
+ assertFalse(isAvif("image/avif-sequence"))
+ assertFalse(isAvif("application/x-image-avif"))
+ }
+
+ @Test
+ fun `extensionFromMimeType returns avif for image avif`() {
+ assertEquals("avif", extensionFromMimeType("image/avif"))
+ }
+
+ @Test
+ fun `extensionFromMimeType returns null for unknown types`() {
+ assertNull(extensionFromMimeType("image/jpeg"))
+ assertNull(extensionFromMimeType(null))
+ }
+
+ @Test
+ fun `AVIF_MIME constant value`() {
+ assertEquals("image/avif", AVIF_MIME)
+ }
+
+ @Test
+ fun `AVIF_EXTENSION constant value`() {
+ assertEquals("avif", AVIF_EXTENSION)
+ }
+}
diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripperTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripperTest.kt
new file mode 100644
index 0000000000..4621bc2f04
--- /dev/null
+++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripperTest.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.uploads
+
+import android.content.ContentResolver
+import android.content.Context
+import android.net.Uri
+import androidx.exifinterface.media.ExifInterface
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Test
+import java.io.ByteArrayInputStream
+
+class MetadataStripperTest {
+ private lateinit var context: Context
+ private lateinit var resolver: ContentResolver
+ private lateinit var uri: Uri
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ context = mockk(relaxed = true)
+ resolver = mockk(relaxed = true)
+ uri = mockk(relaxed = true)
+ every { context.contentResolver } returns resolver
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `AVIF with no sensitive tags returns original uri marked stripped`() {
+ // Arrange: AVIF content; tag reader returns null for every tag (no sensitive tags).
+ every { resolver.getType(uri) } returns "image/avif"
+ every { resolver.openInputStream(uri) } returns ByteArrayInputStream(ByteArray(16))
+
+ // Act: inject a pure-lambda tag reader — no ExifInterface instantiation needed.
+ val result =
+ MetadataStripper.stripImageMetadata(uri, context) { _ ->
+ { _: String -> null }
+ }
+
+ // Assert: original URI returned, marked stripped (i.e. "verified clean").
+ assertEquals(uri, result.uri)
+ assertTrue(result.stripped)
+ }
+
+ @Test(expected = AvifMetadataNotVerifiableException::class)
+ fun `AVIF with GPS EXIF throws AvifMetadataNotVerifiableException`() {
+ // Arrange: AVIF content; tag reader reports GPS_LATITUDE present.
+ every { resolver.getType(uri) } returns "image/avif"
+ every { resolver.openInputStream(uri) } returns ByteArrayInputStream(ByteArray(16))
+
+ // Act: should throw before returning a StrippingResult
+ MetadataStripper.stripImageMetadata(uri, context) { _ ->
+ { tag: String ->
+ if (tag == ExifInterface.TAG_GPS_LATITUDE) "40.7128" else null
+ }
+ }
+
+ // Assert: handled by `expected` annotation; reaching here is a failure
+ fail("Expected AvifMetadataNotVerifiableException, but no exception was thrown")
+ }
+
+ @Test(expected = AvifMetadataNotVerifiableException::class)
+ fun `AVIF where ExifInterface fails throws AvifMetadataNotVerifiableException`() {
+ // Arrange: AVIF content; tag reader factory throws on invocation (simulates parse failure).
+ every { resolver.getType(uri) } returns "image/avif"
+ every { resolver.openInputStream(uri) } returns ByteArrayInputStream(ByteArray(16))
+
+ MetadataStripper.stripImageMetadata(uri, context) { _ ->
+ throw RuntimeException("malformed AVIF")
+ }
+ fail("Expected AvifMetadataNotVerifiableException")
+ }
+}
diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/PreviewMetadataCalculatorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/PreviewMetadataCalculatorTest.kt
new file mode 100644
index 0000000000..bb29f2692a
--- /dev/null
+++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/PreviewMetadataCalculatorTest.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.uploads
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PreviewMetadataCalculatorTest {
+ @Test
+ fun `shouldAttempt accepts AVIF`() {
+ assertTrue(PreviewMetadataCalculator.shouldAttempt("image/avif"))
+ }
+
+ @Test
+ fun `shouldAttempt accepts AVIF case-insensitive`() {
+ assertTrue(PreviewMetadataCalculator.shouldAttempt("IMAGE/AVIF"))
+ }
+
+ @Test
+ fun `computeFromBytes for AVIF with empty bytes returns empty PreviewHashes`() {
+ // Below API 28 (Robolectric default), or with an empty payload, the AVIF
+ // branch must produce a PreviewHashes with no decoded data rather than
+ // crashing. The dimPrecomputed value is preserved when available.
+ val result =
+ PreviewMetadataCalculator.computeFromBytes(
+ data = ByteArray(0),
+ mimeType = "image/avif",
+ dimPrecomputed = null,
+ )
+ assertEquals(null, result.blurhash)
+ assertEquals(null, result.thumbhash)
+ assertEquals(null, result.dim)
+ }
+
+ @Test
+ fun `computeFromBytes for AVIF with malformed bytes does not crash`() {
+ // A few random bytes are not a valid AVIF; the decoder should fail
+ // gracefully and return an empty PreviewHashes (no exception escapes).
+ val result =
+ PreviewMetadataCalculator.computeFromBytes(
+ data = byteArrayOf(0x00, 0x01, 0x02, 0x03),
+ mimeType = "image/avif",
+ dimPrecomputed = null,
+ )
+ assertEquals(null, result.blurhash)
+ assertEquals(null, result.thumbhash)
+ }
+}
diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploaderExtensionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploaderExtensionTest.kt
new file mode 100644
index 0000000000..951779323d
--- /dev/null
+++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploaderExtensionTest.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.uploads.blossom
+
+import com.vitorpamplona.amethyst.service.uploads.extensionFromMimeType
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class BlossomUploaderExtensionTest {
+ @Test
+ fun `extensionFromMimeType helper returns avif for image avif`() {
+ // BlossomUploader will use this helper as a fallback when
+ // MimeTypeMap.getExtensionFromMimeType returns null on older Android.
+ // This test pins the dependency BlossomUploader relies on.
+ assertEquals("avif", extensionFromMimeType("image/avif"))
+ }
+
+ @Test
+ fun `extensionFromMimeType helper returns null for non-AVIF`() {
+ // Other types are handled by MimeTypeMap; the helper is AVIF-only by design.
+ assertEquals(null, extensionFromMimeType("image/jpeg"))
+ assertEquals(null, extensionFromMimeType("video/mp4"))
+ }
+}
diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96UploaderExtensionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96UploaderExtensionTest.kt
new file mode 100644
index 0000000000..aa0def93bc
--- /dev/null
+++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96UploaderExtensionTest.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.uploads.nip96
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class Nip96UploaderExtensionTest {
+ private val uploader = Nip96Uploader()
+
+ @Test
+ fun `fallback returns avif for image avif`() {
+ assertEquals("avif", uploader.fallbackExtensionForMimeType("image/avif"))
+ }
+
+ @Test
+ fun `fallback returns avif for image avif case insensitive`() {
+ assertEquals("avif", uploader.fallbackExtensionForMimeType("IMAGE/AVIF"))
+ }
+
+ @Test
+ fun `fallback returns existing HLS mappings`() {
+ assertEquals("m3u8", uploader.fallbackExtensionForMimeType("application/vnd.apple.mpegurl"))
+ assertEquals("ts", uploader.fallbackExtensionForMimeType("video/mp2t"))
+ assertEquals("mp4", uploader.fallbackExtensionForMimeType("video/mp4"))
+ }
+
+ @Test
+ fun `fallback returns null for unknown types`() {
+ assertNull(uploader.fallbackExtensionForMimeType("application/x-bogus"))
+ }
+}
diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaTest.kt
new file mode 100644
index 0000000000..7d8d421590
--- /dev/null
+++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaTest.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.actions.uploads
+
+import android.net.Uri
+import io.mockk.mockk
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class SelectedMediaTest {
+ private fun media(mimeType: String?): SelectedMedia = SelectedMedia(mockk(relaxed = true), mimeType)
+
+ @Test
+ fun `isCompressible true for video`() {
+ assertTrue(media("video/mp4").isCompressible())
+ assertTrue(media("VIDEO/MP4").isCompressible())
+ }
+
+ @Test
+ fun `isCompressible true for jpeg and png`() {
+ assertTrue(media("image/jpeg").isCompressible())
+ assertTrue(media("image/png").isCompressible())
+ }
+
+ @Test
+ fun `isCompressible false for AVIF`() {
+ assertFalse(media("image/avif").isCompressible())
+ assertFalse(media("IMAGE/AVIF").isCompressible())
+ }
+
+ @Test
+ fun `isCompressible false for GIF and SVG`() {
+ assertFalse(media("image/gif").isCompressible())
+ assertFalse(media("image/svg+xml").isCompressible())
+ }
+
+ @Test
+ fun `isCompressible false for null mime`() {
+ assertFalse(media(null).isCompressible())
+ }
+
+ @Test
+ fun `isCompressible false for non-media types`() {
+ assertFalse(media("application/pdf").isCompressible())
+ assertFalse(media("audio/mpeg").isCompressible())
+ }
+}
diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt
index 122fe1d2e8..c8c4ad9989 100644
--- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt
+++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt
@@ -178,4 +178,50 @@ class MediaCompressorTest {
assertEquals("image/jpeg", result.contentType)
assertEquals(null, result.size)
}
+
+ @Test
+ fun `AVIF media should not be re-encoded as JPEG`() =
+ runTest {
+ // setup
+ val mockContext = mockk(relaxed = true)
+ val mockUri = mockk()
+
+ mockkObject(MediaCompressorFileUtils)
+ every { MediaCompressorFileUtils.from(any(), any()) } returns File("test")
+
+ // Execute with AVIF MIME
+ val result =
+ MediaCompressor().compress(
+ uri = mockUri,
+ contentType = "image/avif",
+ applicationContext = mockContext,
+ mediaQuality = CompressorQuality.MEDIUM,
+ )
+
+ // Verify: original URI, original content type, no JPEG conversion, no size set
+ assertEquals(mockUri, result.uri)
+ assertEquals("image/avif", result.contentType)
+ assertEquals(null, result.size)
+ coVerify(exactly = 0) { Compressor.compress(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `AVIF MIME is case insensitive`() =
+ runTest {
+ val mockContext = mockk(relaxed = true)
+ val mockUri = mockk()
+ mockkObject(MediaCompressorFileUtils)
+ every { MediaCompressorFileUtils.from(any(), any()) } returns File("test")
+
+ val result =
+ MediaCompressor().compress(
+ uri = mockUri,
+ contentType = "IMAGE/AVIF",
+ applicationContext = mockContext,
+ mediaQuality = CompressorQuality.MEDIUM,
+ )
+
+ assertEquals(mockUri, result.uri)
+ coVerify(exactly = 0) { Compressor.compress(any(), any(), any(), any()) }
+ }
}