P6 Stream U: AuthenticatedImage composable + CoilAuthInterceptor

Token-aware image loading matching iOS AuthenticatedImage.swift.
Bearer header attachment, 401-triggered refresh+retry, placeholder on error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:10:25 -05:00
parent 19471d780d
commit 224f6643bf
3 changed files with 374 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
package com.tt.honeyDue.network
import coil3.intercept.Interceptor
import coil3.network.HttpException
import coil3.network.httpHeaders
import coil3.request.ErrorResult
import coil3.request.ImageResult
/**
* Coil3 [Interceptor] that attaches an `Authorization` header to every
* outgoing image request and, on an HTTP 401 response, refreshes the token
* and retries exactly once.
*
* Mirrors the behavior of the iOS `AuthenticatedImage` in
* `iosApp/iosApp/Components/AuthenticatedImage.swift`, centralising the
* concern so individual composables don't need to thread the token through
* themselves.
*
* Usage — install on the singleton [coil3.ImageLoader]:
* ```kotlin
* ImageLoader.Builder(context)
* .components {
* add(CoilAuthInterceptor(
* tokenProvider = { TokenStorage.getToken() },
* refreshToken = { (APILayer.refreshToken() as? ApiResult.Success)?.data },
* authScheme = "Token",
* ))
* add(KtorNetworkFetcherFactory())
* }
* .build()
* ```
*
* @param tokenProvider Suspending supplier of the current auth token. Returning
* `null` means "no token available" — the request proceeds unauthenticated.
* @param refreshToken Suspending supplier that refreshes the backing session and
* returns a fresh token, or `null` if refresh failed.
* @param authScheme The auth scheme to prefix the token with (default `Token`
* to match the existing Go backend — use `Bearer` for JWT deployments).
*/
class CoilAuthInterceptor(
private val tokenProvider: suspend () -> String?,
private val refreshToken: suspend () -> String?,
private val authScheme: String = "Token",
) : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val token = tokenProvider()
// No token — proceed without adding the header so anonymous
// endpoints still work.
if (token == null) {
return chain.proceed()
}
val authed = chain.request.newBuilder()
.httpHeaders(
chain.request.httpHeaders.newBuilder()
.set(HEADER_AUTHORIZATION, "$authScheme $token")
.build()
)
.build()
val result = chain.withRequest(authed).proceed()
// If the server rejected the token, try refreshing once.
if (result.isUnauthorized()) {
val newToken = refreshToken() ?: return result
val retried = authed.newBuilder()
.httpHeaders(
authed.httpHeaders.newBuilder()
.set(HEADER_AUTHORIZATION, "$authScheme $newToken")
.build()
)
.build()
// Only retry *once* — whatever comes back from this call is final,
// even if it is itself a 401. This guards against an infinite loop
// when refresh succeeds but the backing account is still revoked.
return chain.withRequest(retried).proceed()
}
return result
}
private fun ImageResult.isUnauthorized(): Boolean {
if (this !is ErrorResult) return false
val ex = throwable as? HttpException ?: return false
return ex.response.code == HTTP_UNAUTHORIZED
}
companion object {
private const val HEADER_AUTHORIZATION = "Authorization"
private const val HTTP_UNAUTHORIZED = 401
}
}