8.8 KiB
Android regression fixes — H1, M2, M3, M4, backup .tmp, generate() double-tap guard
Source: plans/reports/code-reviewer-260831-2314-precommit-review.md
Scope: android/ only. No commit made.
H-1 — voicePlayer.release() kills audio on relaunch-in-process
Chose the recreatable holder, not the cancel() fallback: cancel() would
fully abandon L1's intent (never releasing the ExoPlayer on explicit exit,
reverting to pre-L1 behavior). The holder keeps L1's actual release on
"Thoát" while surviving a relaunch into the same cached process.
- New
android/app/src/main/java/com/miti99/loto/audio/VoicePlayerHolder.kt: nullable-instance holder,valuerebuilds via a factory afterrelease(). LotoApplication.kt:voicePlayernow backed by a privateVoicePlayerHolder; addedreleaseVoicePlayer().MainActivity.kt:onDestroy()callsreleaseVoicePlayer()instead ofvoicePlayer.release().- Testability:
LotoApplication/ExoVoicePlayerneed a realContext(no Robolectric in this project —testOptions.unitTestsonly setsisReturnDefaultValues), so the recreate-after-release contract is extracted intoVoicePlayerHolderand unit-tested in isolation (VoicePlayerHolderTest.kt, 4 tests) againstFakeVoicePlayer. TheLotoApplication/MainActivitywiring itself stays unverified by a JVM test, same residual gap the reviewer already flagged — out of scope to close without adding Robolectric.
M-2 — settings/loaded sibling-collector race
Replaced the two independent stateIn collectors PlayerBoardViewModel
depended on (settingsState: StateFlow<Settings> + a separate loaded
boolean) with one: LotoApplication.settingsOrNull: StateFlow<Settings?>
(null = not loaded), the sole collector over settingsRepository.settingsFlow.
settingsState (used by MainActivity, MasterPanelViewModel,
SettingsViewModel, SettingsSheetTest) is now derived from
settingsOrNull rather than an independent collection, removing the
top-level race for everyone, not just the reported call site.
PlayerBoardViewModel now takes settingsOrNull directly and reads
settingsOrNull.value for both "is it loaded" and "what is the mode" —
by construction the same snapshot, so they can never disagree.
LotoViewModelFactory updated to wire app.settingsOrNull (+
app.settingsRepository.defaults as the pre-load fallback for the rare
onCellClick-on-a-restored-card-before-settings-load path).
M-3 — generate()/clearMarks() bypass the settings gate
PlayerUiState.loadingnow folds in settings-loaded, not just restore:publishLoading() = !restoreDone || settingsOrNull.value == null, called fromrestore()and from theinitcombine's collector (which now combinesmasterStore.statewithsettingsOrNulldirectly — settings changes still re-triggeronMasterState, same as before). The existing UI gate (state.loadingdisables both buttons) now also covers the settings-unresolved window.- Defense in depth, mirroring
restore()'s own H1 guard:generate()andclearMarks()early-return (no-op) whilesettingsOrNull.value == null, so a caller that acts before the UI gate resolves can't corrupt the auto-cross cursor under a placeholder mode. - Updated the M4-residual test in
PlayerBoardViewModelTest.ktto actually exercise the ordering the review said it dodged:masterStore.restore()now resolves beforegenerate()is tapped (previously the reverse), asserting the tap while gated is a no-op and the full replay lands once settings resolve and the user taps again. The other ordering test ("a settings arrival that lands before masterStore's restore...") is unchanged/kept. - New:
clearMarks() is also a no-op while settings have not resolved yetandloading stays true while settings have not resolved... (M3).
M-4 — non-IO settings read failure deadlocks the gate
SettingsRepository.settingsFlow's catch now falls back to
emptyPreferences() (→ defaults) for any read failure, not only
IOException — the fail-open fix applies at the source now that
LotoApplication.settingsOrNull is the sole downstream collector (a fix
scoped only to the separate loaded: Flow<Boolean>, as the review's
snippet suggested, would no longer reach the actual production consumer
after the M2 unification). loaded is kept as a plain derived signal for
tests/other consumers; it no longer needs its own .catch since upstream
never throws.
ThrowingDataStore gained an optional exception: Throwable constructor
param (defaults to IOException, unchanged for existing callers) so tests
can exercise a non-IO failure. Added
non-IO failure on read still falls back to defaults... and
loaded still flips true after a non-IO read failure to
SettingsRepositoryTest.kt.
Low — backup .tmp sibling
Added datastore/loto_game_state.preferences_pb.tmp exclusion alongside the
existing .preferences_pb one in both data_extraction_rules.xml and
backup_rules.xml.
Low — generate() double-tap guard
Added private var generating to PlayerBoardViewModel: generate() no-ops
while a previous call's off-main compute hasn't resolved yet; also folded
into restore()'s clobber guard (!generating) per the review's "participates
in the restore guard" suggestion, since grid alone can't detect an
in-flight generation. Test: a second rapid generate() tap while the first is still computing is a no-op (L-a), asserting FakeVoicePlayer.cancel()
(new cancelCount counter) fires only once.
Files Modified
android/app/src/main/java/com/miti99/loto/audio/VoicePlayerHolder.kt(new)android/app/src/main/java/com/miti99/loto/LotoApplication.ktandroid/app/src/main/java/com/miti99/loto/MainActivity.ktandroid/app/src/main/java/com/miti99/loto/settings/SettingsRepository.ktandroid/app/src/main/java/com/miti99/loto/state/LotoViewModelFactory.ktandroid/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.ktandroid/app/src/main/res/xml/data_extraction_rules.xmlandroid/app/src/main/res/xml/backup_rules.xmlandroid/app/src/test/java/com/miti99/loto/audio/VoicePlayerHolderTest.kt(new)android/app/src/test/java/com/miti99/loto/audio/FakeVoicePlayer.ktandroid/app/src/test/java/com/miti99/loto/ThrowingDataStore.ktandroid/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.ktandroid/app/src/test/java/com/miti99/loto/state/PlayerBoardViewModelTest.kt
Untouched by me but pre-existing modified/untracked in the working tree
(part of the earlier fix cycle under review, not this task): build.gradle.kts,
AndroidManifest.xml, GameStateRepository.kt, GameStateRepositoryTest.kt,
MasterPanelViewModel.kt, MasterPanelViewModelTest.kt, MasterStore.kt,
MasterStoreTest.kt, SlowDataStore.kt, various ui/* files, strings.xml.
Tests Status
JAVA_HOME=jdk-21 ./gradlew :app:testDebugUnitTest— BUILD SUCCESSFUL, 129 tests, 0 failures, 0 errors (was 120; +9 new: 4VoicePlayerHolderTest, 3PlayerBoardViewModelTest, 2SettingsRepositoryTest).JAVA_HOME=jdk-21 ./gradlew :app:lintDebug— BUILD SUCCESSFUL, 0 errors, 17 warnings (all pre-existing: dependency-version notices, one Vietnamese string typo heuristic, two plurals-candidate heuristics,enableOnBackInvokedCallbackAPI-33 note — none touch the changed files).
Issues Encountered
None. No file-ownership conflicts (single-agent task). local.properties
was not read.
Unresolved Questions
- H-1: the
LotoApplication/MainActivityglue aroundVoicePlayerHolder(realfinish()+ relaunch through a realApplication/Context) is still not exercised by any automated test — this project has no Robolectric dependency, and adding one was out of scope. The holder's own recreate-after-release contract is unit-tested; the wiring is equivalent to what the reviewer already called out as untested. fallbackSettingsforPlayerBoardViewModel(used only ifonCellClickfires on a restored card before settings ever resolve) is wired to the real manifest-derived defaults in production (app.settingsRepository.defaults) and to a placeholderSettings(voice = "")in the constructor's own default parameter (never hit by existing tests, all of which pass a non-null-typed settings flow). Flagging this only so the placeholder default doesn't surprise a future reader who instantiatesPlayerBoardViewModelwith a null-capable flow without supplyingfallbackSettings.
Status: DONE Summary: Fixed H1 (recreatable voice player holder), M2 (single settingsOrNull source of truth), M3 (settings-loaded folded into loading + generate()/clearMarks() guards, test reordered to hit the previously-dodged case), M4 (fail-open on any settingsFlow read failure), plus the backup .tmp exclusion and a generate() double-tap/in-flight guard; all 129 unit tests and lint pass clean.