fix(android): the launch profile refresh waits for the stored login
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m50s
release / govulncheck (push) Successful in 18s
release / integration (push) Successful in 4m36s
release / android (push) Successful in 3m59s
release / Build signed APK (push) Successful in 4m37s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 11:55:59 -05:00
co-authored by Claude Opus 5.5
parent e73950458f
commit adbb3a3cd8
3 changed files with 93 additions and 0 deletions
@@ -89,8 +89,11 @@ class AuthController @Inject constructor(
* + persisted userJson. Carries account-level flags (debug mode) * + persisted userJson. Carries account-level flags (debug mode)
* that aren't in the login response. Best-effort: a network failure * that aren't in the login response. Best-effort: a network failure
* leaves the cached identity untouched. No-op when signed out. * 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() { suspend fun refreshProfile() {
authStore.awaitSessionHydrated()
if (!isSignedIn) return if (!isSignedIn) return
val p = runCatching { meApi.getProfile() }.getOrNull() ?: return val p = runCatching { meApi.getProfile() }.getOrNull() ?: return
val user = UserRef( val user = UserRef(
@@ -70,6 +70,9 @@ class DiagnosticsUploader @Inject constructor(
/** Coalesces concurrent triggers via tryLock — a drain in flight wins. */ /** Coalesces concurrent triggers via tryLock — a drain in flight wins. */
private suspend fun drainSafe() { 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 val clientId = authStore.clientId.value
// Signed out / no client id yet → nothing to do. Single guard keeps // Signed out / no client id yet → nothing to do. Single guard keeps
// the return count within the detekt gate. // the return count within the detekt gate.
@@ -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<AuthSessionDao> {
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
}
}