From ef25f8c0e6e8cdc8e129754caf4782a37cb6ff8d Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 27 May 2026 15:06:25 +0200 Subject: [PATCH] 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. --- .../assets/avif/animated-tiny-3frames.avif | Bin 0 -> 1096 bytes .../assets/avif/still-tiny-8x8-exif-gps.avif | Bin 0 -> 514 bytes .../assets/avif/still-tiny-8x8.avif | Bin 0 -> 315 bytes .../amethyst/AvifInstrumentedTestSupport.kt | 59 +++++++++ .../AvifAnimatedDecodeInstrumentedTest.kt | 98 ++++++++++++++ .../ThumbnailDiskCacheAvifInstrumentedTest.kt | 89 +++++++++++++ ...StripperPoisonedFixtureInstrumentedTest.kt | 54 ++++++++ .../AvifUploadPipelineInstrumentedTest.kt | 123 ++++++++++++++++++ 8 files changed, 423 insertions(+) create mode 100644 amethyst/src/androidTest/assets/avif/animated-tiny-3frames.avif create mode 100644 amethyst/src/androidTest/assets/avif/still-tiny-8x8-exif-gps.avif create mode 100644 amethyst/src/androidTest/assets/avif/still-tiny-8x8.avif create mode 100644 amethyst/src/androidTest/java/com/vitorpamplona/amethyst/AvifInstrumentedTestSupport.kt create mode 100644 amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/AvifAnimatedDecodeInstrumentedTest.kt create mode 100644 amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCacheAvifInstrumentedTest.kt create mode 100644 amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/uploads/AvifMetadataStripperPoisonedFixtureInstrumentedTest.kt create mode 100644 amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/uploads/AvifUploadPipelineInstrumentedTest.kt diff --git a/amethyst/src/androidTest/assets/avif/animated-tiny-3frames.avif b/amethyst/src/androidTest/assets/avif/animated-tiny-3frames.avif new file mode 100644 index 0000000000000000000000000000000000000000..b726c99c29ba13340560a0165cad5bc06777bf87 GIT binary patch literal 1096 zcmah|y=xRv5TCs}!*N{Xh=++WBr625NO7CW!a$A-L88GzENq0$e&n9nmtFQfmrJlX z5nIv1|G~<_UQn?TEUW~vH6Z>A%K4kOyCHWNGRu22^PA6o%n;E^s1Fi16N*6KAX%q|hqKIOTwGAQ*AKz5{ z1%4Ihnh^UTxIt+iN>C{@H|sdPIEqcJhayQ67;cHgi^1DMB>_8v>cwd#Vjvi(*6WNl z-OTAdu^|y%@Zu=l_o4x+Pt(*8(5y&g`A=x9W>ucnh$^3D9A_{OWY+h&U8bWO&%d7F zjZs1TFQt!h98-Wy=7V{KMNIYHCnKFTF4ldSk3c(KL1w5cx1!jqaUoyBDM2ZjMae zbE6TUGlr_=dCDxr<2-y$Y4f_QaZ~)F%ootC*@{9xX|rKF1YQ!y@UG0`Ea9}3Hah^3 zbD3QNrpzMen5nTVeOJ#=qiMr$_UPLeYkKO%YP&^mF=p#Tba4B}+TA6u_2BgktzPU@ j-w%m43*(R$zi++!!pyv^Wv0`-rU;sM=@k8v8F4Tnhm2ieQ&rirFJrX`g_zjsIyIvf| z`R46%A~<)sEY9v>;ukLiwA6H=@^!rM9(AILZ&0h4xPuGr6LZ-mHZ?Ahjhbz<*N~a( zJ@%;sXsb9(<|Yc0uY^>nP!-qnV9D5Xy^zOdYzkEk_eIm0>zA!oFWL5fuSHX zxdg_B(R>A&CAnY@BaoEK%*js%@mv@f7=aK(GB64QX$=t0%*+E@3YL)qa#ERq!XRE^ znSr5meoj8vuB^<0q5`0}cVAKltjGijFmrH##CQ_R44oSl zfGh?E;pF_BqP*ms3ZTin3~UWRA@R(D+(eKZ5C9#rBMaO}@y j + out.outputStream().use { output -> input.copyTo(output) } + } + return out + } + + fun contentUriFor(file: File): Uri = + FileProvider.getUriForFile( + appContext, + "${appContext.packageName}.provider", + file, + ) +} diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/AvifAnimatedDecodeInstrumentedTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/AvifAnimatedDecodeInstrumentedTest.kt new file mode 100644 index 0000000000..cb51d7813f --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/AvifAnimatedDecodeInstrumentedTest.kt @@ -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, + ) + } +} diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCacheAvifInstrumentedTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCacheAvifInstrumentedTest.kt new file mode 100644 index 0000000000..49345c4550 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCacheAvifInstrumentedTest.kt @@ -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) + } +} diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/uploads/AvifMetadataStripperPoisonedFixtureInstrumentedTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/uploads/AvifMetadataStripperPoisonedFixtureInstrumentedTest.kt new file mode 100644 index 0000000000..a2f3b19e03 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/uploads/AvifMetadataStripperPoisonedFixtureInstrumentedTest.kt @@ -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) + } +} diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/uploads/AvifUploadPipelineInstrumentedTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/uploads/AvifUploadPipelineInstrumentedTest.kt new file mode 100644 index 0000000000..983d217776 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/uploads/AvifUploadPipelineInstrumentedTest.kt @@ -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) + } +}