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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user