From 68ca729be4af2e3b18105ded6978bac0b4d6d416 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 12 Oct 2026 09:46:33 +0700 Subject: [PATCH] fix(android): live reduced-motion, off-main voice manifest, audio-only asset mount, voice re-apply after player rebuild --- android/README.md | 7 ++- android/app/build.gradle.kts | 10 ++-- .../java/com/miti99/loto/LotoApplication.kt | 43 +++++++++----- .../com/miti99/loto/audio/VoiceCatalog.kt | 17 +++--- .../java/com/miti99/loto/audio/VoicePlayer.kt | 4 +- .../miti99/loto/audio/VoicePlayerHolder.kt | 28 +++++++++- .../loto/settings/SettingsRepository.kt | 30 ++++++---- .../miti99/loto/state/MasterPanelViewModel.kt | 21 +++---- .../miti99/loto/state/PlayerBoardViewModel.kt | 14 ++--- .../miti99/loto/state/SettingsViewModel.kt | 4 +- .../java/com/miti99/loto/ui/ReducedMotion.kt | 38 ++++++++++--- .../miti99/loto/ui/settings/SettingsSheet.kt | 5 +- .../com/miti99/loto/audio/VoiceCatalogTest.kt | 32 +++++++++-- .../loto/audio/VoicePlayerHolderTest.kt | 56 ++++++++++++++++++- .../loto/settings/SettingsRepositoryTest.kt | 44 +++++++++++++-- .../loto/state/PlayerBoardViewModelTest.kt | 7 ++- 16 files changed, 269 insertions(+), 91 deletions(-) diff --git a/android/README.md b/android/README.md index 82abe1e..056e0dd 100644 --- a/android/README.md +++ b/android/README.md @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4714130..0b2e8cd 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 { diff --git a/android/app/src/main/java/com/miti99/loto/LotoApplication.kt b/android/app/src/main/java/com/miti99/loto/LotoApplication.kt index 852f1f7..fe4ace7 100644 --- a/android/app/src/main/java/com/miti99/loto/LotoApplication.kt +++ b/android/app/src/main/java/com/miti99/loto/LotoApplication.kt @@ -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 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> by lazy { + appScope.async(Dispatchers.IO) { VoiceCatalog.load(this@LotoApplication) } + } + + /** Bundled voices for the picker; empty until [voiceManifest] resolves. */ + val voices: StateFlow> 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 }) } } } diff --git a/android/app/src/main/java/com/miti99/loto/audio/VoiceCatalog.kt b/android/app/src/main/java/com/miti99/loto/audio/VoiceCatalog.kt index 5b0bfc5..ad0993f 100644 --- a/android/app/src/main/java/com/miti99/loto/audio/VoiceCatalog.kt +++ b/android/app/src/main/java/com/miti99/loto/audio/VoiceCatalog.kt @@ -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" } diff --git a/android/app/src/main/java/com/miti99/loto/audio/VoicePlayer.kt b/android/app/src/main/java/com/miti99/loto/audio/VoicePlayer.kt index 31ca9ca..7966699 100644 --- a/android/app/src/main/java/com/miti99/loto/audio/VoicePlayer.kt +++ b/android/app/src/main/java/com/miti99/loto/audio/VoicePlayer.kt @@ -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. * diff --git a/android/app/src/main/java/com/miti99/loto/audio/VoicePlayerHolder.kt b/android/app/src/main/java/com/miti99/loto/audio/VoicePlayerHolder.kt index 57f2da4..29cbfcb 100644 --- a/android/app/src/main/java/com/miti99/loto/audio/VoicePlayerHolder.kt +++ b/android/app/src/main/java/com/miti99/loto/audio/VoicePlayerHolder.kt @@ -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) { + voiceIds.collect { id -> + voiceId = id + instance?.voiceId = id + } + } /** Release the current player, if any, and drop the reference. */ fun release() { 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 f90cfd2..93e4ab5 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 @@ -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, - private val validVoiceIds: Set, - private val defaultVoiceId: String, + private val voices: Deferred>, ) { 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 = settingsFlow.map { true }.onStart { emit(false) } - private fun toSettings(prefs: Preferences): Settings = Settings( + private fun toSettings(prefs: Preferences, voices: List): 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, diff --git a/android/app/src/main/java/com/miti99/loto/state/MasterPanelViewModel.kt b/android/app/src/main/java/com/miti99/loto/state/MasterPanelViewModel.kt index d3d84d5..194416f 100644 --- a/android/app/src/main/java/com/miti99/loto/state/MasterPanelViewModel.kt +++ b/android/app/src/main/java/com/miti99/loto/state/MasterPanelViewModel.kt @@ -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( diff --git a/android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt b/android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt index b6b41ec..2f5f3de 100644 --- a/android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt +++ b/android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt @@ -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`) 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 + diff --git a/android/app/src/main/java/com/miti99/loto/state/SettingsViewModel.kt b/android/app/src/main/java/com/miti99/loto/state/SettingsViewModel.kt index 5afc3b6..d4f5de3 100644 --- a/android/app/src/main/java/com/miti99/loto/state/SettingsViewModel.kt +++ b/android/app/src/main/java/com/miti99/loto/state/SettingsViewModel.kt @@ -18,8 +18,8 @@ import kotlinx.coroutines.launch class SettingsViewModel( private val repository: SettingsRepository, val settings: StateFlow, - /** Bundled voices for the voice picker. */ - val voices: List, + /** Bundled voices for the voice picker; empty until the manifest has loaded. */ + val voices: StateFlow>, ) : ViewModel() { fun setEmptyCellColor(value: String) = update { repository.setEmptyCellColor(value) } diff --git a/android/app/src/main/java/com/miti99/loto/ui/ReducedMotion.kt b/android/app/src/main/java/com/miti99/loto/ui/ReducedMotion.kt index 79ebf32..c6452a3 100644 --- a/android/app/src/main/java/com/miti99/loto/ui/ReducedMotion.kt +++ b/android/app/src/main/java/com/miti99/loto/ui/ReducedMotion.kt @@ -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 diff --git a/android/app/src/main/java/com/miti99/loto/ui/settings/SettingsSheet.kt b/android/app/src/main/java/com/miti99/loto/ui/settings/SettingsSheet.kt index 952837a..bb72bae 100644 --- a/android/app/src/main/java/com/miti99/loto/ui/settings/SettingsSheet.kt +++ b/android/app/src/main/java/com/miti99/loto/ui/settings/SettingsSheet.kt @@ -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, diff --git a/android/app/src/test/java/com/miti99/loto/audio/VoiceCatalogTest.kt b/android/app/src/test/java/com/miti99/loto/audio/VoiceCatalogTest.kt index 70f7047..5513017 100644 --- a/android/app/src/test/java/com/miti99/loto/audio/VoiceCatalogTest.kt +++ b/android/app/src/test/java/com/miti99/loto/audio/VoiceCatalogTest.kt @@ -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(), 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" } } diff --git a/android/app/src/test/java/com/miti99/loto/audio/VoicePlayerHolderTest.kt b/android/app/src/test/java/com/miti99/loto/audio/VoicePlayerHolderTest.kt index 3b873f9..50f5ea3 100644 --- a/android/app/src/test/java/com/miti99/loto/audio/VoicePlayerHolderTest.kt +++ b/android/app/src/test/java/com/miti99/loto/audio/VoicePlayerHolderTest.kt @@ -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) + } } 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 ab870d2..c2ce88e 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 @@ -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) = - SettingsRepository(store, voiceIds, defaultVoiceId = "hoai-my") + private fun repo( + store: DataStore, + voices: List = VoiceCatalog.FALLBACK_VOICES, + ) = SettingsRepository(store, CompletableDeferred(voices)) private fun runWithRepo( block: suspend (SettingsRepository, DataStore) -> 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>() + val repository = SettingsRepository(InMemoryDataStore(), manifest) + val loaded = mutableListOf() + val emitted = mutableListOf() + backgroundScope.launch { repository.loaded.toList(loaded) } + backgroundScope.launch { repository.settingsFlow.toList(emitted) } + runCurrent() + assertEquals(listOf(false), loaded) + assertEquals(emptyList(), 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) + } } diff --git a/android/app/src/test/java/com/miti99/loto/state/PlayerBoardViewModelTest.kt b/android/app/src/test/java/com/miti99/loto/state/PlayerBoardViewModelTest.kt index 30b7b11..4a47a23 100644 --- a/android/app/src/test/java/com/miti99/loto/state/PlayerBoardViewModelTest.kt +++ b/android/app/src/test/java/com/miti99/loto/state/PlayerBoardViewModelTest.kt @@ -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() 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 = gatedSettingsRepo.settingsFlow