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
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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user