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

@@ -34,6 +34,8 @@ import com.tt.honeyDue.ui.theme.ThemeManager
import com.tt.honeyDue.fcm.FCMManager
import com.tt.honeyDue.platform.BillingManager
import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.network.CoilAuthInterceptor
import com.tt.honeyDue.sharing.ContractorSharingManager
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.data.PersistenceManager
@@ -308,6 +310,20 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
override fun newImageLoader(context: PlatformContext): ImageLoader {
return ImageLoader.Builder(context)
.components {
// Auth interceptor runs before the network fetcher so every
// image request carries the current Authorization header, with
// 401 -> refresh-token -> retry handled transparently. Mirrors
// iOS AuthenticatedImage.swift (Stream U).
add(
CoilAuthInterceptor(
tokenProvider = { TokenStorage.getToken() },
refreshToken = {
val r = APILayer.refreshToken()
if (r is ApiResult.Success) r.data else null
},
authScheme = "Token",
)
)
add(KtorNetworkFetcherFactory())
}
.memoryCache {

View File

@@ -0,0 +1,264 @@
package com.tt.honeyDue.network
import androidx.test.core.app.ApplicationProvider
import coil3.ColorImage
import coil3.PlatformContext
import coil3.decode.DataSource
import coil3.intercept.Interceptor
import coil3.network.HttpException
import coil3.network.NetworkHeaders
import coil3.network.NetworkResponse
import coil3.network.httpHeaders
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import coil3.size.Size
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Unit tests for [CoilAuthInterceptor].
*
* The interceptor is responsible for:
* 1. Attaching `Authorization: <scheme> <token>` to image requests.
* 2. On HTTP 401, calling the refresh callback once and retrying the
* request with the new token.
* 3. Not looping: if the retry also returns 401, the error is returned.
* 4. When no token is available, the request proceeds unauthenticated.
*
* Runs under Robolectric so Coil's Android `PlatformContext` (= `Context`)
* is available for constructing `ImageRequest` values.
*/
@RunWith(RobolectricTestRunner::class)
class CoilAuthInterceptorTest {
private val platformContext: PlatformContext
get() = ApplicationProvider.getApplicationContext()
private fun makeRequest(): ImageRequest =
ImageRequest.Builder(platformContext)
.data("https://example.com/media/1")
.build()
private fun makeSuccess(request: ImageRequest): SuccessResult =
SuccessResult(
image = ColorImage(0xFF000000.toInt()),
request = request,
dataSource = DataSource.NETWORK
)
private fun make401Error(request: ImageRequest): ErrorResult {
val response = NetworkResponse(code = 401, headers = NetworkHeaders.EMPTY)
return ErrorResult(
image = null,
request = request,
throwable = HttpException(response)
)
}
private class FakeChain(
initialRequest: ImageRequest,
private val responses: MutableList<(ImageRequest) -> ImageResult>,
val capturedRequests: MutableList<ImageRequest> = mutableListOf(),
) : Interceptor.Chain {
private var currentRequest: ImageRequest = initialRequest
override val request: ImageRequest get() = currentRequest
override val size: Size = Size.ORIGINAL
override fun withRequest(request: ImageRequest): Interceptor.Chain {
currentRequest = request
return this
}
override fun withSize(size: Size): Interceptor.Chain = this
override suspend fun proceed(): ImageResult {
capturedRequests += currentRequest
val responder = responses.removeAt(0)
return responder(currentRequest)
}
}
@Test
fun interceptor_attaches_authorization_header_when_token_present() = runTest {
val request = makeRequest()
val chain = FakeChain(
initialRequest = request,
responses = mutableListOf({ req -> makeSuccess(req) })
)
val interceptor = CoilAuthInterceptor(
tokenProvider = { "abc123" },
refreshToken = { null },
authScheme = "Token",
)
val result = interceptor.intercept(chain)
assertTrue(result is SuccessResult, "Expected success result")
assertEquals(1, chain.capturedRequests.size)
val sent = chain.capturedRequests.first()
assertEquals("Token abc123", sent.httpHeaders["Authorization"])
}
@Test
fun interceptor_skips_header_when_token_missing() = runTest {
val request = makeRequest()
val chain = FakeChain(
initialRequest = request,
responses = mutableListOf({ req -> makeSuccess(req) })
)
val interceptor = CoilAuthInterceptor(
tokenProvider = { null },
refreshToken = { null },
authScheme = "Token",
)
val result = interceptor.intercept(chain)
assertTrue(result is SuccessResult)
assertEquals(1, chain.capturedRequests.size)
val sent = chain.capturedRequests.first()
// No Authorization header should have been added
assertNull(sent.httpHeaders["Authorization"])
}
@Test
fun interceptor_refreshes_and_retries_on_401() = runTest {
val request = makeRequest()
var refreshCallCount = 0
val chain = FakeChain(
initialRequest = request,
responses = mutableListOf(
{ req -> make401Error(req) }, // first attempt -> 401
{ req -> makeSuccess(req) }, // retry -> 200
)
)
val interceptor = CoilAuthInterceptor(
tokenProvider = { "old-token" },
refreshToken = {
refreshCallCount++
"new-token"
},
authScheme = "Token",
)
val result = interceptor.intercept(chain)
assertTrue(result is SuccessResult, "Expected retry to succeed")
assertEquals(1, refreshCallCount, "refreshToken should be invoked exactly once")
assertEquals(2, chain.capturedRequests.size, "Expected original + 1 retry")
assertEquals("Token old-token", chain.capturedRequests[0].httpHeaders["Authorization"])
assertEquals("Token new-token", chain.capturedRequests[1].httpHeaders["Authorization"])
}
@Test
fun interceptor_returns_error_when_refresh_returns_null() = runTest {
val request = makeRequest()
var refreshCallCount = 0
val chain = FakeChain(
initialRequest = request,
responses = mutableListOf({ req -> make401Error(req) })
)
val interceptor = CoilAuthInterceptor(
tokenProvider = { "old-token" },
refreshToken = {
refreshCallCount++
null
},
authScheme = "Token",
)
val result = interceptor.intercept(chain)
assertTrue(result is ErrorResult, "Expected error result when refresh fails")
assertEquals(1, refreshCallCount, "refreshToken should be attempted once")
// Only the first attempt should have gone through
assertEquals(1, chain.capturedRequests.size)
}
@Test
fun interceptor_does_not_loop_on_second_401() = runTest {
val request = makeRequest()
var refreshCallCount = 0
val chain = FakeChain(
initialRequest = request,
responses = mutableListOf(
{ req -> make401Error(req) }, // first attempt -> 401
{ req -> make401Error(req) }, // retry also -> 401
)
)
val interceptor = CoilAuthInterceptor(
tokenProvider = { "old-token" },
refreshToken = {
refreshCallCount++
"new-token"
},
authScheme = "Token",
)
val result = interceptor.intercept(chain)
assertTrue(result is ErrorResult, "Second 401 should surface as ErrorResult")
assertEquals(1, refreshCallCount, "refreshToken should be called exactly once — no infinite loop")
assertEquals(2, chain.capturedRequests.size, "Expected original + exactly one retry")
}
@Test
fun interceptor_passes_through_non_401_errors_without_refresh() = runTest {
val request = makeRequest()
var refreshCallCount = 0
val chain = FakeChain(
initialRequest = request,
responses = mutableListOf({ req ->
ErrorResult(
image = null,
request = req,
throwable = HttpException(
NetworkResponse(code = 500, headers = NetworkHeaders.EMPTY)
)
)
})
)
val interceptor = CoilAuthInterceptor(
tokenProvider = { "tok" },
refreshToken = {
refreshCallCount++
"should-not-be-called"
},
authScheme = "Token",
)
val result = interceptor.intercept(chain)
assertTrue(result is ErrorResult)
assertEquals(0, refreshCallCount, "refreshToken should not be invoked on non-401 errors")
assertEquals(1, chain.capturedRequests.size)
}
@Test
fun interceptor_supports_bearer_scheme() = runTest {
val request = makeRequest()
val chain = FakeChain(
initialRequest = request,
responses = mutableListOf({ req -> makeSuccess(req) })
)
val interceptor = CoilAuthInterceptor(
tokenProvider = { "jwt.payload.sig" },
refreshToken = { null },
authScheme = "Bearer",
)
val result = interceptor.intercept(chain)
assertTrue(result is SuccessResult)
val sent = chain.capturedRequests.first()
assertEquals("Bearer jwt.payload.sig", sent.httpHeaders["Authorization"])
}
}

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
}
}