fix(android): unblock first device test — auth contrast + dynamic base URL

Two regressions surfaced on the first real device run.

1. Contrast on ServerUrl + Login screens: both wrapped content in a
   bare Box(fillMaxSize), no Surface. The obsidian background never
   painted (rendered against the system root view's default),
   LocalContentColor cascade fell through to Material's default
   contentColor — the screens rendered as near-invisible dark text
   on dark grey. Wrap both in Surface(color = background, contentColor
   = onBackground) so the bg paints AND the M3 contentColor pipeline
   flows correctly through OutlinedTextField labels / cursor /
   placeholders + the Button content tint.

2. The bigger bug: NetworkModule.provideRetrofit read
   authStore.baseUrl.value ONCE at Retrofit creation. AuthStore loads
   from Room async, so at injection time the value was still the
   localhost:8080 placeholder. Result: even after the user typed
   their real server URL on the ServerUrl screen, every API call
   kept hitting localhost:8080 ("Failed to connect to
   localhost/127.0.0.1:8080" on the login attempt). The pre-fix
   NetworkModule comment even acknowledged it — *"Server-URL
   changes require an app relaunch"*.

   Fix: per-request rewrite. New BaseUrlInterceptor reads the live
   AuthStore.baseUrl.value on every request and rewrites
   scheme/host/port of the outgoing URL. Retrofit now keeps a
   placeholder baseUrl ("http://placeholder.invalid/") solely to
   satisfy its parser; the actual target host is dynamic. Order in
   OkHttp chain: BaseUrl first → Auth → logging, so the cookie
   interceptor sees the final URL.

New:
  - api/BaseUrlInterceptor.kt — per-request scheme/host/port rewrite
    from AuthStore.baseUrl. Falls through to the original request
    when the stored URL is unparseable.

Modified:
  - api/NetworkModule.kt — adds BaseUrlInterceptor to the OkHttp
    chain. Drops the AuthStore dependency from provideRetrofit;
    swaps baseUrl for the placeholder.
  - auth/ui/ServerUrlScreen.kt — Box → Surface wrap.
  - auth/ui/LoginScreen.kt — Box → Surface wrap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 00:27:05 -04:00
parent 0415b5ccc3
commit 0b72827682
5553 changed files with 63548 additions and 10 deletions
@@ -0,0 +1,43 @@
package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.auth.AuthStore
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* Rewrites every outgoing request's scheme/host/port to match the
* current [AuthStore.baseUrl]. Retrofit's `.baseUrl(...)` is read
* once at Retrofit creation, but AuthStore.baseUrl loads from Room
* asynchronously — so at injection time the Retrofit instance is
* frozen pointing at the [AuthStore.DEFAULT_BASE_URL] placeholder.
*
* This interceptor closes the gap: Retrofit can stay built with the
* placeholder forever, and every actual request gets retargeted at
* the live AuthStore value. Lets the user change server URL in
* Settings without an app relaunch (Phase 11 wiring).
*
* No-op when the stored base URL is unparseable (falls through to
* whatever Retrofit had) — that case shows up as a transport-level
* error in the caller's normal error path.
*/
@Singleton
class BaseUrlInterceptor @Inject constructor(
private val authStore: AuthStore,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
val baseUrl = authStore.baseUrl.value.toHttpUrlOrNull()
?: return chain.proceed(original)
val rewritten: HttpUrl = original.url.newBuilder()
.scheme(baseUrl.scheme)
.host(baseUrl.host)
.port(baseUrl.port)
.build()
return chain.proceed(original.newBuilder().url(rewritten).build())
}
}
@@ -1,7 +1,6 @@
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
@@ -44,10 +43,15 @@ object NetworkModule {
@Provides
@Singleton
fun provideOkHttp(
baseUrl: BaseUrlInterceptor,
auth: AuthCookieInterceptor,
logging: HttpLoggingInterceptor,
): OkHttpClient =
OkHttpClient.Builder()
// BaseUrlInterceptor first — it rewrites scheme/host/port
// to match the live AuthStore.baseUrl on every request.
// Auth then sees the final URL.
.addInterceptor(baseUrl)
.addInterceptor(auth)
.addInterceptor(logging)
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
@@ -59,13 +63,14 @@ object NetworkModule {
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('/') + "/")
// Placeholder base URL — BaseUrlInterceptor rewrites every
// outgoing request to the live AuthStore value, so this
// string only has to satisfy Retrofit's parser and is
// never reached as an actual host. This lets the user
// change server URL in Settings without an app relaunch.
.baseUrl("http://placeholder.invalid/")
.client(okHttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
@@ -1,7 +1,6 @@
package com.fabledsword.minstrel.auth.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -13,6 +12,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -45,7 +45,11 @@ fun LoginScreen(
}
}
Box(modifier = Modifier.fillMaxSize()) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
) {
LoginForm(
state = state,
onUsernameChange = viewModel::setUsername,
@@ -1,7 +1,6 @@
package com.fabledsword.minstrel.auth.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -11,6 +10,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -101,7 +101,11 @@ fun ServerUrlScreen(
}
}
Box(modifier = Modifier.fillMaxSize()) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
) {
ServerUrlForm(
state = state,
onUrlChange = viewModel::setUrl,