feat(android): NetworkModule + AuthCookieInterceptor + AuthStore placeholder

M8 phase 3.1. Wires the single shared OkHttp + Retrofit instance the
whole app uses (per-endpoint Retrofit interfaces land in feature
modules). Audio HTTP via ExoPlayer's OkHttpDataSource.Factory will
reuse this same client — single auth/connection-pool surface.

  - AuthStore: in-memory MutableStateFlow placeholder. Task 4.2
    promotes it to a Room-backed single-row table for process-death
    persistence; public API stays identical.
  - AuthCookieInterceptor: attaches Cookie on outbound, captures
    Set-Cookie on successful responses (login flow), clears the store
    on 401 (logout signal).
  - ServerBaseUrl: value class to type-safely DI the base URL.
  - NetworkModule: Hilt-provided OkHttp + Retrofit + HttpLogging.

First task with unit tests — AuthCookieInterceptorTest uses MockWebServer
to verify all three interceptor branches plus a no-Set-Cookie-no-overwrite
case. Will tell us if the JUnit 5 + okhttp-mockwebserver test stack is
plumbed correctly through Gradle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 09:00:41 -04:00
parent 25ae70c5bd
commit 53f69fb1f5
5 changed files with 240 additions and 0 deletions
@@ -0,0 +1,45 @@
package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.auth.AuthStore
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* Attaches the session cookie from [AuthStore] to every request,
* captures Set-Cookie from successful responses (login flow), and
* clears the store on 401 so downstream code can react to logout.
*
* Mirrors the Flutter Dio interceptor pattern.
*/
@Singleton
class AuthCookieInterceptor @Inject constructor(
private val authStore: AuthStore,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val cookie = authStore.sessionCookie.value
val request = chain.request().newBuilder().apply {
if (!cookie.isNullOrEmpty()) header("Cookie", cookie)
}.build()
val response = chain.proceed(request)
if (response.code == HTTP_UNAUTHORIZED) {
authStore.setSessionCookie(null)
} else if (response.isSuccessful) {
// Capture the first Set-Cookie pair on login; subsequent
// responses that include Set-Cookie keep the store fresh.
response.headers("Set-Cookie").firstOrNull()?.let { sc ->
val firstPair = sc.substringBefore(';')
if (firstPair.isNotBlank()) authStore.setSessionCookie(firstPair)
}
}
return response
}
private companion object {
const val HTTP_UNAUTHORIZED = 401
}
}
@@ -0,0 +1,75 @@
package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.auth.AuthStore
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
/**
* Provides a single shared OkHttp client + Retrofit instance for the
* whole app. Per-endpoint Retrofit interfaces (LibraryApi, PlaylistsApi,
* etc.) are provided by feature modules using
* `retrofit.create(EndpointApi::class.java)` in the relevant @Provides.
*
* Streaming audio HTTP (ExoPlayer) reuses this same OkHttp via
* `OkHttpDataSource.Factory` so the auth interceptor, connection pool,
* and TLS config are shared.
*/
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideHttpLogging(): HttpLoggingInterceptor =
HttpLoggingInterceptor().apply {
level =
if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BASIC
} else {
HttpLoggingInterceptor.Level.NONE
}
}
@Provides
@Singleton
fun provideOkHttp(
auth: AuthCookieInterceptor,
logging: HttpLoggingInterceptor,
): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(auth)
.addInterceptor(logging)
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
@Provides
@Singleton
fun provideRetrofit(
okHttp: OkHttpClient,
json: Json,
authStore: AuthStore,
): Retrofit =
Retrofit.Builder()
// Base URL is read once on Retrofit creation. Server-URL
// changes (Settings screen) require an app relaunch; the
// Settings flow that handles that lands in M8 phase 11.
.baseUrl(authStore.baseUrl.value.trimEnd('/') + "/")
.client(okHttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
private const val CONNECT_TIMEOUT_SECONDS = 10L
private const val READ_TIMEOUT_SECONDS = 30L
}
@@ -0,0 +1,9 @@
package com.fabledsword.minstrel.api
/**
* Value-class wrapper around the server base URL string. Used for
* dependency-injection type safety so a raw String isn't ambiguous in
* the graph.
*/
@JvmInline
value class ServerBaseUrl(val value: String)
@@ -0,0 +1,34 @@
package com.fabledsword.minstrel.auth
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Holds the user's session cookie and configured server base URL.
*
* **This is the in-memory placeholder.** Phase 4 (Task 4.2) replaces it
* with a Room-backed single-row table so state survives process death.
* The public surface stays the same — only the storage swap.
*/
@Singleton
class AuthStore @Inject constructor() {
private val sessionCookieState = MutableStateFlow<String?>(null)
val sessionCookie: StateFlow<String?> get() = sessionCookieState
private val baseUrlState = MutableStateFlow(DEFAULT_BASE_URL)
val baseUrl: StateFlow<String> get() = baseUrlState
fun setSessionCookie(value: String?) {
sessionCookieState.value = value
}
fun setBaseUrl(value: String) {
baseUrlState.value = value
}
companion object {
const val DEFAULT_BASE_URL = "http://localhost:8080"
}
}
@@ -0,0 +1,77 @@
package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.auth.AuthStore
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class AuthCookieInterceptorTest {
private lateinit var server: MockWebServer
private lateinit var authStore: AuthStore
private lateinit var client: OkHttpClient
@BeforeEach
fun setup() {
server = MockWebServer().apply { start() }
authStore = AuthStore()
client =
OkHttpClient.Builder()
.addInterceptor(AuthCookieInterceptor(authStore))
.build()
}
@AfterEach
fun teardown() {
server.shutdown()
}
@Test
fun `attaches stored cookie to outgoing requests`() {
authStore.setSessionCookie("session=abc123")
server.enqueue(MockResponse().setResponseCode(200))
client.newCall(Request.Builder().url(server.url("/api/test")).build()).execute()
val recorded = server.takeRequest()
assertEquals("session=abc123", recorded.getHeader("Cookie"))
}
@Test
fun `clears cookie on 401 response`() {
authStore.setSessionCookie("session=expired")
server.enqueue(MockResponse().setResponseCode(401))
client.newCall(Request.Builder().url(server.url("/api/test")).build()).execute()
assertNull(authStore.sessionCookie.value)
}
@Test
fun `captures Set-Cookie on successful response`() {
server.enqueue(
MockResponse()
.setResponseCode(200)
.addHeader("Set-Cookie", "session=newvalue; Path=/; HttpOnly"),
)
client.newCall(Request.Builder().url(server.url("/api/login")).build()).execute()
assertEquals("session=newvalue", authStore.sessionCookie.value)
}
@Test
fun `does not overwrite cookie on a successful response without Set-Cookie`() {
authStore.setSessionCookie("session=existing")
server.enqueue(MockResponse().setResponseCode(200))
client.newCall(Request.Builder().url(server.url("/api/anything")).build()).execute()
assertEquals("session=existing", authStore.sessionCookie.value)
}
}