mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
+38
@@ -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)
|
||||
}
|
||||
|
||||
+40
-8
@@ -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(
|
||||
|
||||
+111
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user