diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt index 04dd99848..39022662f 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt @@ -69,7 +69,12 @@ class FlipcashApp : Application(), Configuration.Provider, SingletonImageLoader. .maxSizeBytes(50L * 1024 * 1024) .build() } - .components { add(OkHttpNetworkFetcherFactory()) } + // Treat cached blobs as immutable so we never re-download bytes we already hold (which + // would flash the BlurHash again). The default strategy respects HTTP cache headers and + // would revalidate/re-fetch the ephemeral, expiring download URLs; blobs are static. + .components { + add(OkHttpNetworkFetcherFactory(cacheStrategy = { ImmutableBlobCacheStrategy })) + } .build() } } \ No newline at end of file diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/ImmutableBlobCacheStrategy.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/ImmutableBlobCacheStrategy.kt new file mode 100644 index 000000000..beddc145c --- /dev/null +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/ImmutableBlobCacheStrategy.kt @@ -0,0 +1,39 @@ +package com.flipcash.app + +import coil3.network.CacheStrategy +import coil3.network.NetworkRequest +import coil3.network.NetworkResponse +import coil3.request.Options + +/** + * A [CacheStrategy] that treats every cached image as immutable. + * + * Blob bytes never change once stored — a blob id addresses one fixed set of bytes forever — and + * their `download_url`s are ephemeral (the server re-mints and expires them on every fetch). Coil's + * default strategy is a stock HTTP cache: it honours `Cache-Control`/`Expires`/`ETag` and will + * revalidate or re-download once a stored response looks stale, so images we already have on disk + * still get re-fetched across launches — flashing the BlurHash before the same bytes reappear. + * + * Since the bytes are static there is nothing to revalidate: if we have a cached response we serve + * it unconditionally and never hit the network; on a miss we download once and always persist. This + * is paired with keying the Coil disk/memory cache on the durable blob id rather than the rotating + * URL (see `MediaItem.cacheKeyForSize`); without a stable key this strategy would have nothing to + * hit. + */ +internal object ImmutableBlobCacheStrategy : CacheStrategy { + + // Only invoked when a cached response exists — serve it as-is, no revalidation, no network. + override suspend fun read( + cacheResponse: NetworkResponse, + networkRequest: NetworkRequest, + options: Options, + ): CacheStrategy.ReadResult = CacheStrategy.ReadResult(cacheResponse) + + // Always persist freshly downloaded bytes so the next load is a hit. + override suspend fun write( + cacheResponse: NetworkResponse?, + networkRequest: NetworkRequest, + networkResponse: NetworkResponse, + options: Options, + ): CacheStrategy.WriteResult = CacheStrategy.WriteResult(networkResponse) +} 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 725bcf370..b6eee3026 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 @@ -166,17 +166,26 @@ private fun ProfileAvatar( 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 } + // Cache identity is the durable blob id, NOT the download URL: the server re-mints + // and expires `download_url` on every fetch, so a URL-keyed cache misses on the next + // fetch even though the bytes are immutable — every load would re-download and flash + // the BlurHash. Falls back to the URL only if a blob id is somehow unavailable. + val cacheKey = remember(image, targetPx, photoUri) { + image.cacheKeyForSize(targetPx) ?: photoUri } - val request = remember(photoUri, previewKey, blurHash) { + val previewKey = remember(image, targetPx, cacheKey) { + image.cacheKeyBelow(targetPx)?.takeIf { it != cacheKey } + } + val request = remember(photoUri, cacheKey, 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) + // Key both caches on the stable blob id (not the ephemeral URL, and not + // size) so every avatar load of this rendition shares one entry that + // survives URL rotation and app restarts — which is also what lets a + // smaller rendition resolve via placeholderMemoryCacheKey across surfaces. + .memoryCacheKey(cacheKey) + .diskCacheKey(cacheKey) .apply { blurHash?.let { placeholder(it) } previewKey?.let { placeholderMemoryCacheKey(it) } 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 6b716ab6d..fe108fd2a 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 @@ -1,5 +1,6 @@ package com.flipcash.services.models.chat +import com.getcode.utils.base58 import kotlinx.serialization.Serializable @Serializable @@ -54,6 +55,20 @@ data class MediaItem( fun urlForSize(targetLongestSidePx: Int): String? = renditionForSize(targetLongestSidePx)?.blob?.downloadUrl + /** + * Stable Coil cache key for the rendition [renditionForSize] resolves to, derived from the + * durable [MediaItemRendition.blobId] rather than the download URL. The server re-mints and + * expires `download_url` on every fetch, so keying the cache on the URL guarantees a miss on + * the next fetch even though the bytes are immutable — the blob id is the rendition's stable + * identity. Null exactly when [urlForSize] is null. + */ + fun cacheKeyForSize(targetLongestSidePx: Int): String? = + renditionForSize(targetLongestSidePx)?.cacheKey + + /** Stable cache key for [renditionBelow], for use as a placeholder memory-cache key. */ + fun cacheKeyBelow(targetLongestSidePx: Int): String? = + renditionBelow(targetLongestSidePx)?.cacheKey + /** * 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] @@ -90,5 +105,9 @@ data class MediaItem( /** 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) } + + /** Stable, URL-independent cache key for a rendition — its durable blob id, base58-encoded. */ + private val MediaItemRendition.cacheKey: String + get() = blobId.bytes.base58 } }