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
11 changes: 11 additions & 0 deletions apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -60,6 +65,12 @@ class FlipcashApp : Application(), Configuration.Provider, SingletonImageLoader.
.maxSizePercent(0.2)
.build()
}
.components {
add(ETagOfflineFallbackInterceptor())
add(OkHttpNetworkFetcherFactory(
cacheStrategy = { ETagCacheStrategy() },
))
}
.build()
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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"])
}
}
Loading