Comprehensive AVIF support (#837)

feat(ui): hide compression slider for non-compressible files (AVIF, GIF, SVG)
feat(images): custom Coil decoder for animated AVIF
feat(ui): include AVIF in animation-aware MIME predicates
fix(uploads): AVIF extension fallback in BlossomUploader
fix(uploads): AVIF extension fallback for NIP-96 multipart filename
feat(uploads): decode AVIF previews with ImageDecoder for blurhash/thumbhash
feat(uploads): fail-closed AVIF metadata inspection in MetadataStripper
fix(uploads): preserve AVIF bytes through MediaCompressor
feat(uploads): add MediaMimeTypes helper for AVIF detection
This commit is contained in:
davotoula
2026-05-27 16:11:20 +02:00
parent adc0d36407
commit 57724cee8c
22 changed files with 796 additions and 16 deletions
@@ -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
}
}
@@ -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())
@@ -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)
}
@@ -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
}
@@ -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,
@@ -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(
@@ -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
}
}
}
@@ -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
@@ -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
@@ -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
}
@@ -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
@@ -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)
@@ -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
}
@@ -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 =
+1
View File
@@ -1807,6 +1807,7 @@
<string name="metadata_strip_failed_upload">Upload anyway</string>
<string name="metadata_strip_failed_upload_cancelled">Failed to strip private metadata from media. Upload cancelled.</string>
<string name="upload_cancelled">Upload cancelled</string>
<string name="avif_metadata_strip_failed">Cannot strip metadata from AVIF: %1$s</string>
<string name="edit_draft">Edit draft</string>
@@ -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)
}
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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"))
}
}
@@ -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"))
}
}
@@ -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<Uri>(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())
}
}
@@ -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<Context>(relaxed = true)
val mockUri = mockk<Uri>()
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<Context>(relaxed = true)
val mockUri = mockk<Uri>()
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()) }
}
}