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 a86596ce2..75ad934cf 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 @@ -7,11 +7,15 @@ import androidx.work.Configuration import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader +import coil3.annotation.ExperimentalCoilApi import coil3.disk.DiskCache import coil3.disk.directory +import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.CachePolicy import coil3.request.crossfade import com.flipcash.app.auth.AuthManager +import com.flipcash.app.core.cache.ETagCacheStrategy +import com.flipcash.app.core.cache.ETagOfflineFallbackInterceptor import com.flipcash.app.currency.PreferredCurrencyController import com.getcode.opencode.repositories.EventRepository import com.getcode.utils.trace @@ -49,6 +53,7 @@ class FlipcashApp : Application(), Configuration.Provider, SingletonImageLoader. trace("app onCreate end") } + @OptIn(ExperimentalCoilApi::class) override fun newImageLoader(context: PlatformContext): ImageLoader { return ImageLoader.Builder(context) .crossfade(true) @@ -60,6 +65,12 @@ class FlipcashApp : Application(), Configuration.Provider, SingletonImageLoader. .maxSizePercent(0.2) .build() } + .components { + add(ETagOfflineFallbackInterceptor()) + add(OkHttpNetworkFetcherFactory( + cacheStrategy = { ETagCacheStrategy() }, + )) + } .build() } } \ No newline at end of file diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/cache/ETagRevalidation.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/cache/ETagRevalidation.kt new file mode 100644 index 000000000..286eb1c98 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/cache/ETagRevalidation.kt @@ -0,0 +1,125 @@ +package com.flipcash.app.core.cache + +import coil3.Extras +import coil3.annotation.ExperimentalCoilApi +import coil3.getExtra +import coil3.intercept.Interceptor +import coil3.network.CacheStrategy +import coil3.network.NetworkRequest +import coil3.network.NetworkResponse +import coil3.request.ErrorResult +import coil3.request.ImageRequest +import coil3.request.ImageResult +import coil3.request.Options +import java.io.IOException + +/** + * [Extras.Key] that enables ETag-based cache revalidation for an individual image request. + * When `true`, [ETagCacheStrategy] will attach conditional headers (`If-None-Match` / + * `If-Modified-Since`) so Coil revalidates against the server instead of blindly returning + * the disk-cached image. + */ +internal val ETagRevalidationKey = Extras.Key(default = false) + +/** + * Opts this image request into ETag-based cache revalidation. + * + * When set, the [ETagCacheStrategy] reads `ETag` and `Last-Modified` values stored in the + * disk-cache metadata and sends conditional request headers so the server can respond with + * `304 Not Modified` when the cached image is still fresh. + * + * Pair with [ETagOfflineFallbackInterceptor] to gracefully serve stale cache when the + * network is unavailable. + */ +fun ImageRequest.Builder.etagRevalidation() = apply { + extras[ETagRevalidationKey] = true +} + +/** + * A [CacheStrategy] that performs conditional revalidation using `ETag` / `Last-Modified` + * headers stored in Coil's disk-cache metadata. + * + * **Read behaviour:** + * - If [ETagRevalidationKey] is **not** set on the request, the cached response is returned + * immediately (same as [CacheStrategy.DEFAULT]). + * - If the key **is** set, cached `etag` and `last-modified` headers are copied into + * `If-None-Match` / `If-Modified-Since` on the outgoing [NetworkRequest], forcing a + * network round-trip. The server can then respond with `304` to avoid re-downloading the + * image body. + * + * **Write behaviour:** delegates entirely to [CacheStrategy.DEFAULT], which merges headers on + * `304` responses and writes new bodies on `2xx` responses. + */ +@OptIn(ExperimentalCoilApi::class) +class ETagCacheStrategy : CacheStrategy { + + override suspend fun read( + cacheResponse: NetworkResponse, + networkRequest: NetworkRequest, + options: Options, + ): CacheStrategy.ReadResult { + if (!options.getExtra(ETagRevalidationKey)) { + return CacheStrategy.ReadResult(cacheResponse) + } + + val cachedHeaders = cacheResponse.headers + val etag = cachedHeaders["etag"] + val lastModified = cachedHeaders["last-modified"] + + if (etag == null && lastModified == null) { + return CacheStrategy.ReadResult(networkRequest) + } + + val requestHeaders = networkRequest.headers.newBuilder() + if (etag != null) { + requestHeaders["if-none-match"] = etag + } + if (lastModified != null) { + requestHeaders["if-modified-since"] = lastModified + } + + return CacheStrategy.ReadResult( + networkRequest.copy(headers = requestHeaders.build()) + ) + } + + override suspend fun write( + cacheResponse: NetworkResponse?, + networkRequest: NetworkRequest, + networkResponse: NetworkResponse, + options: Options, + ): CacheStrategy.WriteResult { + return CacheStrategy.DEFAULT.write(cacheResponse, networkRequest, networkResponse, options) + } +} + +/** + * A Coil [Interceptor] that provides offline fallback for requests using ETag revalidation. + * + * When [ETagRevalidationKey] is enabled, [ETagCacheStrategy] forces a network round-trip for + * conditional validation. If the device is offline (or the request otherwise fails with an + * [IOException]), this interceptor retries the request with revalidation **disabled**, allowing + * the default cache strategy to serve the stale disk-cached image instead of surfacing an error. + * + * Requests that do not opt into ETag revalidation are passed through unmodified. + */ +@OptIn(ExperimentalCoilApi::class) +class ETagOfflineFallbackInterceptor : Interceptor { + + override suspend fun intercept(chain: Interceptor.Chain): ImageResult { + val request = chain.request + if (!request.getExtra(ETagRevalidationKey)) { + return chain.proceed() + } + + val result = chain.proceed() + if (result is ErrorResult && result.throwable is IOException) { + val fallbackRequest = request.newBuilder() + .apply { extras[ETagRevalidationKey] = false } + .build() + return chain.withRequest(fallbackRequest).proceed() + } + + return result + } +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenImageWithName.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenImageWithName.kt index b6446021a..de74f9cc4 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenImageWithName.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenImageWithName.kt @@ -17,10 +17,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext -import coil3.request.CachePolicy import coil3.request.ImageRequest import coil3.request.crossfade import coil3.request.error +import com.flipcash.app.core.cache.etagRevalidation import com.getcode.opencode.model.financial.Token import com.getcode.theme.CodeTheme import com.getcode.ui.components.R @@ -108,8 +108,7 @@ fun TokenIcon( .data(image) .crossfade(false) .error(R.drawable.ic_placeholder_user) - .memoryCachePolicy(CachePolicy.ENABLED) - .diskCachePolicy(CachePolicy.ENABLED) + .etagRevalidation() .build(), contentDescription = null, contentScale = ContentScale.Crop, diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/cache/ETagCacheStrategyTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/cache/ETagCacheStrategyTest.kt new file mode 100644 index 000000000..098b48140 --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/cache/ETagCacheStrategyTest.kt @@ -0,0 +1,94 @@ +package com.flipcash.app.core.cache + +import coil3.Extras +import coil3.annotation.ExperimentalCoilApi +import coil3.network.NetworkHeaders +import coil3.network.NetworkRequest +import coil3.network.NetworkResponse +import coil3.request.Options +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@OptIn(ExperimentalCoilApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class ETagCacheStrategyTest { + + private val strategy = ETagCacheStrategy() + + private fun options(revalidate: Boolean): Options { + val extras = Extras.Builder() + .apply { if (revalidate) set(ETagRevalidationKey, true) } + .build() + return Options(context = RuntimeEnvironment.getApplication(), extras = extras) + } + + private val baseCacheResponse = NetworkResponse( + code = 200, + headers = NetworkHeaders.EMPTY, + ) + + private val baseNetworkRequest = NetworkRequest(url = "https://example.com/icon.png") + + @Test + fun `without key returns cache response`() = runTest { + val result = strategy.read(baseCacheResponse, baseNetworkRequest, options(false)) + assertNotNull(result.response) + assertNull(result.request) + } + + @Test + fun `with key and cached etag adds If-None-Match`() = runTest { + val cached = baseCacheResponse.copy( + headers = NetworkHeaders.Builder() + .set("etag", "\"abc123\"") + .build() + ) + val result = strategy.read(cached, baseNetworkRequest, options(true)) + assertNotNull(result.request) + assertNull(result.response) + assertEquals("\"abc123\"", result.request!!.headers["if-none-match"]) + } + + @Test + fun `with key and cached last-modified adds If-Modified-Since`() = runTest { + val cached = baseCacheResponse.copy( + headers = NetworkHeaders.Builder() + .set("last-modified", "Wed, 21 Oct 2015 07:28:00 GMT") + .build() + ) + val result = strategy.read(cached, baseNetworkRequest, options(true)) + assertNotNull(result.request) + assertEquals("Wed, 21 Oct 2015 07:28:00 GMT", result.request!!.headers["if-modified-since"]) + } + + @Test + fun `with key and both headers adds both conditional headers`() = runTest { + val cached = baseCacheResponse.copy( + headers = NetworkHeaders.Builder() + .set("etag", "\"abc123\"") + .set("last-modified", "Wed, 21 Oct 2015 07:28:00 GMT") + .build() + ) + val result = strategy.read(cached, baseNetworkRequest, options(true)) + assertNotNull(result.request) + assertEquals("\"abc123\"", result.request!!.headers["if-none-match"]) + assertEquals("Wed, 21 Oct 2015 07:28:00 GMT", result.request!!.headers["if-modified-since"]) + } + + @Test + fun `with key and no cached headers sends plain request`() = runTest { + val result = strategy.read(baseCacheResponse, baseNetworkRequest, options(true)) + assertNotNull(result.request) + assertNull(result.response) + assertNull(result.request!!.headers["if-none-match"]) + assertNull(result.request!!.headers["if-modified-since"]) + } +}