mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
+81
@@ -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())
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
+76
-1
@@ -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(
|
||||
|
||||
+59
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
+7
-2
@@ -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
|
||||
|
||||
|
||||
+9
-5
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+22
@@ -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)
|
||||
|
||||
+12
-2
@@ -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
|
||||
}
|
||||
|
||||
+1
-3
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+92
@@ -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)
|
||||
}
|
||||
}
|
||||
+103
@@ -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")
|
||||
}
|
||||
}
|
||||
+67
@@ -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)
|
||||
}
|
||||
}
|
||||
+42
@@ -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"))
|
||||
}
|
||||
}
|
||||
+51
@@ -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"))
|
||||
}
|
||||
}
|
||||
+66
@@ -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())
|
||||
}
|
||||
}
|
||||
+46
@@ -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()) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user