diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt index 3009d63..5bb7186 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt @@ -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 { diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/network/CoilAuthInterceptorTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/network/CoilAuthInterceptorTest.kt new file mode 100644 index 0000000..e6e1050 --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/network/CoilAuthInterceptorTest.kt @@ -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: ` 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 = 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"]) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/CoilAuthInterceptor.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/CoilAuthInterceptor.kt new file mode 100644 index 0000000..7710515 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/CoilAuthInterceptor.kt @@ -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 + } +}