mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
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 |
+59
@@ -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,
|
||||
)
|
||||
}
|
||||
+98
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+89
@@ -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)
|
||||
}
|
||||
}
|
||||
+54
@@ -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)
|
||||
}
|
||||
}
|
||||
+123
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user