From 17915b19bfa51b8d3d18e4d48f13f45c3257ae84 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 24 Jul 2026 08:59:47 -0400 Subject: [PATCH] feat(ui): size-aware avatar renditions + BlurHash placeholders Profile-picture avatars were grabbing an arbitrary (often the smallest, 32px) rendition by role, which looked grainy on larger surfaces. The server ships several sizes per role (THUMBNAIL 32/160/320, DISPLAY 800/1600), so selection now keys off the avatar's measured pixel size instead of role alone. - MediaItem: add renditionForSize / urlForSize (smallest rendition whose longest side >= target, ORIGINAL excluded, degrades to the role ladder when dimensions are absent), renditionBelow (next-smaller, for progressive placeholders), and blurhash(). - ContactAvatar: measure bounds via BoxWithConstraints and request the matching rendition; bridge the load with an instant BlurHash preview plus any already-cached smaller rendition (placeholderMemoryCacheKey), with a deterministic memoryCacheKey so renditions are reused across surfaces. New MediaItem overload; own-profile + tips call sites use it. - NotificationService: size the person icon to the platform large-icon dimension instead of the grainy 32px thumbnail. - BlurHash: public-domain decoder + rememberBlurHashPainter helper. - Tests for MediaItem selection and BlurHash decoding. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TGyi9wiGyHv3AHMPSfQ2iM --- .../internal/UserProfileScreenContent.kt | 3 +- .../com/flipcash/shared/common/ui/BlurHash.kt | 146 ++++++++++++++++++ .../shared/common/ui/ContactAvatar.kt | 76 ++++++++- .../flipcash/shared/common/ui/BlurHashTest.kt | 50 ++++++ .../app/notifications/NotificationService.kt | 8 +- .../services/models/chat/MediaItem.kt | 53 +++++++ .../services/models/chat/MediaItemTest.kt | 94 +++++++++++ 7 files changed, 419 insertions(+), 11 deletions(-) create mode 100644 apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/BlurHash.kt create mode 100644 apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/BlurHashTest.kt diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt index 3a9acfa0b..f18ad2bfa 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.unit.dp import com.flipcash.core.R import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.chat.MediaItem -import com.flipcash.services.models.chat.MediaItemRendition import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme import com.getcode.ui.components.SwipeAction @@ -278,7 +277,7 @@ private fun ProfileHeader( verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), ) { ContactAvatar( - photoUri = profilePicture?.url(preferred = MediaItemRendition.Role.DISPLAY), + image = profilePicture, displayName = displayName.orEmpty(), modifier = Modifier .size(96.dp) diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/BlurHash.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/BlurHash.kt new file mode 100644 index 000000000..f60126182 --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/BlurHash.kt @@ -0,0 +1,146 @@ +package com.flipcash.shared.common.ui + +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.withSign + +/** + * Decoder for [BlurHash](https://blurha.sh) strings — the compact, blurred image preview that + * ships in a media item's [com.flipcash.services.models.chat.ImageMetadata]. Decode it into a tiny + * bitmap and let the image layer scale it up as an instant placeholder while the real (larger) + * image downloads. + * + * Ported from the public-domain reference implementation. A hash only encodes a handful of DCT + * components, so decode at a low resolution (a couple dozen pixels) — anything larger just wastes + * cycles for no visible gain. + */ +object BlurHash { + + /** + * Decodes [blurHash] into an [width] × [height] [Bitmap], or null if the string is malformed. + * [punch] adjusts contrast (1f = as encoded). + */ + fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f): Bitmap? { + if (blurHash == null || blurHash.length < 6 || width <= 0 || height <= 0) return null + + val sizeFlag = decode83(blurHash, 0, 1) ?: return null + val numCompX = sizeFlag % 9 + 1 + val numCompY = sizeFlag / 9 + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) return null + + val maxAc = ((decode83(blurHash, 1, 2) ?: return null) + 1) / 166f + val colors = Array(numCompX * numCompY) { i -> + if (i == 0) { + decodeDc(decode83(blurHash, 2, 6) ?: return null) + } else { + val from = 4 + i * 2 + decodeAc(decode83(blurHash, from, from + 2) ?: return null, maxAc * punch) + } + } + return composeBitmap(width, height, numCompX, numCompY, colors) + } + + private fun decode83(str: String, from: Int, to: Int): Int? { + var result = 0 + for (i in from until to) { + val index = CHARS.indexOf(str[i]) + if (index < 0) return null + result = result * 83 + index + } + return result + } + + private fun decodeDc(colorEnc: Int): FloatArray = floatArrayOf( + srgbToLinear(colorEnc shr 16 and 255), + srgbToLinear(colorEnc shr 8 and 255), + srgbToLinear(colorEnc and 255), + ) + + private fun decodeAc(value: Int, maxAc: Float): FloatArray = floatArrayOf( + signPow((value / (19 * 19) - 9) / 9f) * maxAc, + signPow((value / 19 % 19 - 9) / 9f) * maxAc, + signPow((value % 19 - 9) / 9f) * maxAc, + ) + + /** sign(value) * value² — the reference impl's quantisation curve. */ + private fun signPow(value: Float): Float = value.pow(2f).withSign(value) + + private fun srgbToLinear(colorEnc: Int): Float { + val v = colorEnc / 255f + return if (v <= 0.04045f) v / 12.92f else ((v + 0.055f) / 1.055f).pow(2.4f) + } + + private fun linearToSrgb(value: Float): Int { + val v = value.coerceIn(0f, 1f) + val srgb = if (v <= 0.0031308f) v * 12.92f else 1.055f * v.pow(1f / 2.4f) - 0.055f + return (srgb * 255f + 0.5f).toInt() + } + + private fun composeBitmap( + width: Int, + height: Int, + numCompX: Int, + numCompY: Int, + colors: Array, + ): Bitmap { + // Precompute the cosine basis for each axis so the inner pixel loop is just multiplies. + val cosX = FloatArray(width * numCompX) + for (x in 0 until width) { + for (i in 0 until numCompX) { + cosX[x * numCompX + i] = cos(PI * x * i / width).toFloat() + } + } + val cosY = FloatArray(height * numCompY) + for (y in 0 until height) { + for (j in 0 until numCompY) { + cosY[y * numCompY + j] = cos(PI * y * j / height).toFloat() + } + } + + val pixels = IntArray(width * height) + for (y in 0 until height) { + for (x in 0 until width) { + var r = 0f + var g = 0f + var b = 0f + for (j in 0 until numCompY) { + val cy = cosY[y * numCompY + j] + for (i in 0 until numCompX) { + val basis = cosX[x * numCompX + i] * cy + val color = colors[j * numCompX + i] + r += color[0] * basis + g += color[1] * basis + b += color[2] * basis + } + } + pixels[y * width + x] = + (0xFF shl 24) or (linearToSrgb(r) shl 16) or (linearToSrgb(g) shl 8) or linearToSrgb(b) + } + } + return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888) + } + + private const val CHARS = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#\$%*+,-.:;=?@[]^_{|}~" +} + +/** + * Remembers a [Painter] that draws [blurHash]'s decoded preview, or null when the hash is absent or + * malformed. Decodes at a deliberately tiny [width]/[height]; callers scale it to fit. + */ +@Composable +fun rememberBlurHashPainter( + blurHash: String?, + width: Int = 24, + height: Int = 24, +): Painter? = remember(blurHash, width, height) { + val bitmap = BlurHash.decode(blurHash, width, height) ?: return@remember null + BitmapPainter(bitmap.asImageBitmap()) +} diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt index f49c83c0b..725bcf370 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt @@ -28,12 +28,14 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.min import androidx.core.net.toUri +import coil3.asImage import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade +import coil3.request.placeholder import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.services.models.UserProfile -import com.flipcash.services.models.chat.MediaItemRendition +import com.flipcash.services.models.chat.MediaItem import com.getcode.theme.CodeTheme import com.getcode.ui.core.addIf @@ -102,23 +104,83 @@ fun ContactAvatar( @Composable fun ContactAvatar( userProfile: UserProfile, - imageRole: MediaItemRendition.Role = MediaItemRendition.Role.THUMBNAIL, modifier: Modifier = Modifier, +) { + ProfileAvatar( + image = userProfile.profilePicture, + modifier = modifier, + fallback = { UnknownContactAvatar(includeBorder = true) }, + ) +} + +/** + * Renders a server-side profile picture ([MediaItem]) — the tips list, chat header, info card, etc. + * Prefer this over the raw `photoUri` overload for anything backed by a [MediaItem]: it picks the + * rendition that matches the avatar's measured pixel size (the server ships several thumbnail / + * display sizes) so the image is never grainy or over-fetched, and bridges the load with the + * item's BlurHash plus any already-cached smaller rendition. Falls back to [displayName]'s + * initials when there's no picture. + */ +@Composable +fun ContactAvatar( + image: MediaItem?, + displayName: String, + modifier: Modifier = Modifier, +) { + ProfileAvatar( + image = image, + modifier = modifier, + fallback = { InitialsText(displayName) }, + ) +} + +@Composable +private fun ProfileAvatar( + image: MediaItem?, + modifier: Modifier, + fallback: @Composable BoxWithConstraintsScope.() -> Unit, ) { BoxWithConstraints( modifier = modifier.background( Brush.linearGradient(CodeTheme.colors.contactAvatar.colors) ) ) { - val photoUri = userProfile.profilePicture?.url(imageRole) - if (photoUri != null) { + // Pick the rendition by the avatar's actual pixel size — the longest bounded side of the + // measured constraints (unbounded → request the largest, so it's never under-sized). + val targetPx = remember(constraints) { + val w = if (constraints.hasBoundedWidth) constraints.maxWidth else 0 + val h = if (constraints.hasBoundedHeight) constraints.maxHeight else 0 + maxOf(w, h).takeIf { it > 0 } ?: Int.MAX_VALUE + } + val photoUri = remember(image, targetPx) { image?.urlForSize(targetPx) } + if (image != null && photoUri != null) { var isError by rememberSaveable(photoUri) { mutableStateOf(false) } if (!isError) { val context = LocalContext.current - val request = remember(photoUri) { + // Two progressively better placeholders bridge the load so we never flash a blank + // gradient while the correctly-sized rendition downloads: + // 1. the BlurHash — an instant, self-contained blurred preview, and + // 2. the next-smaller rendition — if another surface already cached it (e.g. the + // list loaded the 160 this 320 avatar sits above), Coil shows it immediately + // (see placeholderMemoryCacheKey) and upgrades in place. + val blurHash = remember(image) { + BlurHash.decode(image.blurhash(), width = 24, height = 24)?.asImage() + } + val previewKey = remember(image, targetPx, photoUri) { + image.renditionBelow(targetPx)?.blob?.downloadUrl?.takeIf { it != photoUri } + } + val request = remember(photoUri, previewKey, blurHash) { ImageRequest.Builder(context) .crossfade(true) .data(photoUri.toUri()) + // Key on the download URL alone (not size) so every avatar load of this + // rendition shares one cache entry — which is what lets a smaller rendition + // reliably resolve via placeholderMemoryCacheKey across surfaces. + .memoryCacheKey(photoUri) + .apply { + blurHash?.let { placeholder(it) } + previewKey?.let { placeholderMemoryCacheKey(it) } + } .build() } AsyncImage( @@ -133,10 +195,10 @@ fun ContactAvatar( ) } if (isError) { - UnknownContactAvatar(includeBorder = true) + fallback() } } else { - UnknownContactAvatar(includeBorder = true) + fallback() } } } diff --git a/apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/BlurHashTest.kt b/apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/BlurHashTest.kt new file mode 100644 index 000000000..229917310 --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/BlurHashTest.kt @@ -0,0 +1,50 @@ +package com.flipcash.shared.common.ui + +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class BlurHashTest { + + // A valid 4x3-component hash from the BlurHash reference test vectors. + private val validHash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj" + + @Test + fun `decodes a valid hash to a bitmap of the requested size`() { + val bitmap = BlurHash.decode(validHash, width = 32, height = 24) + assertNotNull(bitmap) + assertEquals(32, bitmap.width) + assertEquals(24, bitmap.height) + } + + @Test + fun `returns null for null or blank hashes`() { + assertNull(BlurHash.decode(null, 16, 16)) + assertNull(BlurHash.decode("", 16, 16)) + } + + @Test + fun `returns null for a hash whose declared component count doesn't match its length`() { + // Truncated hash: the size flag promises more components than the string carries. + assertNull(BlurHash.decode(validHash.substring(0, validHash.length - 4), 16, 16)) + } + + @Test + fun `returns null for non-positive dimensions`() { + assertNull(BlurHash.decode(validHash, width = 0, height = 16)) + assertNull(BlurHash.decode(validHash, width = 16, height = -1)) + } + + @Test + fun `returns null for hashes containing characters outside the base-83 alphabet`() { + // 'é' is not part of the BlurHash alphabet. + val invalid = "L" + "é".repeat(validHash.length - 1) + assertNull(BlurHash.decode(invalid, 16, 16)) + } +} diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index a4e2d42a9..0180792aa 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -32,7 +32,6 @@ import com.flipcash.services.controllers.PushController import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId -import com.flipcash.services.models.chat.MediaItemRendition import com.flipcash.services.models.NavigationTrigger import com.flipcash.services.models.NotificationCategory import com.flipcash.services.models.NotificationPayload @@ -250,9 +249,14 @@ class NotificationService : FirebaseMessagingService(), // Device-contact photo (local, synchronous) first; otherwise the profile // picture URL loaded through the app's shared Coil loader (cache-first, // network-bounded). Works for CONTACT_DM and TIP_DM alike. + // + // Size the rendition to the platform's large-icon dimension (density-scaled) rather than + // grabbing the smallest THUMBNAIL — the server ships several thumbnail/display sizes and + // the tiny 32px one looks grainy on the notification's person icon. + val avatarPx = resources.getDimensionPixelSize(android.R.dimen.notification_large_icon_width) val avatar = e164?.let { resolveContactPhoto(it) } ?: member?.userProfile?.profilePicture - ?.url(MediaItemRendition.Role.THUMBNAIL) + ?.urlForSize(avatarPx) ?.let { loadRemoteAvatar(it) } trace( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt index af9a2dd49..6b716ab6d 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt @@ -30,6 +30,55 @@ data class MediaItem( fun url(preferred: MediaItemRendition.Role = MediaItemRendition.Role.ORIGINAL): String? = rendition(preferred)?.blob?.downloadUrl + /** + * The most appropriate rendition to display at [targetLongestSidePx] pixels — the *smallest* + * available rendition whose longest side is at least the target, so a surface gets enough + * pixels to look crisp without over-fetching. Falls back to the largest available rendition + * when none is big enough. + * + * The server derives several sizes per role (e.g. THUMBNAIL at 32/160/320, DISPLAY at + * 800/1600), so picking by [MediaItemRendition.Role] alone is ambiguous — a 32px thumbnail + * looks grainy on a 96px avatar. This selects by pixel size instead. The full-quality + * [MediaItemRendition.Role.ORIGINAL] upload is excluded (it's arbitrarily large); only + * available renditions (populated blob) that carry image dimensions are considered. When none + * do, this degrades to the role-based [rendition] ladder (capped at DISPLAY to avoid ORIGINAL). + */ + fun renditionForSize(targetLongestSidePx: Int): MediaItemRendition? { + val sized = sizedRenditions() + if (sized.isEmpty()) return rendition(MediaItemRendition.Role.DISPLAY) + val ranked = sized.sortedBy { it.second } + return (ranked.firstOrNull { it.second >= targetLongestSidePx } ?: ranked.last()).first + } + + /** Download URL of the rendition resolved for [renditionForSize], or null if none exists. */ + fun urlForSize(targetLongestSidePx: Int): String? = + renditionForSize(targetLongestSidePx)?.blob?.downloadUrl + + /** + * The largest available rendition strictly smaller than [targetLongestSidePx], or null if + * there isn't one. This is the best intermediate placeholder to show while [renditionForSize] + * loads: e.g. an info card targeting 320 reuses the 160 the list already cached, upgrading in + * place rather than flashing. + */ + fun renditionBelow(targetLongestSidePx: Int): MediaItemRendition? = + sizedRenditions() + .filter { it.second < targetLongestSidePx } + .maxByOrNull { it.second } + ?.first + + /** + * Any available BlurHash. All renditions describe the same image, so any one's hash is an + * acceptable instant preview while a full rendition downloads. Null when none carry one. + */ + fun blurhash(): String? = + renditions.firstNotNullOfOrNull { it.blob?.image?.blurhash?.takeIf(String::isNotEmpty) } + + /** Available, non-ORIGINAL renditions paired with their longest side, in list order. */ + private fun sizedRenditions(): List> = + renditions + .filter { it.role != MediaItemRendition.Role.ORIGINAL } + .mapNotNull { r -> r.longestSide?.let { r to it } } + companion object { /** Rendition quality, best first; fallback walks this downward from the requested role. */ private val QUALITY_LADDER = listOf( @@ -37,5 +86,9 @@ data class MediaItem( MediaItemRendition.Role.DISPLAY, MediaItemRendition.Role.THUMBNAIL, ) + + /** Longest image side (px) of a rendition, or null if it has no image dimensions. */ + private val MediaItemRendition.longestSide: Int? + get() = blob?.image?.let { maxOf(it.width, it.height) } } } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/MediaItemTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/MediaItemTest.kt index d7d701127..031b9b5a6 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/MediaItemTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/MediaItemTest.kt @@ -21,8 +21,102 @@ class MediaItemTest { } else null, ) + private fun sized( + role: MediaItemRendition.Role, + longestSide: Int, + available: Boolean = true, + ) = MediaItemRendition( + role = role, + blobId = BlobId(byteArrayOf(1)), + blob = if (available) { + BlobMetadata( + mimeType = "image/png", + sizeBytes = 1, + downloadUrl = "https://cdn/${role.name.lowercase()}-$longestSide", + image = ImageMetadata(width = longestSide, height = longestSide / 2, blurhash = "abc"), + ) + } else null, + ) + private fun media(vararg renditions: MediaItemRendition) = MediaItem(renditions.toList()) + // The server's rendition spec: three thumbnails + two display sizes (order intentionally + // shuffled to prove selection isn't relying on list order). + private fun sizedMedia() = media( + sized(MediaItemRendition.Role.THUMBNAIL, 320), + sized(MediaItemRendition.Role.DISPLAY, 1600), + sized(MediaItemRendition.Role.THUMBNAIL, 32), + sized(MediaItemRendition.Role.DISPLAY, 800), + sized(MediaItemRendition.Role.THUMBNAIL, 160), + sized(MediaItemRendition.Role.ORIGINAL, 4000), + ) + + @Test + fun `renditionForSize picks the smallest rendition at least as large as the target`() { + val item = sizedMedia() + assertEquals(32, item.renditionForSize(1)?.longestSideForTest()) + assertEquals(160, item.renditionForSize(96)?.longestSideForTest()) + assertEquals(160, item.renditionForSize(160)?.longestSideForTest()) + assertEquals(320, item.renditionForSize(161)?.longestSideForTest()) + assertEquals(800, item.renditionForSize(500)?.longestSideForTest()) + } + + @Test + fun `renditionForSize falls back to the largest non-original when the target exceeds all`() { + // 1600 is the biggest derived size; ORIGINAL (4000) is excluded from selection. + assertEquals(1600, sizedMedia().renditionForSize(9000)?.longestSideForTest()) + } + + @Test + fun `renditionForSize uses original only when it is the sole sized rendition`() { + val item = media(sized(MediaItemRendition.Role.ORIGINAL, 4000)) + // No non-original sized renditions -> degrades to the role ladder (DISPLAY cap), which + // finds nothing at/below DISPLAY, so null rather than serving the huge ORIGINAL. + assertNull(item.renditionForSize(100)) + } + + @Test + fun `renditionForSize skips renditions still uploading`() { + val item = media( + sized(MediaItemRendition.Role.THUMBNAIL, 160, available = false), + sized(MediaItemRendition.Role.THUMBNAIL, 320), + ) + assertEquals(320, item.renditionForSize(96)?.longestSideForTest()) + } + + @Test + fun `renditionForSize degrades to the role ladder when no dimensions are present`() { + // Legacy media without ImageMetadata -> DISPLAY-capped role fallback. + val item = media( + rendition(MediaItemRendition.Role.DISPLAY), + rendition(MediaItemRendition.Role.THUMBNAIL), + ) + assertEquals(MediaItemRendition.Role.DISPLAY, item.renditionForSize(100)?.role) + } + + @Test + fun `renditionBelow returns the largest rendition strictly smaller than the target`() { + val item = sizedMedia() + assertEquals(160, item.renditionBelow(320)?.longestSideForTest()) + assertEquals(32, item.renditionBelow(160)?.longestSideForTest()) + assertNull(item.renditionBelow(32)) // nothing smaller than the smallest + } + + @Test + fun `urlForSize returns the resolved download url`() { + assertEquals("https://cdn/thumbnail-160", sizedMedia().urlForSize(96)) + assertNull(media().urlForSize(96)) + } + + @Test + fun `blurhash returns the first available hash`() { + assertEquals("abc", sizedMedia().blurhash()) + assertNull(media(rendition(MediaItemRendition.Role.THUMBNAIL)).blurhash()) // image == null + } + + private fun MediaItemRendition.longestSideForTest(): Int? = + blob?.image?.let { maxOf(it.width, it.height) } + @Test fun `returns the preferred rendition when available`() { val display = rendition(MediaItemRendition.Role.DISPLAY)