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