Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.flipcash.services.models.chat

import com.getcode.utils.base58
import kotlinx.serialization.Serializable

@Serializable
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
}
}
Loading