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()) } + } }