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 + } +}