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:
@@ -34,6 +34,8 @@ import com.tt.honeyDue.ui.theme.ThemeManager
|
|||||||
import com.tt.honeyDue.fcm.FCMManager
|
import com.tt.honeyDue.fcm.FCMManager
|
||||||
import com.tt.honeyDue.platform.BillingManager
|
import com.tt.honeyDue.platform.BillingManager
|
||||||
import com.tt.honeyDue.network.APILayer
|
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.sharing.ContractorSharingManager
|
||||||
import com.tt.honeyDue.data.DataManager
|
import com.tt.honeyDue.data.DataManager
|
||||||
import com.tt.honeyDue.data.PersistenceManager
|
import com.tt.honeyDue.data.PersistenceManager
|
||||||
@@ -308,6 +310,20 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
|
|||||||
override fun newImageLoader(context: PlatformContext): ImageLoader {
|
override fun newImageLoader(context: PlatformContext): ImageLoader {
|
||||||
return ImageLoader.Builder(context)
|
return ImageLoader.Builder(context)
|
||||||
.components {
|
.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())
|
add(KtorNetworkFetcherFactory())
|
||||||
}
|
}
|
||||||
.memoryCache {
|
.memoryCache {
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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