diff --git a/android/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt b/android/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt index 93e4ab5..e4907dc 100644 --- a/android/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt +++ b/android/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt @@ -13,11 +13,17 @@ import androidx.datastore.preferences.core.stringPreferencesKey import com.miti99.loto.audio.Voice import com.miti99.loto.audio.VoiceCatalog import java.io.IOException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.Deferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.withTimeoutOrNull /** * DataStore-backed settings store. Every field is validated independently on @@ -28,10 +34,14 @@ import kotlinx.coroutines.flow.onStart * ids are the voice allowlist and its first entry the default voice; * [settingsFlow] awaits it, so no settings snapshot is ever published * from a not-yet-loaded manifest. + * @param readTimeout how long the first DataStore read may take before + * [settingsFlow] publishes defaults instead, so consumers gated on + * "settings loaded" never wait forever on a hung store. */ class SettingsRepository( private val dataStore: DataStore, private val voices: Deferred>, + private val readTimeout: Duration = 5.seconds, ) { private object Keys { @@ -65,16 +75,27 @@ class SettingsRepository( // save" (acceptable, L4) had regressed into "settings never load and // auto-cross is dead for the rest of the process" (not acceptable). A // broken settings store must degrade to defaults, not to a dead board. - val settingsFlow: Flow = dataStore.data + private val persistedPrefs: Flow = dataStore.data .catch { Log.w(TAG, "Failed to read settings; falling back to defaults", it) emit(emptyPreferences()) } - .map { prefs -> toSettings(prefs, voices.await()) } + + // A first read that hangs (rather than fails) publishes defaults once, + // then keeps following the store so the real values still land if the + // read eventually resolves. + val settingsFlow: Flow = flow { + if (withTimeoutOrNull(readTimeout) { persistedPrefs.first() } == null) { + Log.w(TAG, "Settings read timed out after $readTimeout; using defaults") + emit(toSettings(emptyPreferences(), voices.await())) + } + emitAll(persistedPrefs.map { prefs -> toSettings(prefs, voices.await()) }) + } /** * True once [settingsFlow] has produced its first value — either the - * real persisted read or the [catch] fallback after a read failure. + * real persisted read, the [catch] fallback after a read failure, or the + * defaults published when the first read times out. * False only for the brief startup window before that read lands (M4 * residual): [LotoApplication]'s `settingsOrNull` is * `stateIn(..., SharingStarted.Eagerly, null)`, so its *initial* value diff --git a/android/app/src/main/java/com/miti99/loto/state/GameStateRepository.kt b/android/app/src/main/java/com/miti99/loto/state/GameStateRepository.kt index 8f36b1a..084f538 100644 --- a/android/app/src/main/java/com/miti99/loto/state/GameStateRepository.kt +++ b/android/app/src/main/java/com/miti99/loto/state/GameStateRepository.kt @@ -9,7 +9,10 @@ import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey import com.miti99.loto.game.CardGenerator import java.io.IOException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull /** The player's persisted round: card, marks, and explicit unticks. */ data class PlayerRoundState( @@ -31,9 +34,13 @@ data class MasterRoundState( * Both failure classes fall back instead of crashing (the web's * try/catch-everything localStorage posture): malformed payloads * shape-validate to "no saved round", and IO errors are swallowed on write - * and read as empty state on load. + * and read as empty state on load. A read that hangs past [readTimeout] also + * reads as empty state, so `loading` gates can never stick forever. */ -class GameStateRepository(private val dataStore: DataStore) { +class GameStateRepository( + private val dataStore: DataStore, + private val readTimeout: Duration = 5.seconds, +) { private object Keys { val PLAYER_GRID = stringPreferencesKey("player_grid") @@ -84,7 +91,10 @@ class GameStateRepository(private val dataStore: DataStore) { } private suspend fun readPrefs(): Preferences = try { - dataStore.data.first() + withTimeoutOrNull(readTimeout) { dataStore.data.first() } ?: run { + Log.w(TAG, "Game state read timed out after $readTimeout; falling back to empty state") + emptyPreferences() + } } catch (e: IOException) { // L4: was silently swallowed — a persistently failing store used to // degrade to "round never restores" with zero signal for device QA. diff --git a/android/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.kt b/android/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.kt index c2ce88e..81b3fd2 100644 --- a/android/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.kt +++ b/android/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.kt @@ -11,11 +11,13 @@ import com.miti99.loto.SlowDataStore import com.miti99.loto.ThrowingDataStore import com.miti99.loto.audio.Voice import com.miti99.loto.audio.VoiceCatalog +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -253,6 +255,32 @@ class SettingsRepositoryTest { assertEquals(listOf(false, true), states) } + @Test + fun `a hung first read publishes defaults, then the real values once it resolves`() = runTest { + val backing = InMemoryDataStore() + repo(backing).setMode(AppMode.BOTH) + val gate = CompletableDeferred() + val repository = SettingsRepository( + SlowDataStore(backing, gate), + CompletableDeferred(VoiceCatalog.FALLBACK_VOICES), + readTimeout = 5.seconds, + ) + val emitted = mutableListOf() + backgroundScope.launch { repository.settingsFlow.toList(emitted) } + + advanceTimeBy(4.seconds) + runCurrent() + assertEquals(emptyList(), emitted) + + advanceTimeBy(2.seconds) + runCurrent() + assertEquals(listOf(AppMode.PLAYER), emitted.map { it.mode }) + + gate.complete(Unit) + runCurrent() + assertEquals(listOf(AppMode.PLAYER, AppMode.BOTH), emitted.map { it.mode }) + } + @Test fun `settings are not published until the voice manifest has loaded`() = runTest { // The manifest is parsed off the main thread; a snapshot published diff --git a/android/app/src/test/java/com/miti99/loto/state/GameStateRepositoryTest.kt b/android/app/src/test/java/com/miti99/loto/state/GameStateRepositoryTest.kt index 049dda2..f88c3d5 100644 --- a/android/app/src/test/java/com/miti99/loto/state/GameStateRepositoryTest.kt +++ b/android/app/src/test/java/com/miti99/loto/state/GameStateRepositoryTest.kt @@ -3,11 +3,18 @@ package com.miti99.loto.state import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import com.miti99.loto.InMemoryDataStore +import com.miti99.loto.SlowDataStore import com.miti99.loto.ThrowingDataStore import com.miti99.loto.game.CardGenerator import kotlin.random.Random +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Test @@ -143,6 +150,25 @@ class GameStateRepositoryTest { assertNull(repo.loadMasterState()) } + @Test + fun `a read that never resolves times out to empty state`() = runTest { + val backing = InMemoryDataStore() + GameStateRepository(backing).saveMasterState( + MasterRoundState(called = (1..10).toList(), remaining = (11..90).toList()), + ) + // The gate is never completed: the store hangs instead of failing. + val repo = GameStateRepository(SlowDataStore(backing, CompletableDeferred()), readTimeout = 5.seconds) + + val load = async { repo.loadMasterState() } + advanceTimeBy(4.seconds) + runCurrent() + assertFalse(load.isCompleted) + + advanceTimeBy(2.seconds) + runCurrent() + assertNull(load.await()) + } + @Test fun `IO failures on write are swallowed instead of throwing (L4)`() = runTest { val repo = GameStateRepository(ThrowingDataStore()) diff --git a/plans/TODO.md b/plans/TODO.md index f3438c4..7e1f558 100644 --- a/plans/TODO.md +++ b/plans/TODO.md @@ -147,9 +147,6 @@ iOS Safari. No code; verification only. ## Tech debt -- **Android loading-flag timeouts.** `MasterStore.loading` and - `PlayerUiState.loading` have no timeout; a hung DataStore read would - leave the UI on its loading state. (Web has no async loads.) - **M6 recomposition perf** — needs a profiled baseline on a device first. - **`web/package.json` overrides.** `cookie` still forces 0.7.2 over Kit's `^0.6.0`. `serialize-javascript` stays: it holds the floor