fix(android): live reduced-motion, off-main voice manifest, audio-only asset mount, voice re-apply after player rebuild

This commit is contained in:
tiennm99 committed 2026-10-12 09:46:33 +07:00
1 parent 2831953380
commit 68ca729be4
16 files changed
+269 -91

No files matched your search

+4 -3
View File
@@ -24,7 +24,7 @@ behavior change on the web side needs a matching Kotlin change here.
| Package | What it is |
|---------|------------|
| `game` | Pure game rules ported from `web/src/lib/game-logic.js` and friends: card generator, row state, draw deck, auto-cross |
| `audio` | Voice catalog (parses `assets/audio/manifest.json`) + ExoPlayer wrapper with the web's cancellation semantics |
| `audio` | Voice catalog (parses the bundled `manifest.json`) + ExoPlayer wrapper with the web's cancellation semantics |
| `settings` | `Settings` model + DataStore repository — same fields, defaults, and per-field validation as the web store |
| `state` | Round persistence, the shared master store, and the three ViewModels |
| `ui` | Compose screens: player board, master panel, settings sheet, theme |
@@ -32,8 +32,9 @@ behavior change on the web side needs a matching Kotlin change here.
## Audio assets
The 184 MP3s (2 voices × 92 clips) and their manifest are **not copied** into
this project. `app/build.gradle.kts` mounts `../web/static/audio/` as
`assets/audio/` at build time, so web and Android always ship the same files.
this project. `app/build.gradle.kts` mounts `../web/static/audio/` (and only
that directory) as the assets root at build time, so web and Android always
ship the same clips, and nothing else added to `web/static/` reaches the APK.
Regenerate them with the web app's `scripts/generate-audio.py`.
## Audio focus
+4 -6
View File
@@ -25,8 +25,10 @@ android {
getByName("main") {
// Voice clips and their manifest are mounted straight from the web
// app's static dir so both apps ship the exact same files — no copy
// step, no drift. Mounts web/static/audio/** as assets/audio/**.
assets.srcDir("../../web/static")
// step, no drift. Only web/static/audio/ is mounted (as the assets
// root), so anything else added to web/static never reaches the
// APK; VoiceCatalog's asset paths are relative to this dir.
assets.srcDir("../../web/static/audio")
}
}
@@ -35,10 +37,6 @@ android {
// `resourceConfigurations` (defaultConfig) is deprecated for locales
// in AGP 8.13 in favor of this (L11).
localeFilters += listOf("vi")
// Keep only the audio/ subtree from the mounted web/static dir.
// Pattern extends AAPT's default ignore list (hidden files, VCS dirs).
ignoreAssetsPattern =
"!icons:!manifest.webmanifest:!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~"
}
signingConfigs {
@@ -15,10 +15,13 @@ import com.miti99.loto.settings.SettingsRepository
import com.miti99.loto.state.GameStateRepository
import com.miti99.loto.state.MasterStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -45,15 +48,26 @@ class LotoApplication : Application() {
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
/** Bundled announcer voices, parsed once from the asset manifest. */
val voices: List<Voice> by lazy { VoiceCatalog.load(this) }
/**
* Bundled announcer voices, parsed once from the asset manifest on an IO
* thread (asset read + JSON parse stay off the main thread). Started in
* [onCreate]. Nothing reads it synchronously: [settingsRepository]
* awaits it before publishing any settings, so the voice id every draw
* announces with is validated against the real manifest, and until then
* the placeholder defaults name the same default voice.
*/
private val voiceManifest: Deferred<List<Voice>> by lazy {
appScope.async(Dispatchers.IO) { VoiceCatalog.load(this@LotoApplication) }
}
/** Bundled voices for the picker; empty until [voiceManifest] resolves. */
val voices: StateFlow<List<Voice>> by lazy {
flow { emit(voiceManifest.await()) }
.stateIn(appScope, SharingStarted.Eagerly, emptyList())
}
val settingsRepository: SettingsRepository by lazy {
SettingsRepository(
dataStore = settingsDataStore,
validVoiceIds = voices.map { it.id }.toSet(),
defaultVoiceId = VoiceCatalog.defaultVoiceId(voices),
)
SettingsRepository(dataStore = settingsDataStore, voices = voiceManifest)
}
/**
@@ -94,12 +108,11 @@ class LotoApplication : Application() {
* so a plain `by lazy` singleton would hand every ViewModel an
* already-released, permanently-dead player on a relaunch into the same
* cached process. [releaseVoicePlayer] drops the reference on an
* explicit exit; the next access here rebuilds a working one.
* explicit exit; the next access here rebuilds a working one, on the
* configured voice (see [VoicePlayerHolder.followVoice]).
*/
private val voicePlayerHolder: VoicePlayerHolder by lazy {
VoicePlayerHolder {
ExoVoicePlayer(this).also { it.voiceId = VoiceCatalog.defaultVoiceId(voices) }
}
VoicePlayerHolder { ExoVoicePlayer(this) }
}
val voicePlayer: VoicePlayerApi get() = voicePlayerHolder.value
@@ -108,6 +121,7 @@ class LotoApplication : Application() {
override fun onCreate() {
super.onCreate()
voiceManifest.start()
// Two unrelated jobs on two launches (M4): they used to share one
// coroutine with the round-state restore first, so a slow/failed
// DataStore read for the round would stall voice-id sync too. Each
@@ -115,9 +129,8 @@ class LotoApplication : Application() {
// settings/masterStore.state combine() is what makes that race safe
// rather than this ordering.
appScope.launch { masterStore.restore() }
appScope.launch {
// Keep the announcer on the configured voice.
settingsState.collect { voicePlayer.voiceId = it.voice }
}
// Keep the announcer — including one rebuilt after a release — on
// the configured voice.
appScope.launch { voicePlayerHolder.followVoice(settingsState.map { it.voice }) }
}
}
@@ -4,8 +4,9 @@ import android.content.Context
import org.json.JSONObject
/**
* A bundled announcer voice, mirroring the entries of
* `assets/audio/manifest.json` (the same file the web app imports).
* A bundled announcer voice, mirroring the entries of the bundled
* `manifest.json` (`web/static/audio/manifest.json`, the same file the web
* app imports).
*/
data class Voice(
val id: String,
@@ -15,13 +16,15 @@ data class Voice(
)
/**
* Catalog of the bundled voices. Clips live under
* `assets/audio/{voiceId}/{1..90,cho,kinh}.mp3`; the manifest is produced by
* `scripts/generate-audio.py` in the web app and shipped verbatim.
* Catalog of the bundled voices. `web/static/audio/` is mounted as the
* assets root (see `app/build.gradle.kts`), so clips live at
* `assets/{voiceId}/{1..90,cho,kinh}.mp3` next to `assets/manifest.json`;
* the manifest is produced by `scripts/generate-audio.py` in the web app and
* shipped verbatim.
*/
object VoiceCatalog {
const val MANIFEST_ASSET_PATH = "audio/manifest.json"
const val MANIFEST_ASSET_PATH = "manifest.json"
/** Clip basename for the "Chờ" announcement. */
const val CLIP_CHO = "cho"
@@ -79,5 +82,5 @@ object VoiceCatalog {
* [clipName] ∈ "1".."90" | [CLIP_CHO] | [CLIP_KINH].
*/
fun clipPath(voiceId: String, clipName: String): String =
"audio/$voiceId/$clipName.mp3"
"$voiceId/$clipName.mp3"
}
@@ -15,7 +15,7 @@ import androidx.media3.exoplayer.ExoPlayer
* across calls. Switching the voice takes effect on the next utterance.
*/
interface VoicePlayerApi {
/** The active voice id (folder slug under `assets/audio/`). */
/** The active voice id (folder slug under the assets root, i.e. `web/static/audio/`). */
var voiceId: String
/** Play [clipNames] as one gapless sequence, canceling any in-flight one. */
@@ -53,7 +53,7 @@ fun waitingClips(n: Int, voiceWaitingNumber: Boolean, modeIsBoth: Boolean): List
}
/**
* Media3 ExoPlayer implementation over the bundled `asset:///audio/...`
* Media3 ExoPlayer implementation over the bundled `asset:///{voiceId}/...`
* clips. Main-thread only (ExoPlayer requirement) — constructed and used
* from the UI thread.
*
@@ -1,5 +1,7 @@
package com.miti99.loto.audio
import kotlinx.coroutines.flow.Flow
/**
* Recreatable holder for an app-scoped [VoicePlayerApi]. A plain `by lazy`
* singleton is one-shot: once [release] is called the delegate is terminal
@@ -13,14 +15,38 @@ package com.miti99.loto.audio
* again instead of a dead one, while an explicit exit still actually frees
* the previous instance's resources (the original intent behind releasing
* at all, rather than merely cancelling).
*
* [followVoice] owns the configured voice id rather than writing it straight
* into one player instance, so a rebuilt player starts on the user's voice
* instead of the factory default (the settings flow does not re-emit an
* unchanged value just because the player behind it was replaced).
*/
class VoicePlayerHolder(private val factory: () -> VoicePlayerApi) {
private var instance: VoicePlayerApi? = null
/** Latest id from [followVoice]; null = keep the factory's default. */
private var voiceId: String? = null
/** Builds a fresh player on first access and after every [release]. */
val value: VoicePlayerApi
get() = instance ?: factory().also { instance = it }
get() = instance ?: factory().also { player ->
voiceId?.let { player.voiceId = it }
instance = player
}
/**
* Keeps the current player, and every one rebuilt later, on the latest
* id from [voiceIds]. Does not build a player itself: a released holder
* just remembers the id for the next [value] access. Suspends for as
* long as [voiceIds] does.
*/
suspend fun followVoice(voiceIds: Flow<String>) {
voiceIds.collect { id ->
voiceId = id
instance?.voiceId = id
}
}
/** Release the current player, if any, and drop the reference. */
fun release() {
@@ -10,7 +10,10 @@ import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.miti99.loto.audio.Voice
import com.miti99.loto.audio.VoiceCatalog
import java.io.IOException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
@@ -21,13 +24,14 @@ import kotlinx.coroutines.flow.onStart
* read and falls back to its own default (the web's per-field `valid*` ??
* default pattern) — a bad value never wholesale-resets the rest.
*
* @param validVoiceIds allowlist from the audio manifest
* @param defaultVoiceId first manifest entry
* @param voices the bundled voice manifest, parsed off the main thread. Its
* 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.
*/
class SettingsRepository(
private val dataStore: DataStore<Preferences>,
private val validVoiceIds: Set<String>,
private val defaultVoiceId: String,
private val voices: Deferred<List<Voice>>,
) {
private object Keys {
@@ -43,8 +47,14 @@ class SettingsRepository(
val BOARD_TEXT_SCALE = floatPreferencesKey("boardTextScale")
}
/** Defaults with the manifest-derived voice filled in. */
val defaults: Settings = Settings(voice = defaultVoiceId)
/**
* Placeholder for the window before [settingsFlow]'s first emission.
* Must not wait for the manifest, so the voice comes from
* [VoiceCatalog.FALLBACK_VOICES], whose first entry matches the shipped
* manifest's (pinned by `VoiceCatalogTest`). Loaded settings always
* carry the real manifest-derived default.
*/
val defaults: Settings = Settings(voice = VoiceCatalog.defaultVoiceId(VoiceCatalog.FALLBACK_VOICES))
// Every read failure — IOException (full disk, revoked storage
// permission) or anything else — falls back to defaults instead of
@@ -60,7 +70,7 @@ class SettingsRepository(
Log.w(TAG, "Failed to read settings; falling back to defaults", it)
emit(emptyPreferences())
}
.map { prefs -> toSettings(prefs) }
.map { prefs -> toSettings(prefs, voices.await()) }
/**
* True once [settingsFlow] has produced its first value — either the
@@ -76,7 +86,7 @@ class SettingsRepository(
*/
val loaded: Flow<Boolean> = settingsFlow.map { true }.onStart { emit(false) }
private fun toSettings(prefs: Preferences): Settings = Settings(
private fun toSettings(prefs: Preferences, voices: List<Voice>): Settings = Settings(
emptyCellColor = prefs[Keys.EMPTY_CELL_COLOR]
?.takeIf { Settings.HEX6.matches(it) }
?: Settings.DEFAULT_EMPTY_CELL_COLOR,
@@ -90,8 +100,8 @@ class SettingsRepository(
voiceEnabledPlayer = prefs[Keys.VOICE_ENABLED_PLAYER] ?: false,
voiceWaitingNumber = prefs[Keys.VOICE_WAITING_NUMBER] ?: false,
voice = prefs[Keys.VOICE]
?.takeIf { validVoiceIds.contains(it) }
?: defaultVoiceId,
?.takeIf { id -> voices.any { it.id == id } }
?: VoiceCatalog.defaultVoiceId(voices),
boardTextScale = prefs[Keys.BOARD_TEXT_SCALE]
?.takeIf { scale -> Settings.BOARD_TEXT_SCALES.any { it == scale } }
?: Settings.DEFAULT_BOARD_TEXT_SCALE,
@@ -18,6 +18,13 @@ import kotlinx.coroutines.launch
* `web/src/lib/MasterPanel.svelte`. Deck data lives in the shared
* [MasterStore]; this ViewModel adds the auto-call ticker, voice
* announcements, and the countdown reset signal.
*
* No `onCleared()` cancel of [voicePlayer]: it is an app-scoped singleton
* shared with [PlayerBoardViewModel], and `MainActivity.onDestroy()`
* already releases it on the same final-exit (`isFinishing`) path. A
* per-ViewModel cancel would be redundant, and would let one screen
* tearing down cut audio the other is still using if the two ViewModels'
* scopes ever diverged.
*/
class MasterPanelViewModel(
private val masterStore: MasterStore,
@@ -116,20 +123,6 @@ class MasterPanelViewModel(
_foreground.value = foreground
if (!foreground) voicePlayer.cancel()
}
override fun onCleared() {
// L2: voicePlayer is an app-scoped singleton also cancelled by
// PlayerBoardViewModel.onCleared(). Both VMs are activity-scoped
// (LotoViewModelFactory), so onCleared() only fires together, on a
// real finish — MainActivity.onDestroy() already stops/releases the
// player unconditionally on that same isFinishing path (L1). A
// per-VM cancel() here was therefore redundant *and* fragile: if
// either VM's scope ever changed to clear independently of the
// other, one screen tearing down could cut audio the other screen
// is still using. Not cancelling here relies solely on
// MainActivity's guarded release() to stop playback on exit.
super.onCleared()
}
}
private data class Quad(
@@ -61,6 +61,9 @@ data class PlayerUiState(
* `web/src/lib/PlayerBoard.svelte`: cell toggling with manual-untick
* tracking, master auto-cross replay, chờ/kinh detection with voice + toast,
* and round persistence.
*
* Deliberately leaves [voicePlayer] alone when cleared — see
* [MasterPanelViewModel]'s class doc.
*/
class PlayerBoardViewModel(
private val repository: GameStateRepository,
@@ -93,7 +96,7 @@ class PlayerBoardViewModel(
* Used only for the narrow pre-load window where [settingsOrNull] is
* still null but a restored, already-clickable grid can be interacted
* with (`onCellClick` → `detectRowEvents`). Production wiring supplies
* the manifest-derived defaults; tests that never exercise that window
* `SettingsRepository.defaults`; tests that never exercise that window
* (i.e. every test whose `settingsOrNull` is a non-null-typed
* `StateFlow<Settings>`) never read this.
*/
@@ -380,15 +383,6 @@ class PlayerBoardViewModel(
}
}
override fun onCleared() {
// L2: see MasterPanelViewModel.onCleared() — voicePlayer is a
// shared singleton; MainActivity.onDestroy() already stops it on
// the same isFinishing condition this onCleared() fires under, so
// cancelling it here too was redundant and coupled two
// independently-clearable owners to one shared resource.
super.onCleared()
}
/**
* Detect newly completed and newly waiting rows, mirroring the web's
* two-pass $effect: at most one Kinh modal per change, then toast +
@@ -18,8 +18,8 @@ import kotlinx.coroutines.launch
class SettingsViewModel(
private val repository: SettingsRepository,
val settings: StateFlow<Settings>,
/** Bundled voices for the voice picker. */
val voices: List<Voice>,
/** Bundled voices for the voice picker; empty until the manifest has loaded. */
val voices: StateFlow<List<Voice>>,
) : ViewModel() {
fun setEmptyCellColor(value: String) = update { repository.setEmptyCellColor(value) }
@@ -1,8 +1,16 @@
package com.miti99.loto.ui
import android.content.ContentResolver
import android.database.ContentObserver
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
/**
@@ -10,15 +18,31 @@ import androidx.compose.ui.platform.LocalContext
* globally disabled when the animator duration scale is 0 (system
* "Remove animations" accessibility setting). Gates decorative pulses,
* confetti, and tap haptics — same events the web suppresses.
*
* Observes the setting while in composition, so toggling it mid-session
* takes effect without a restart (the web's media query is live too).
*/
@Composable
fun rememberReducedMotion(): Boolean {
val context = LocalContext.current
return remember {
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
) == 0f
val resolver = LocalContext.current.contentResolver
var reduced by remember(resolver) { mutableStateOf(readReducedMotion(resolver)) }
DisposableEffect(resolver) {
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
reduced = readReducedMotion(resolver)
}
}
resolver.registerContentObserver(
Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE),
false,
observer,
)
// Catch a toggle that landed between the first read and registering.
reduced = readReducedMotion(resolver)
onDispose { resolver.unregisterContentObserver(observer) }
}
return reduced
}
private fun readReducedMotion(resolver: ContentResolver): Boolean =
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
@@ -52,6 +52,7 @@ fun SettingsSheet(
) {
val palette = LotoTheme.palette
val settings by viewModel.settings.collectAsState()
val voices by viewModel.voices.collectAsState()
var confirmReset by remember { mutableStateOf(false) }
ModalBottomSheet(
@@ -215,7 +216,7 @@ fun SettingsSheet(
}
}
}
if (viewModel.voices.size > 1) {
if (voices.size > 1) {
Text(
text = stringResource(R.string.settings_voice_picker),
color = palette.buttonSecondaryText,
@@ -224,7 +225,7 @@ fun SettingsSheet(
modifier = Modifier.padding(top = 12.dp, bottom = 6.dp),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (voice in viewModel.voices) {
for (voice in voices) {
ChoiceButton(
label = voice.label,
selected = settings.voice == voice.id,
@@ -11,13 +11,19 @@ class VoiceCatalogTest {
fun `parses the real bundled manifest`() {
// The same file Gradle mounts into assets/ — read straight from the
// web app's static dir (test working dir is the app module).
val manifest = File("../../web/static/audio/manifest.json")
val manifest = File(AUDIO_ASSETS_DIR, VoiceCatalog.MANIFEST_ASSET_PATH)
assertTrue("expected ${manifest.absolutePath} to exist", manifest.exists())
val voices = VoiceCatalog.parse(manifest.readText())
assertEquals(listOf("hoai-my", "nam-minh"), voices.map { it.id })
assertEquals("vi-VN-HoaiMyNeural", voices[0].edgeName)
assertEquals("female", voices[0].gender)
assertEquals("Nam Minh (nam)", voices[1].label)
// SettingsRepository.defaults uses the fallback default before the
// manifest has loaded; it must name the same voice.
assertEquals(
VoiceCatalog.defaultVoiceId(VoiceCatalog.FALLBACK_VOICES),
VoiceCatalog.defaultVoiceId(voices),
)
}
@Test
@@ -55,8 +61,26 @@ class VoiceCatalogTest {
@Test
fun `clipPath builds the asset-relative mp3 path`() {
assertEquals("audio/hoai-my/42.mp3", VoiceCatalog.clipPath("hoai-my", "42"))
assertEquals("audio/nam-minh/cho.mp3", VoiceCatalog.clipPath("nam-minh", VoiceCatalog.CLIP_CHO))
assertEquals("audio/hoai-my/kinh.mp3", VoiceCatalog.clipPath("hoai-my", VoiceCatalog.CLIP_KINH))
assertEquals("hoai-my/42.mp3", VoiceCatalog.clipPath("hoai-my", "42"))
assertEquals("nam-minh/cho.mp3", VoiceCatalog.clipPath("nam-minh", VoiceCatalog.CLIP_CHO))
assertEquals("hoai-my/kinh.mp3", VoiceCatalog.clipPath("hoai-my", VoiceCatalog.CLIP_KINH))
}
@Test
fun `every clip path resolves inside the mounted assets dir`() {
// Pins the build.gradle.kts mount (web/static/audio as the assets
// root) to the paths the player requests at runtime.
val voices = VoiceCatalog.parse(File(AUDIO_ASSETS_DIR, VoiceCatalog.MANIFEST_ASSET_PATH).readText())
val clips = (1..90).map { it.toString() } + VoiceCatalog.CLIP_CHO + VoiceCatalog.CLIP_KINH
val missing = voices.flatMap { voice ->
clips.map { VoiceCatalog.clipPath(voice.id, it) }
.filterNot { File(AUDIO_ASSETS_DIR, it).isFile }
}
assertEquals(emptyList<String>(), missing)
}
private companion object {
/** Mirrors `assets.srcDir` in `app/build.gradle.kts` (test working dir is the app module). */
const val AUDIO_ASSETS_DIR = "../../web/static/audio"
}
}
@@ -1,5 +1,10 @@
package com.miti99.loto.audio
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotSame
@@ -13,8 +18,11 @@ import org.junit.Test
* Android can relaunch into the same cached process. These cover the
* holder's recreate-after-release contract in isolation, since
* `LotoApplication`/`ExoVoicePlayer` need a real Android `Context` and
* cannot be constructed in a plain JVM unit test.
* cannot be constructed in a plain JVM unit test. The voice-sync glue
* `LotoApplication` wires up ([VoicePlayerHolder.followVoice] over the
* settings voice) is covered here the same way, with a fake player.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class VoicePlayerHolderTest {
@Test
@@ -57,4 +65,50 @@ class VoicePlayerHolderTest {
assertNotSame(first, second)
assertFalse(second.released)
}
@Test
fun `followVoice applies every configured voice to the live player`() = runTest {
val holder = VoicePlayerHolder { FakeVoicePlayer() }
val player = holder.value
val voice = MutableStateFlow("hoai-my")
backgroundScope.launch { holder.followVoice(voice) }
runCurrent()
assertEquals("hoai-my", player.voiceId)
voice.value = "nam-minh"
runCurrent()
assertEquals("nam-minh", player.voiceId)
}
@Test
fun `a player rebuilt after release starts on the configured voice, not the factory default`() =
runTest {
val holder = VoicePlayerHolder { FakeVoicePlayer() } // fake defaults to hoai-my
holder.value
val voice = MutableStateFlow("nam-minh")
backgroundScope.launch { holder.followVoice(voice) }
runCurrent()
holder.release()
// The settings flow does not re-emit an unchanged voice, so the
// rebuilt player must pick it up from the holder itself.
assertEquals("nam-minh", holder.value.voiceId)
}
@Test
fun `followVoice does not build a player while none is held`() = runTest {
var buildCount = 0
val holder = VoicePlayerHolder { buildCount++; FakeVoicePlayer() }
holder.value
holder.release()
val voice = MutableStateFlow("hoai-my")
backgroundScope.launch { holder.followVoice(voice) }
runCurrent()
voice.value = "nam-minh"
runCurrent()
assertEquals(1, buildCount)
assertEquals("nam-minh", holder.value.voiceId)
assertEquals(2, buildCount)
}
}
@@ -9,6 +9,8 @@ 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.audio.Voice
import com.miti99.loto.audio.VoiceCatalog
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
@@ -28,10 +30,10 @@ import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SettingsRepositoryTest {
private val voiceIds = setOf("hoai-my", "nam-minh")
private fun repo(store: DataStore<Preferences>) =
SettingsRepository(store, voiceIds, defaultVoiceId = "hoai-my")
private fun repo(
store: DataStore<Preferences>,
voices: List<Voice> = VoiceCatalog.FALLBACK_VOICES,
) = SettingsRepository(store, CompletableDeferred(voices))
private fun runWithRepo(
block: suspend (SettingsRepository, DataStore<Preferences>) -> Unit,
@@ -250,4 +252,38 @@ class SettingsRepositoryTest {
runCurrent()
assertEquals(listOf(false, true), states)
}
@Test
fun `settings are not published until the voice manifest has loaded`() = runTest {
// The manifest is parsed off the main thread; a snapshot published
// before it lands would validate the voice against nothing.
val manifest = CompletableDeferred<List<Voice>>()
val repository = SettingsRepository(InMemoryDataStore(), manifest)
val loaded = mutableListOf<Boolean>()
val emitted = mutableListOf<Settings>()
backgroundScope.launch { repository.loaded.toList(loaded) }
backgroundScope.launch { repository.settingsFlow.toList(emitted) }
runCurrent()
assertEquals(listOf(false), loaded)
assertEquals(emptyList<Settings>(), emitted)
manifest.complete(VoiceCatalog.FALLBACK_VOICES)
runCurrent()
assertEquals(listOf(false, true), loaded)
assertEquals(listOf("hoai-my"), emitted.map { it.voice })
}
@Test
fun `default and allowed voices come from the loaded manifest`() = runTest {
val manifestVoices = listOf(
Voice("nam-minh", "vi-VN-NamMinhNeural", "Nam Minh (nam)", "male"),
)
val store = InMemoryDataStore()
val repository = repo(store, manifestVoices)
assertEquals("nam-minh", repository.settingsFlow.first().voice)
// Valid under the fallback list, but not in this manifest.
store.edit { it[stringPreferencesKey("voice")] = "hoai-my" }
assertEquals("nam-minh", repository.settingsFlow.first().voice)
}
}
@@ -3,6 +3,7 @@ package com.miti99.loto.state
import com.miti99.loto.InMemoryDataStore
import com.miti99.loto.SlowDataStore
import com.miti99.loto.audio.FakeVoicePlayer
import com.miti99.loto.audio.VoiceCatalog
import com.miti99.loto.game.CardGenerator
import com.miti99.loto.settings.AppMode
import com.miti99.loto.settings.Settings
@@ -456,15 +457,15 @@ class PlayerBoardViewModelTest {
MasterRoundState(called = (1..90).toList(), remaining = emptyList()),
)
val voiceIds = setOf("hoai-my")
val voices = CompletableDeferred(listOf(VoiceCatalog.FALLBACK_VOICES.first()))
val settingsBacking = InMemoryDataStore()
// Persist the real mode ahead of time (BOTH != the PLAYER
// default) via an ungated repository over the same backing store.
SettingsRepository(settingsBacking, voiceIds, "hoai-my").setMode(AppMode.BOTH)
SettingsRepository(settingsBacking, voices).setMode(AppMode.BOTH)
val gate = CompletableDeferred<Unit>()
val gatedSettingsRepo =
SettingsRepository(SlowDataStore(settingsBacking, gate), voiceIds, "hoai-my")
SettingsRepository(SlowDataStore(settingsBacking, gate), voices)
// Single source of truth (M2), matching LotoApplication's
// settingsOrNull wiring: null until the gated read resolves.
val settingsOrNull: StateFlow<Settings?> = gatedSettingsRepo.settingsFlow