test(amethyst): instrumented coverage for AVIF upload + decode

Adds 4 instrumented test files + 3 tiny pre-committed AVIF fixtures to
catch regressions in the upload pipeline.
This commit is contained in:
davotoula
2026-05-27 16:11:20 +02:00
parent f8b24c645a
commit ef25f8c0e6
8 changed files with 423 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 B

@@ -0,0 +1,59 @@
/*
* 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
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.util.UUID
/**
* Shared helpers for AVIF instrumented tests.
*
* [copyAssetToCache] always uses a UUID-prefixed filename so concurrent or
* back-to-back test runs cannot collide on a shared cache path.
*
* [contentUriFor] wraps a file with FileProvider so `ContentResolver.getType()`
* returns the correct MIME — it returns null for `file://` URIs, which makes
* `MetadataStripper` skip the AVIF branch entirely.
*/
object AvifInstrumentedTestSupport {
val appContext: Context get() = InstrumentationRegistry.getInstrumentation().targetContext
val testContext: Context get() = InstrumentationRegistry.getInstrumentation().context
fun copyAssetToCache(assetPath: String): File {
val cacheDir = appContext.cacheDir.also { it.mkdirs() }
val out = File(cacheDir, "${UUID.randomUUID()}-${assetPath.substringAfterLast('/')}")
testContext.assets.open(assetPath).use { input ->
out.outputStream().use { output -> input.copyTo(output) }
}
return out
}
fun contentUriFor(file: File): Uri =
FileProvider.getUriForFile(
appContext,
"${appContext.packageName}.provider",
file,
)
}
@@ -0,0 +1,98 @@
/*
* 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.graphics.drawable.Animatable
import android.graphics.drawable.AnimatedImageDrawable
import android.os.Build
import androidx.core.net.toUri
import androidx.test.ext.junit.runners.AndroidJUnit4
import coil3.ImageLoader
import coil3.asDrawable
import coil3.gif.AnimatedImageDecoder
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.size.ScaleDrawable
import coil3.toBitmap
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.copyAssetToCache
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AvifAnimatedDecodeInstrumentedTest {
private val avifLoader: ImageLoader by lazy {
ImageLoader
.Builder(appContext)
.components {
add(AvifAnimatedDecoderFactory())
add(AnimatedImageDecoder.Factory())
}.build()
}
@Test
fun stillAvifDecodesToBitmap() =
runBlocking {
assumeTrue("AVIF requires API 31+", Build.VERSION.SDK_INT >= 31)
val avif = copyAssetToCache("avif/still-tiny-8x8.avif")
val result =
avifLoader.execute(
ImageRequest.Builder(appContext).data(avif.toUri()).build(),
)
assertTrue("Expected SuccessResult, got ${result::class.simpleName}", result is SuccessResult)
val bitmap = (result as SuccessResult).image.toBitmap()
assertEquals(8, bitmap.width)
assertEquals(8, bitmap.height)
}
@Test
fun animatedAvifDecodesToAnimatedImageDrawable() =
runBlocking {
assumeTrue("Animated AVIF requires API 31+", Build.VERSION.SDK_INT >= 31)
val avif = copyAssetToCache("avif/animated-tiny-3frames.avif")
val result =
avifLoader.execute(
ImageRequest.Builder(appContext).data(avif.toUri()).build(),
)
assertTrue("Expected SuccessResult", result is SuccessResult)
val drawable = (result as SuccessResult).image.asDrawable(appContext.resources)
// AnimatedImageDecoder wraps AnimatedImageDrawable in a ScaleDrawable.
// Both are Animatable; a BitmapDrawable (the failure case) is not.
assertTrue(
"Animated AVIF must be Animatable — Coil's AvifAnimatedDecoderFactory was not invoked if this fails (got ${drawable::class.simpleName})",
drawable is Animatable,
)
// Unwrap ScaleDrawable to confirm the inner drawable is AnimatedImageDrawable.
val inner = if (drawable is ScaleDrawable) drawable.child else drawable
assertTrue(
"Inner drawable must be AnimatedImageDrawable, got ${inner::class.simpleName}",
inner is AnimatedImageDrawable,
)
}
}
@@ -0,0 +1,89 @@
/*
* 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.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.copyAssetToCache
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class ThumbnailDiskCacheAvifInstrumentedTest {
private lateinit var cacheDir: File
private lateinit var cache: ThumbnailDiskCache
@Before
fun setUp() {
cacheDir = File(appContext.cacheDir, "thumbnail-test-${UUID.randomUUID()}")
cache = ThumbnailDiskCache(cacheDir)
}
@After
fun tearDown() {
cacheDir.deleteRecursively()
}
@Test
fun animatedAvifIsNotCached() {
val url = "https://example.com/profile-pic-${UUID.randomUUID()}.avif"
val source = copyAssetToCache("avif/animated-tiny-3frames.avif")
val saved = cache.generateFromFile(url, source)
assertFalse(
"generateFromFile must return false for animated AVIF (would otherwise freeze avatar on first frame)",
saved,
)
assertNull(
"load() must return null for an animated-AVIF URL that was skipped",
cache.load(url),
)
}
@Test
fun stillAvifIsCachedAsBitmap() {
// generateFromFile uses BitmapFactory.decodeFile to read the source,
// which requires platform AVIF decode support (API 31+). On older
// devices the still-AVIF path returns false because decode fails,
// not because of the animated-skip branch.
assumeTrue("Still AVIF decode requires API 31+", Build.VERSION.SDK_INT >= 31)
val url = "https://example.com/profile-pic-${UUID.randomUUID()}.avif"
val source = copyAssetToCache("avif/still-tiny-8x8.avif")
val saved = cache.generateFromFile(url, source)
assertTrue("generateFromFile must return true for still AVIF", saved)
val bitmap = cache.load(url)
assertNotNull("load() must return a non-null Bitmap for a cached still AVIF", bitmap)
}
}
@@ -0,0 +1,54 @@
/*
* 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 androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.contentUriFor
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.copyAssetToCache
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertThrows
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AvifMetadataStripperPoisonedFixtureInstrumentedTest {
@Test
fun stripDispatcherThrowsAvifMetadataNotVerifiableExceptionForPoisonedAvif() {
// Regression for commit b9112550b: NewUserMetadataViewModel calls
// MetadataStripper.strip(uri, "image/avif", context) at line 218.
// The viewmodel only catches AvifMetadataNotVerifiableException to
// surface the AVIF-specific error string; any other exception type
// would fall through to "Upload cancelled" and confuse the user.
//
// Note: strip() dispatches by mimeType param, then internally
// stripImageMetadata() re-resolves via contentResolver.getType(uri).
// A content:// URI (FileProvider) is required so getType() returns
// "image/avif" and the AVIF inspection branch is reached.
val avif = copyAssetToCache("avif/still-tiny-8x8-exif-gps.avif")
val uri = contentUriFor(avif)
val ex =
assertThrows(AvifMetadataNotVerifiableException::class.java) {
MetadataStripper.strip(uri, AVIF_MIME, appContext)
}
assertNotNull("Exception must have a non-null message for UI display", ex.message)
}
}
@@ -0,0 +1,123 @@
/*
* 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.os.Build
import androidx.core.net.toUri
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.contentUriFor
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.copyAssetToCache
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class AvifUploadPipelineInstrumentedTest {
/**
* Drives [assetPath] through [MediaCompressor.compress] with [AVIF_MIME] and asserts the
* bytes survive unchanged. AVIF must bypass JPEG re-encoding regardless of whether the
* source is still or animated.
*/
private fun assertAvifBytesPreserved(
assetPath: String,
description: String,
) = runBlocking {
val avif = copyAssetToCache(assetPath)
val originalBytes = avif.readBytes()
val result =
MediaCompressor().compress(
uri = avif.toUri(),
contentType = AVIF_MIME,
applicationContext = appContext,
mediaQuality = CompressorQuality.MEDIUM,
)
assertEquals(AVIF_MIME, result.contentType)
val resultBytes = File(result.uri.path!!).readBytes()
assertTrue(description, originalBytes.contentEquals(resultBytes))
}
@Test
fun stillAvifPassesThroughMediaCompressorUnchanged() =
assertAvifBytesPreserved(
"avif/still-tiny-8x8.avif",
"Still AVIF bytes must be preserved through MediaCompressor",
)
@Test
fun animatedAvifPassesThroughMediaCompressorUnchanged() =
assertAvifBytesPreserved(
"avif/animated-tiny-3frames.avif",
"Animated AVIF bytes must be preserved (headline regression to prevent)",
)
@Test
fun avifMetadataStripperReturnsCleanFile() {
// Use a content:// URI via FileProvider so contentResolver.getType() returns AVIF_MIME.
// ContentResolver.getType() returns null for file:// URIs even on API 36 (AVIF is not
// recognised via MimeTypeMap for the file:// scheme). FileProvider uses
// MimeTypeMap.getSingleton().getMimeTypeFromExtension("avif") which returns "image/avif"
// on API 31+, matching production behaviour (gallery pickers always deliver content://).
val avif = copyAssetToCache("avif/still-tiny-8x8.avif")
val uri = contentUriFor(avif)
val result = MetadataStripper.stripImageMetadata(uri, appContext)
assertTrue("Clean AVIF should be marked stripped=true", result.stripped)
assertEquals("Clean AVIF URI should be the original", uri, result.uri)
}
@Test
fun avifPreviewMetadataGeneratesBlurhashAndThumbhash() {
assumeTrue("AVIF decoding requires API 31+", Build.VERSION.SDK_INT >= 31)
val avif = copyAssetToCache("avif/still-tiny-8x8.avif")
val result =
PreviewMetadataCalculator.computeFromUri(
context = appContext,
uri = avif.toUri(),
mimeType = AVIF_MIME,
)
assertNotNull("PreviewMetadataCalculator must return non-null on API 31+", result)
assertNotNull("AVIF blurhash should be generated via ImageDecoder", result!!.blurhash)
assertNotNull("AVIF thumbhash should be generated via ImageDecoder", result.thumbhash)
assertNotNull("AVIF dimensions should be returned", result.dim)
assertEquals(8, result.dim!!.width)
assertEquals(8, result.dim.height)
}
@Test
fun poisonedAvifMetadataStripperThrowsAvifMetadataNotVerifiableException() {
val avif = copyAssetToCache("avif/still-tiny-8x8-exif-gps.avif")
val ex =
assertThrows(AvifMetadataNotVerifiableException::class.java) {
MetadataStripper.stripImageMetadata(contentUriFor(avif), appContext)
}
assertNotNull("Exception must have a non-null message", ex.message)
}
}