Skip to content

Commit cc7bebb

Browse files
committed
feat(cache): add ETag-based image cache revalidation for Coil
Token icons from currency-assets lack Cache-Control headers, so Coil's default strategy serves stale disk-cached images indefinitely. Add an opt-in ETagCacheStrategy that sends If-None-Match / If-Modified-Since conditional headers, letting the server respond 304 when unchanged. An offline fallback interceptor retries without revalidation so cached icons still display when the network is unavailable. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent c415ac7 commit cc7bebb

4 files changed

Lines changed: 232 additions & 3 deletions

File tree

apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@ import androidx.work.Configuration
77
import coil3.ImageLoader
88
import coil3.PlatformContext
99
import coil3.SingletonImageLoader
10+
import coil3.annotation.ExperimentalCoilApi
1011
import coil3.disk.DiskCache
1112
import coil3.disk.directory
13+
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
1214
import coil3.request.CachePolicy
1315
import coil3.request.crossfade
1416
import com.flipcash.app.auth.AuthManager
17+
import com.flipcash.app.core.cache.ETagCacheStrategy
18+
import com.flipcash.app.core.cache.ETagOfflineFallbackInterceptor
1519
import com.flipcash.app.currency.PreferredCurrencyController
1620
import com.getcode.opencode.repositories.EventRepository
1721
import com.getcode.utils.trace
@@ -49,6 +53,7 @@ class FlipcashApp : Application(), Configuration.Provider, SingletonImageLoader.
4953
trace("app onCreate end")
5054
}
5155

56+
@OptIn(ExperimentalCoilApi::class)
5257
override fun newImageLoader(context: PlatformContext): ImageLoader {
5358
return ImageLoader.Builder(context)
5459
.crossfade(true)
@@ -60,6 +65,12 @@ class FlipcashApp : Application(), Configuration.Provider, SingletonImageLoader.
6065
.maxSizePercent(0.2)
6166
.build()
6267
}
68+
.components {
69+
add(ETagOfflineFallbackInterceptor())
70+
add(OkHttpNetworkFetcherFactory(
71+
cacheStrategy = { ETagCacheStrategy() },
72+
))
73+
}
6374
.build()
6475
}
6576
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package com.flipcash.app.core.cache
2+
3+
import coil3.Extras
4+
import coil3.annotation.ExperimentalCoilApi
5+
import coil3.getExtra
6+
import coil3.intercept.Interceptor
7+
import coil3.network.CacheStrategy
8+
import coil3.network.NetworkRequest
9+
import coil3.network.NetworkResponse
10+
import coil3.request.ErrorResult
11+
import coil3.request.ImageRequest
12+
import coil3.request.ImageResult
13+
import coil3.request.Options
14+
import java.io.IOException
15+
16+
/**
17+
* [Extras.Key] that enables ETag-based cache revalidation for an individual image request.
18+
* When `true`, [ETagCacheStrategy] will attach conditional headers (`If-None-Match` /
19+
* `If-Modified-Since`) so Coil revalidates against the server instead of blindly returning
20+
* the disk-cached image.
21+
*/
22+
internal val ETagRevalidationKey = Extras.Key(default = false)
23+
24+
/**
25+
* Opts this image request into ETag-based cache revalidation.
26+
*
27+
* When set, the [ETagCacheStrategy] reads `ETag` and `Last-Modified` values stored in the
28+
* disk-cache metadata and sends conditional request headers so the server can respond with
29+
* `304 Not Modified` when the cached image is still fresh.
30+
*
31+
* Pair with [ETagOfflineFallbackInterceptor] to gracefully serve stale cache when the
32+
* network is unavailable.
33+
*/
34+
fun ImageRequest.Builder.etagRevalidation() = apply {
35+
extras[ETagRevalidationKey] = true
36+
}
37+
38+
/**
39+
* A [CacheStrategy] that performs conditional revalidation using `ETag` / `Last-Modified`
40+
* headers stored in Coil's disk-cache metadata.
41+
*
42+
* **Read behaviour:**
43+
* - If [ETagRevalidationKey] is **not** set on the request, the cached response is returned
44+
* immediately (same as [CacheStrategy.DEFAULT]).
45+
* - If the key **is** set, cached `etag` and `last-modified` headers are copied into
46+
* `If-None-Match` / `If-Modified-Since` on the outgoing [NetworkRequest], forcing a
47+
* network round-trip. The server can then respond with `304` to avoid re-downloading the
48+
* image body.
49+
*
50+
* **Write behaviour:** delegates entirely to [CacheStrategy.DEFAULT], which merges headers on
51+
* `304` responses and writes new bodies on `2xx` responses.
52+
*/
53+
@OptIn(ExperimentalCoilApi::class)
54+
class ETagCacheStrategy : CacheStrategy {
55+
56+
override suspend fun read(
57+
cacheResponse: NetworkResponse,
58+
networkRequest: NetworkRequest,
59+
options: Options,
60+
): CacheStrategy.ReadResult {
61+
if (!options.getExtra(ETagRevalidationKey)) {
62+
return CacheStrategy.ReadResult(cacheResponse)
63+
}
64+
65+
val cachedHeaders = cacheResponse.headers
66+
val etag = cachedHeaders["etag"]
67+
val lastModified = cachedHeaders["last-modified"]
68+
69+
if (etag == null && lastModified == null) {
70+
return CacheStrategy.ReadResult(networkRequest)
71+
}
72+
73+
val requestHeaders = networkRequest.headers.newBuilder()
74+
if (etag != null) {
75+
requestHeaders["if-none-match"] = etag
76+
}
77+
if (lastModified != null) {
78+
requestHeaders["if-modified-since"] = lastModified
79+
}
80+
81+
return CacheStrategy.ReadResult(
82+
networkRequest.copy(headers = requestHeaders.build())
83+
)
84+
}
85+
86+
override suspend fun write(
87+
cacheResponse: NetworkResponse?,
88+
networkRequest: NetworkRequest,
89+
networkResponse: NetworkResponse,
90+
options: Options,
91+
): CacheStrategy.WriteResult {
92+
return CacheStrategy.DEFAULT.write(cacheResponse, networkRequest, networkResponse, options)
93+
}
94+
}
95+
96+
/**
97+
* A Coil [Interceptor] that provides offline fallback for requests using ETag revalidation.
98+
*
99+
* When [ETagRevalidationKey] is enabled, [ETagCacheStrategy] forces a network round-trip for
100+
* conditional validation. If the device is offline (or the request otherwise fails with an
101+
* [IOException]), this interceptor retries the request with revalidation **disabled**, allowing
102+
* the default cache strategy to serve the stale disk-cached image instead of surfacing an error.
103+
*
104+
* Requests that do not opt into ETag revalidation are passed through unmodified.
105+
*/
106+
@OptIn(ExperimentalCoilApi::class)
107+
class ETagOfflineFallbackInterceptor : Interceptor {
108+
109+
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
110+
val request = chain.request
111+
if (!request.getExtra(ETagRevalidationKey)) {
112+
return chain.proceed()
113+
}
114+
115+
val result = chain.proceed()
116+
if (result is ErrorResult && result.throwable is IOException) {
117+
val fallbackRequest = request.newBuilder()
118+
.apply { extras[ETagRevalidationKey] = false }
119+
.build()
120+
return chain.withRequest(fallbackRequest).proceed()
121+
}
122+
123+
return result
124+
}
125+
}

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenImageWithName.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ import androidx.compose.ui.unit.Dp
1717
import androidx.compose.ui.unit.dp
1818
import coil3.compose.AsyncImage
1919
import coil3.compose.LocalPlatformContext
20-
import coil3.request.CachePolicy
2120
import coil3.request.ImageRequest
2221
import coil3.request.crossfade
2322
import coil3.request.error
23+
import com.flipcash.app.core.cache.etagRevalidation
2424
import com.getcode.opencode.model.financial.Token
2525
import com.getcode.theme.CodeTheme
2626
import com.getcode.ui.components.R
@@ -108,8 +108,7 @@ fun TokenIcon(
108108
.data(image)
109109
.crossfade(false)
110110
.error(R.drawable.ic_placeholder_user)
111-
.memoryCachePolicy(CachePolicy.ENABLED)
112-
.diskCachePolicy(CachePolicy.ENABLED)
111+
.etagRevalidation()
113112
.build(),
114113
contentDescription = null,
115114
contentScale = ContentScale.Crop,
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.flipcash.app.core.cache
2+
3+
import coil3.Extras
4+
import coil3.annotation.ExperimentalCoilApi
5+
import coil3.network.NetworkHeaders
6+
import coil3.network.NetworkRequest
7+
import coil3.network.NetworkResponse
8+
import coil3.request.Options
9+
import kotlinx.coroutines.test.runTest
10+
import org.junit.runner.RunWith
11+
import org.robolectric.RobolectricTestRunner
12+
import org.robolectric.RuntimeEnvironment
13+
import org.robolectric.annotation.Config
14+
import kotlin.test.Test
15+
import kotlin.test.assertEquals
16+
import kotlin.test.assertNotNull
17+
import kotlin.test.assertNull
18+
19+
@OptIn(ExperimentalCoilApi::class)
20+
@RunWith(RobolectricTestRunner::class)
21+
@Config(manifest = Config.NONE)
22+
class ETagCacheStrategyTest {
23+
24+
private val strategy = ETagCacheStrategy()
25+
26+
private fun options(revalidate: Boolean): Options {
27+
val extras = Extras.Builder()
28+
.apply { if (revalidate) set(ETagRevalidationKey, true) }
29+
.build()
30+
return Options(context = RuntimeEnvironment.getApplication(), extras = extras)
31+
}
32+
33+
private val baseCacheResponse = NetworkResponse(
34+
code = 200,
35+
headers = NetworkHeaders.EMPTY,
36+
)
37+
38+
private val baseNetworkRequest = NetworkRequest(url = "https://example.com/icon.png")
39+
40+
@Test
41+
fun `without key returns cache response`() = runTest {
42+
val result = strategy.read(baseCacheResponse, baseNetworkRequest, options(false))
43+
assertNotNull(result.response)
44+
assertNull(result.request)
45+
}
46+
47+
@Test
48+
fun `with key and cached etag adds If-None-Match`() = runTest {
49+
val cached = baseCacheResponse.copy(
50+
headers = NetworkHeaders.Builder()
51+
.set("etag", "\"abc123\"")
52+
.build()
53+
)
54+
val result = strategy.read(cached, baseNetworkRequest, options(true))
55+
assertNotNull(result.request)
56+
assertNull(result.response)
57+
assertEquals("\"abc123\"", result.request!!.headers["if-none-match"])
58+
}
59+
60+
@Test
61+
fun `with key and cached last-modified adds If-Modified-Since`() = runTest {
62+
val cached = baseCacheResponse.copy(
63+
headers = NetworkHeaders.Builder()
64+
.set("last-modified", "Wed, 21 Oct 2015 07:28:00 GMT")
65+
.build()
66+
)
67+
val result = strategy.read(cached, baseNetworkRequest, options(true))
68+
assertNotNull(result.request)
69+
assertEquals("Wed, 21 Oct 2015 07:28:00 GMT", result.request!!.headers["if-modified-since"])
70+
}
71+
72+
@Test
73+
fun `with key and both headers adds both conditional headers`() = runTest {
74+
val cached = baseCacheResponse.copy(
75+
headers = NetworkHeaders.Builder()
76+
.set("etag", "\"abc123\"")
77+
.set("last-modified", "Wed, 21 Oct 2015 07:28:00 GMT")
78+
.build()
79+
)
80+
val result = strategy.read(cached, baseNetworkRequest, options(true))
81+
assertNotNull(result.request)
82+
assertEquals("\"abc123\"", result.request!!.headers["if-none-match"])
83+
assertEquals("Wed, 21 Oct 2015 07:28:00 GMT", result.request!!.headers["if-modified-since"])
84+
}
85+
86+
@Test
87+
fun `with key and no cached headers sends plain request`() = runTest {
88+
val result = strategy.read(baseCacheResponse, baseNetworkRequest, options(true))
89+
assertNotNull(result.request)
90+
assertNull(result.response)
91+
assertNull(result.request!!.headers["if-none-match"])
92+
assertNull(result.request!!.headers["if-modified-since"])
93+
}
94+
}

0 commit comments

Comments
 (0)