From adbb3a3cd8066e2424032f60f84df75162af0427 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 6 Nov 2026 11:55:59 -0500 Subject: [PATCH] fix(android): the launch profile refresh waits for the stored login AuthController refreshes /api/me when the app starts, to pick up account flags such as debug mode. That ran before the session cookie had loaded from the Keystore, read the user as signed out, and skipped. It now waits for the load (bounded by the hydration deadline). The diagnostics uploader's drain waits the same way, so a launch-time trigger isn't skipped. BootReceiver already waited. Co-Authored-By: Claude Opus 5.5 --- .../minstrel/auth/AuthController.kt | 3 + .../diagnostics/DiagnosticsUploader.kt | 3 + .../minstrel/auth/AuthControllerTest.kt | 87 +++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/auth/AuthControllerTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt index eb70e0a9..07dc321a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt @@ -89,8 +89,11 @@ class AuthController @Inject constructor( * + persisted userJson. Carries account-level flags (debug mode) * that aren't in the login response. Best-effort: a network failure * leaves the cached identity untouched. No-op when signed out. + * Waits for the stored login to load first: at launch this runs before + * it has, and would otherwise read the user as signed out and skip. */ suspend fun refreshProfile() { + authStore.awaitSessionHydrated() if (!isSignedIn) return val p = runCatching { meApi.getProfile() }.getOrNull() ?: return val user = UserRef( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/diagnostics/DiagnosticsUploader.kt b/android/app/src/main/java/com/fabledsword/minstrel/diagnostics/DiagnosticsUploader.kt index b51571a1..a19622c4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/diagnostics/DiagnosticsUploader.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/diagnostics/DiagnosticsUploader.kt @@ -70,6 +70,9 @@ class DiagnosticsUploader @Inject constructor( /** Coalesces concurrent triggers via tryLock — a drain in flight wins. */ private suspend fun drainSafe() { + // A trigger at launch can fire before the stored login has loaded; + // wait for it, so a signed-in user is not read as signed out. + authStore.awaitSessionHydrated() val clientId = authStore.clientId.value // Signed out / no client id yet → nothing to do. Single guard keeps // the return count within the detekt gate. diff --git a/android/app/src/test/java/com/fabledsword/minstrel/auth/AuthControllerTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/auth/AuthControllerTest.kt new file mode 100644 index 00000000..ba1ecf7d --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/auth/AuthControllerTest.kt @@ -0,0 +1,87 @@ +package com.fabledsword.minstrel.auth + +import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao +import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.flowOf +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +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 retrofit2.Retrofit +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class AuthControllerTest { + private lateinit var server: MockWebServer + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val json = Json { ignoreUnknownKeys = true } + + @BeforeEach + fun setup() { + server = MockWebServer().apply { start() } + } + + @AfterEach + fun teardown() { + scope.cancel() + server.shutdown() + } + + // At launch the controller refreshes the profile (it carries account + // flags such as debug mode). The stored login loads from the Keystore + // off the main thread, so the refresh has to wait for it rather than + // read the user as signed out and skip. + @Test + fun `the launch profile refresh waits for the stored login`() { + val slowVault = + object : SessionVault { + override fun read(): String? { + Thread.sleep(SLOW_VAULT_MS) + return "session=stored" + } + + override fun write(value: String?): Boolean = true + } + val row = AuthSessionEntity(baseUrl = server.url("/").toString()) + val dao = + mockk { + every { observe() } returns flowOf(row) + coEvery { get() } returns row + coEvery { upsert(any()) } returns Unit + coEvery { setSessionCookie(any()) } returns Unit + coEvery { setBaseUrl(any()) } returns Unit + } + val store = AuthStore(dao, slowVault, scope) + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + server.enqueue(MockResponse().setResponseCode(200).setBody("""{"id":"u1","username":"listener"}""")) + + AuthController(store, json, scope, retrofit) + + val request = server.takeRequest(REQUEST_WAIT_SECONDS, TimeUnit.SECONDS) + assertNotNull(request, "the profile refresh never reached the server") + assertEquals("/api/me", request.path) + } + + private companion object { + const val SLOW_VAULT_MS = 300L + const val REQUEST_WAIT_SECONDS = 5L + } +}