AVIF display + thumbnail-cache fixes from manual testing

fix(ui): default avatar contentScale to Crop, not Fit
fix(images): skip thumbnail cache for animated AVIF profile pictures
fix(ui): animate profile pictures regardless of URL extension
This commit is contained in:
davotoula
2026-05-27 16:11:20 +02:00
parent 57724cee8c
commit 03c42f585e
3 changed files with 189 additions and 8 deletions
@@ -88,6 +88,15 @@ class ThumbnailDiskCache(
): Boolean {
if (!inFlight.add(url)) return false
if (isAnimatedAvif(sourceFile)) {
// Skip caching — animated AVIF must always go through the AVIF decoder
// so it can produce an AnimatedImageDrawable. A static JPEG thumbnail
// would permanently freeze the avatar on the first frame.
inFlight.remove(url)
Log.d("ThumbnailDiskCache") { "Skipping animated AVIF thumbnail for $url" }
return false
}
try {
val key = keyFor(url)
val finalFile = File(cacheDir, key)
@@ -184,4 +193,33 @@ class ThumbnailDiskCache(
.forEach { it.delete() }
}
}
/**
* Returns true if the source file is an AVIF Image Sequence (animated AVIF).
*
* Sniffs the ISOBMFF `ftyp` box brand at offset 8. Only `avis` is treated as
* potentially animated; still AVIF (`avif`, `avo1`) is allowed to be cached
* as a JPEG thumbnail because the visual result is the same.
*
* Returning true causes [generateFromFile] to skip thumbnail caching so the
* full AVIF stays in Coil's normal disk cache and is decoded by the AVIF
* decoder on every load — preserving animation.
*/
private fun isAnimatedAvif(sourceFile: File): Boolean =
runCatching {
sourceFile.inputStream().use { stream ->
val header = ByteArray(12)
if (stream.read(header) != 12) return@use false
// Offset 4..7 must be "ftyp" (the standard ISOBMFF box type)
header[4] == 'f'.code.toByte() &&
header[5] == 't'.code.toByte() &&
header[6] == 'y'.code.toByte() &&
header[7] == 'p'.code.toByte() &&
// Offset 8..11 must be "avis" (AVIF Image Sequence brand)
header[8] == 'a'.code.toByte() &&
header[9] == 'v'.code.toByte() &&
header[10] == 'i'.code.toByte() &&
header[11] == 's'.code.toByte()
}
}.getOrDefault(false)
}
@@ -41,7 +41,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.asDrawable
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
@@ -112,7 +111,7 @@ fun RobohashFallbackAsyncImage(
contentDescription: String?,
modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
contentScale: ContentScale = ContentScale.Crop,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
@@ -135,7 +134,7 @@ fun RobohashFallbackAsyncImage(
autoPlay = autoPlayGif,
)
} else if (bridgedModel != null && loadProfilePicture) {
val painter =
val fallbackPainter =
if (loadRobohash) {
rememberVectorPainter(
image = CachedRobohash.get(robot, MaterialTheme.colorScheme.isLight),
@@ -147,19 +146,52 @@ fun RobohashFallbackAsyncImage(
)
}
AsyncImage(
val resources = LocalContext.current.resources
SubcomposeAsyncImage(
model = ProfilePictureUrl(bridgedModel),
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
)
) {
val state by painter.state.collectAsState()
val successState = state as? AsyncImagePainter.State.Success
val drawable = successState?.result?.image?.asDrawable(resources)
LaunchedEffect(drawable, autoPlayGif) {
if (drawable is Animatable) {
if (autoPlayGif) drawable.start() else drawable.stop()
}
}
when (state) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
is AsyncImagePainter.State.Loading ->
Image(
painter = fallbackPainter,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
)
is AsyncImagePainter.State.Error ->
Image(
painter = fallbackPainter,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
)
else -> {}
}
}
} else {
if (loadRobohash) {
Image(
@@ -0,0 +1,111 @@
/*
* 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 org.junit.Assert.assertFalse
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class ThumbnailDiskCacheAvifTest {
@get:Rule
val tempFolder = TemporaryFolder()
private fun cache(): ThumbnailDiskCache = ThumbnailDiskCache(tempFolder.newFolder("thumbs"))
private fun fileWithHeader(header: ByteArray): File {
val f = tempFolder.newFile()
f.outputStream().use { it.write(header) }
return f
}
private val ftypAvis =
byteArrayOf(
0x00,
0x00,
0x00,
0x20, // box size
'f'.code.toByte(),
't'.code.toByte(),
'y'.code.toByte(),
'p'.code.toByte(),
'a'.code.toByte(),
'v'.code.toByte(),
'i'.code.toByte(),
's'.code.toByte(),
)
private val ftypAvif =
byteArrayOf(
0x00,
0x00,
0x00,
0x20,
'f'.code.toByte(),
't'.code.toByte(),
'y'.code.toByte(),
'p'.code.toByte(),
'a'.code.toByte(),
'v'.code.toByte(),
'i'.code.toByte(),
'f'.code.toByte(),
)
private val jpegMagic =
byteArrayOf(
0xFF.toByte(),
0xD8.toByte(),
0xFF.toByte(),
0xE0.toByte(),
0x00,
0x10,
'J'.code.toByte(),
'F'.code.toByte(),
'I'.code.toByte(),
'F'.code.toByte(),
0x00,
0x01,
)
@Test
fun `generateFromFile skips animated AVIF (ftyp avis)`() {
val src = fileWithHeader(ftypAvis)
assertFalse(cache().generateFromFile("https://example.com/animated.avif", src))
}
@Test
fun `generateFromFile does not skip still AVIF (ftyp avif)`() {
// Still AVIF is allowed through to BitmapFactory; this test verifies the
// AVIF brand sniff does not over-match. BitmapFactory will fail on a
// 12-byte file but that's the existing failure-fast path; we just
// verify isAnimatedAvif() returns false for `avif`.
val src = fileWithHeader(ftypAvif)
// Generation will likely return false too (BitmapFactory can't decode 12 bytes),
// but the path it takes is the normal path, not the AVIF-skip path. We can't
// easily distinguish without instrumentation; for now this test documents intent.
assertFalse(cache().generateFromFile("https://example.com/still.avif", src))
}
@Test
fun `generateFromFile does not skip JPEG`() {
val src = fileWithHeader(jpegMagic)
assertFalse(cache().generateFromFile("https://example.com/photo.jpg", src))
}
}