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 0000000000..b726c99c29 Binary files /dev/null and b/amethyst/src/androidTest/assets/avif/animated-tiny-3frames.avif differ diff --git a/amethyst/src/androidTest/assets/avif/still-tiny-8x8-exif-gps.avif b/amethyst/src/androidTest/assets/avif/still-tiny-8x8-exif-gps.avif new file mode 100644 index 0000000000..7b583a7e5c Binary files /dev/null and b/amethyst/src/androidTest/assets/avif/still-tiny-8x8-exif-gps.avif differ diff --git a/amethyst/src/androidTest/assets/avif/still-tiny-8x8.avif b/amethyst/src/androidTest/assets/avif/still-tiny-8x8.avif new file mode 100644 index 0000000000..0fc9359262 Binary files /dev/null and b/amethyst/src/androidTest/assets/avif/still-tiny-8x8.avif differ diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/AvifInstrumentedTestSupport.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/AvifInstrumentedTestSupport.kt new file mode 100644 index 0000000000..fabf3eaed5 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/AvifInstrumentedTestSupport.kt @@ -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, + ) +} 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) + } +}