diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/AuthCookieInterceptor.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/AuthCookieInterceptor.kt new file mode 100644 index 00000000..e1a87926 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/AuthCookieInterceptor.kt @@ -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 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/NetworkModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/NetworkModule.kt new file mode 100644 index 00000000..bde4b927 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/NetworkModule.kt @@ -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 +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/ServerBaseUrl.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/ServerBaseUrl.kt new file mode 100644 index 00000000..889146be --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/ServerBaseUrl.kt @@ -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) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt new file mode 100644 index 00000000..fd5701d6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt @@ -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(null) + val sessionCookie: StateFlow get() = sessionCookieState + + private val baseUrlState = MutableStateFlow(DEFAULT_BASE_URL) + val baseUrl: StateFlow 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" + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt new file mode 100644 index 00000000..99592c35 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt @@ -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) + } +}