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