From 194a51abda4e316971a998c969c7c51e7bdc0772 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 31 Aug 2026 13:28:48 +0700 Subject: [PATCH] chore(plans): record the native rewrite execution, QA log, and review Phase statuses, API 36 QA results, review follow-ups in todo.md, and the session journal. --- plans/260429-1511-loto-upstream-sync/plan.md | 8 + .../phase-01-start.md | 91 +++++ .../phase-02-game-logic-core.md | 78 ++++ .../phase-03-audio-subsystem.md | 84 +++++ ...-04-settings-and-game-state-persistence.md | 95 +++++ .../phase-05-player-board-ui.md | 78 ++++ .../phase-06-master-panel-ui.md | 79 ++++ .../phase-07-settings-ui.md | 73 ++++ ...hase-08-platform-behaviors-and-branding.md | 84 +++++ .../phase-09-wrapper-removal-and-ci-rewire.md | 84 +++++ .../phase-10-qa-and-release.md | 105 ++++++ .../plan.md | 184 +++++++++ ...1-native-android-rewrite-implementation.md | 68 ++++ ...-31-plan-native-android-rewrite-of-loto.md | 32 ++ ...-reviewer-260831-native-android-rewrite.md | 357 ++++++++++++++++++ plans/todo.md | 112 +++--- 16 files changed, 1555 insertions(+), 57 deletions(-) create mode 100644 plans/260831-1131-native-android-rewrite/phase-01-start.md create mode 100644 plans/260831-1131-native-android-rewrite/phase-02-game-logic-core.md create mode 100644 plans/260831-1131-native-android-rewrite/phase-03-audio-subsystem.md create mode 100644 plans/260831-1131-native-android-rewrite/phase-04-settings-and-game-state-persistence.md create mode 100644 plans/260831-1131-native-android-rewrite/phase-05-player-board-ui.md create mode 100644 plans/260831-1131-native-android-rewrite/phase-06-master-panel-ui.md create mode 100644 plans/260831-1131-native-android-rewrite/phase-07-settings-ui.md create mode 100644 plans/260831-1131-native-android-rewrite/phase-08-platform-behaviors-and-branding.md create mode 100644 plans/260831-1131-native-android-rewrite/phase-09-wrapper-removal-and-ci-rewire.md create mode 100644 plans/260831-1131-native-android-rewrite/phase-10-qa-and-release.md create mode 100644 plans/260831-1131-native-android-rewrite/plan.md create mode 100644 plans/journals/2026-08-31-native-android-rewrite-implementation.md create mode 100644 plans/journals/2026-08-31-plan-native-android-rewrite-of-loto.md create mode 100644 plans/reports/code-reviewer-260831-native-android-rewrite.md diff --git a/plans/260429-1511-loto-upstream-sync/plan.md b/plans/260429-1511-loto-upstream-sync/plan.md index 465985a..f09a9cd 100644 --- a/plans/260429-1511-loto-upstream-sync/plan.md +++ b/plans/260429-1511-loto-upstream-sync/plan.md @@ -1,5 +1,13 @@ # Loto upstream sync — 2026-04-29 +> **SUPERSEDED (2026-08-31)** by +> [260831-1131-native-android-rewrite](../260831-1131-native-android-rewrite/plan.md). +> This plan targeted the retired 2026-04 native port (baseline `fe52232`), +> which is not being resurrected — the rewrite starts fresh. Phases 01–04 +> remain useful as behavioral specs (confetti tiers, chờ ring, palette, +> per-mode hint copy) and are referenced from the new plan. No phase here +> will be executed. + Pull 6 logic/UX changes from `tiennm99/loto` (web) into the Android port. Behavior-faithful where the platform allows; idiomatic Compose where it matters. diff --git a/plans/260831-1131-native-android-rewrite/phase-01-start.md b/plans/260831-1131-native-android-rewrite/phase-01-start.md new file mode 100644 index 0000000..24c6982 --- /dev/null +++ b/plans/260831-1131-native-android-rewrite/phase-01-start.md @@ -0,0 +1,91 @@ +--- +phase: 1 +title: "Project Scaffold and Build Foundation" +status: done +priority: P1 +effort: "1d" +dependencies: [] +--- + +# Phase 1: Project Scaffold and Build Foundation + +## Overview + +Stand up a fresh, CI-buildable native Android project in a temporary +`android-native/` directory (renamed to `android/` in Phase 9, after the +wrapper is deleted). Everything downstream builds on this skeleton. + +## Requirements + +- Functional: `./gradlew :app:lint :app:test :app:assembleDebug` and + `:app:bundleRelease` (with signing env set) succeed locally. +- Non-functional: no `INTERNET` permission; reproducible builds via + version catalog; release build minified with R8. + +## Architecture + +- Single Gradle project, single `:app` module, Kotlin DSL + (`settings.gradle.kts`, `app/build.gradle.kts`, + `gradle/libs.versions.toml`). +- Kotlin 2.x, Compose BOM (latest stable), Material 3, activity-compose, + lifecycle-viewmodel-compose, kotlinx-coroutines, DataStore Preferences, + Media3 ExoPlayer. Test deps: JUnit4, kotlinx-coroutines-test, Turbine, + compose-ui-test-junit4. +- Manual DI: an `Application` subclass owns singletons (settings repo, + audio player); ViewModels built via a small factory. No Hilt. +- `applicationId com.miti99.loto`, `versionCode 7`, `versionName "0.2.0"`, + minSdk 24, targetSdk 36, compileSdk 36, JDK 21 toolchain. +- Release signing config reads `LOTO_KEYSTORE_PATH`, + `LOTO_KEYSTORE_PASSWORD`, `LOTO_KEY_ALIAS`, `LOTO_KEY_PASSWORD` env vars + — same contract as the wrapper (`android/android/app/build.gradle` today) + so CI secrets and local workflow are unchanged. Unsigned debug builds + must not require them. +- Audio assets are NOT copied: `android { sourceSets["main"].assets.srcDir("../../web/static/audio") }` + (exact relative path fixed when the dir moves in Phase 9) — mounts the + 184 MP3s + `manifest.json` under `assets/audio/` at build time. + +## Related Code Files + +- Create: `android-native/settings.gradle.kts`, `android-native/build.gradle.kts`, + `android-native/gradle/libs.versions.toml`, `android-native/gradle.properties`, + `android-native/gradlew*`, `android-native/app/build.gradle.kts`, + `android-native/app/src/main/AndroidManifest.xml`, + `android-native/app/src/main/java/com/miti99/loto/LotoApplication.kt`, + `android-native/app/src/main/java/com/miti99/loto/MainActivity.kt` (empty Compose shell), + `android-native/app/proguard-rules.pro`, `android-native/.gitignore` +- Reference (do not copy code): wrapper Gradle files under + `android/android/`, old port `f7cbb6e:android/app/build.gradle.kts` + +## Implementation Steps + +1. Generate the Gradle skeleton (wrapper scripts pinned to a current + Gradle 8.x; verify against Kotlin/AGP compatibility matrix). +2. Fill `libs.versions.toml`; wire Compose BOM; enable + `kotlin.jvmToolchain(21)`. +3. Manifest: portrait-friendly single activity, `android:supportsRtl` + left default-off unless spec says otherwise, `VIBRATE` permission, + **no** `INTERNET`; declare `touchscreen`/`faketouch`/screen features + optional (Play + emulator filtering, carried from wrapper manifest). +4. Signing config + release build type (minify on, shrinkResources on); + assert debug build works without signing env. +5. Assets srcDir wired to `web/static/audio`; sanity-check an + `assets.list("audio")` in a unit-less smoke check or manual apk + inspection (`unzip -l`). +6. Placeholder `MainActivity` renders a "Lô tô" Compose text; smoke + instrumentation test compiles (execution deferred to Phase 10). + +## Success Criteria + +- [ ] `assembleDebug` + `lint` + `test` pass locally without signing env +- [ ] `bundleRelease` signs with the miti99-apps.p12 keystore env contract +- [ ] `aapt dump permissions` shows only `VIBRATE` +- [ ] APK contains `assets/audio/hoai-my/1.mp3` … `assets/audio/manifest.json` (184 MP3s, 2 voices) + +## Risk Assessment + +- **AGP/Kotlin/Compose version drift** — pick the latest stable trio at + implementation time; if the catalog resolution fails in CI, pin to the + versions Android Studio's new-project template currently emits. +- **assets srcDir relative path** breaks when `android-native/` → + `android/` (Phase 9). Signal: missing-asset crash on Phase 9 CI run. + Response: path is defined in one place; Phase 9 checklist updates it. diff --git a/plans/260831-1131-native-android-rewrite/phase-02-game-logic-core.md b/plans/260831-1131-native-android-rewrite/phase-02-game-logic-core.md new file mode 100644 index 0000000..42255f6 --- /dev/null +++ b/plans/260831-1131-native-android-rewrite/phase-02-game-logic-core.md @@ -0,0 +1,78 @@ +--- +phase: 2 +title: "Game Logic Core" +status: done +priority: P1 +effort: "1d" +dependencies: [1] +--- + +# Phase 2: Game Logic Core + +## Overview + +Port the pure game rules from `web/src/lib/game-logic.js` and the draw +deck from `master-store.svelte.js` into dependency-free Kotlin, with the +web test suites ported alongside. No Android APIs in this phase — plain +Kotlin, fully unit-testable on the JVM. + +## Requirements + +- Functional: byte-for-byte behavioral parity with the web logic — + read `web/src/lib/game-logic.js` and `game-logic.test.js` line by line + before writing Kotlin; every web test case has a Kotlin twin. +- Non-functional: no `android.*` imports; deterministic under injected + `kotlin.random.Random` for tests. + +## Architecture + +Package `com.miti99.loto.game`: + +- `CardGenerator.kt` — the 9×9 player card: exactly 5 numbers per row + AND per column, column `c` holds numbers from its decade + (ones-digit-aligned ranges as the web does it), plus the web's soft + constraint (rejection-sampling against 3+ consecutive fully-filled + columns — confirm the exact rule from `generateGrid()` before coding). +- `PlayerCard.kt` — immutable grid model + crossed/manual-untick state, + `findUncrossedCell`, `isRowComplete`, `getWaitingNumber` (chờ = row + has 4 of 5 crossed; kinh = row complete — row-based, not full-card). +- `DrawDeck.kt` — 1..90 shuffle, `drawNext()`, called list, remaining + count, reset; forward-only semantics matching `drawNext()` in + `master-store.svelte.js`. +- All models are plain data classes; serialization concerns live in + Phase 4. + +## Related Code Files + +- Create: `app/src/main/java/com/miti99/loto/game/CardGenerator.kt`, + `.../game/PlayerCard.kt`, `.../game/DrawDeck.kt` +- Create tests: `app/src/test/java/com/miti99/loto/game/CardGeneratorTest.kt`, + `PlayerCardTest.kt`, `DrawDeckTest.kt` +- Spec: `web/src/lib/game-logic.js`, `game-logic.test.js`, + `master-store.svelte.js`, `master-store.test.js` + +## Implementation Steps + +1. Read the four spec files; extract every invariant the web tests + assert into a checklist inside the test files (as test names, not + comments). +2. Implement `CardGenerator` with injectable `Random`; property-style + test: 1000 generated cards all satisfy row/column/decade invariants. +3. Implement `PlayerCard` state transitions (cross, un-cross with + manual-untick tracking, auto-cross candidate lookup). +4. Implement `DrawDeck`; test shuffle coverage, exhaustion (remaining 0), + reset, monotonic call order. +5. Port remaining web test cases one-to-one. + +## Success Criteria + +- [ ] `./gradlew :app:test` green +- [ ] Every test case in `game-logic.test.js` + `master-store.test.js` has a named Kotlin counterpart (list the mapping in the PR description) +- [ ] 1000-card property test passes the row/col/decade invariants + +## Risk Assessment + +- **Silent spec divergence** (e.g. misreading the column-run soft + constraint). Signal: a web test case with no Kotlin twin, or a twin + asserting different values. Response: the one-to-one mapping list in + the PR is the gate — reviewer rejects if a case is missing. diff --git a/plans/260831-1131-native-android-rewrite/phase-03-audio-subsystem.md b/plans/260831-1131-native-android-rewrite/phase-03-audio-subsystem.md new file mode 100644 index 0000000..d45f3b1 --- /dev/null +++ b/plans/260831-1131-native-android-rewrite/phase-03-audio-subsystem.md @@ -0,0 +1,84 @@ +--- +phase: 3 +title: "Audio Subsystem" +status: done +priority: P1 +effort: "1d" +dependencies: [1] +--- + +# Phase 3: Audio Subsystem + +## Overview + +Play the bundled Vietnamese voice clips (2 voices × 92 clips: 1–90 + +`cho` + `kinh`, mounted from `web/static/audio` in Phase 1) with the same +sequencing and cancellation semantics as `web/src/lib/voice.js`. + +## Requirements + +- Functional: announce a drawn number; announce "Chờ" (optionally + followed by the awaited number — the `voiceWaitingNumber` setting); + announce "Kinh"; switching voice takes effect on the next utterance; + a new utterance cancels any in-flight one (token semantics of + `voice.js`). +- Non-functional: no audio focus grab beyond transient playback + (`plans/todo.md` flags the unanswered incoming-call question — decide + and document here: use `AudioAttributes` USAGE_MEDIA + + `setHandleAudioBecomingNoisy`, request no focus or transient-may-duck; + record the choice in the code and android README); no INTERNET. + +## Architecture + +Package `com.miti99.loto.audio`: + +- `VoiceCatalog.kt` — parses `assets/audio/manifest.json` + (`{voices: [{id, edgeName, label, gender}]}`, same file the web + imports); exposes `VOICES`, default voice id, and + `clipPath(voiceId, clip)` where clip ∈ 1..90 | cho | kinh. Malformed + manifest → hardcoded fallback to `hoai-my`/`nam-minh` (mirror the web's + defensive posture). +- `VoicePlayer.kt` — wraps a single Media3 ExoPlayer on the main thread; + `speak(vararg clips)` builds a gapless playlist (e.g. `cho` + `34`) + from `asset:///audio/...` URIs; each call increments a token and stops + the previous playback. `release()` from Application/Activity teardown. +- Interface `VoicePlayerApi` so ViewModels depend on an abstraction and + unit tests use a recording fake; the ExoPlayer implementation is + covered by a small instrumentation test (runs in Phase 10's device + pass). + +## Related Code Files + +- Create: `app/src/main/java/com/miti99/loto/audio/VoiceCatalog.kt`, + `.../audio/VoicePlayer.kt` +- Create tests: `app/src/test/java/com/miti99/loto/audio/VoiceCatalogTest.kt` + (JSON parse + fallback), fake player for ViewModel tests +- Spec: `web/src/lib/voice.js`, `voice.test.js`, + `web/src/lib/audio-manifest.js`, `web/static/audio/manifest.json` + +## Implementation Steps + +1. Read `voice.js` + its test; write down the cancellation token rules + and the exact chờ+number sequence gating (`voiceEnabledPlayer`, + `voiceWaitingNumber`). +2. Implement `VoiceCatalog` + parse test against the real manifest file + (read from test resources via the same relative path). +3. Implement `VoicePlayer` (ExoPlayer, playlist, token cancellation). +4. Wire construction in `LotoApplication`; release in `onTerminate` + path/activity destroy as appropriate. + +## Success Criteria + +- [ ] Catalog test parses the real `manifest.json` and falls back on garbage input +- [ ] Fake-player unit tests assert sequencing + cancellation token rules +- [ ] Manual emulator check: number clip plays; rapid draws cut each other off; chờ+N plays gaplessly (deferred to Phase 10 checklist) + +## Risk Assessment + +- **ExoPlayer `asset:///` quirks on API 24** — the old port's research + already picked ExoPlayer over SoundPool (decision record: + `git show f7cbb6e:android/plans/todo.md`; the standalone research + reports no longer exist). Signal: playback failure on + the API 24 emulator in Phase 10. Response: fall back to + `AssetFileDescriptor` + `ProgressiveMediaSource`, or MediaPlayer for + single clips — decide then, not now. diff --git a/plans/260831-1131-native-android-rewrite/phase-04-settings-and-game-state-persistence.md b/plans/260831-1131-native-android-rewrite/phase-04-settings-and-game-state-persistence.md new file mode 100644 index 0000000..17b755b --- /dev/null +++ b/plans/260831-1131-native-android-rewrite/phase-04-settings-and-game-state-persistence.md @@ -0,0 +1,95 @@ +--- +phase: 4 +title: "Settings and Game State Persistence" +status: done +priority: P1 +effort: "1d" +dependencies: [1, 2] +--- + +# Phase 4: Settings and Game State Persistence + +## Overview + +Implement the settings contract (DataStore) and round-state persistence +(card, crossed cells, manual unticks, master deck) so a process-death or +device reboot restores the exact game, matching what the web persists in +`localStorage` (`loto*` / `loto_master` keys). + +## Requirements + +- Functional: every field of the web `DEFAULT_SETTINGS` exists with the + same defaults and validation: + `emptyCellColor "#7030A0"` (hex6-validated), `theme auto|light|dark`, + `mode player|master|both` (default player), `autoCallEnabled false`, + `autoCallSpeed 5` (int 1..10), `voiceEnabledMaster true`, + `voiceEnabledPlayer false`, `voiceWaitingNumber false`, + `voice `, `boardTextScale 1` + (∈ {0.9, 1, 1.15, 1.3}). + Invalid stored values fall back per-field to defaults (the web's + per-field `valid*` ?? default pattern), never wholesale reset. + The web's legacy `masterMode` migration is web-only — do NOT port it; + the native store starts clean. +- Functional: round state survives process death — player grid, crossed + set, manual unticks, master called-list/deck order, and whether a round + is live. +- Non-functional: main-thread-safe (DataStore is async); ViewModels read + settings as `StateFlow`. + +## Architecture + +Package `com.miti99.loto.settings` + `com.miti99.loto.state`: + +- `SettingsRepository.kt` — DataStore Preferences; typed accessors + + per-field validation on read; `settingsFlow: Flow`; + `reset()`. +- `Settings.kt` — immutable data class mirroring DEFAULT_SETTINGS. +- `GameStateRepository.kt` — second DataStore (or the same with + namespaced keys) persisting the Phase-2 models; serialize grid/crossed + as compact strings (e.g. CSV of 45 numbers + bitmask) — no kotlinx + serialization dependency unless it pays for itself. +- ViewModels (`PlayerBoardViewModel`, `MasterPanelViewModel`, + `SettingsViewModel`) in `com.miti99.loto.state`, constructed by the + Phase-1 factory; expose `StateFlow` UI state consumed by Phases 5–7. + Master→player call propagation via a shared flow owned by the + Application scope (the web's store coupling, the old port called it + CallBus — same idea, fresh code). + +## Related Code Files + +- Create: `app/src/main/java/com/miti99/loto/settings/Settings.kt`, + `SettingsRepository.kt`; + `app/src/main/java/com/miti99/loto/state/GameStateRepository.kt`, + `PlayerBoardViewModel.kt`, `MasterPanelViewModel.kt`, + `SettingsViewModel.kt` +- Create tests: repository round-trip + validation tests (Robolectric or + in-memory DataStore), ViewModel tests with `runTest` + + `advanceTimeBy` for auto-call cadence, Turbine for flows +- Spec: `web/src/lib/settings-store.svelte.js`, `settings-store.test.js`, + `game-logic.js` save/load functions, `master-store.svelte.js` + +## Implementation Steps + +1. Read the spec files; table every persisted key and its validation. +2. Implement `Settings` + `SettingsRepository` with per-field fallback; + tests for each invalid-value case in `settings-store.test.js`. +3. Implement `GameStateRepository` round-trip; test corrupt-payload + fallback (fresh round, never crash). +4. Implement the three ViewModels against fakes (deck, voice player, + repos). Auto-call: coroutine ticker honoring `autoCallSpeed`, + cancel/restart on speed change without double-ticking (test with + virtual time), stop when remaining == 0. +5. Wire everything in `LotoApplication` + factory. + +## Success Criteria + +- [ ] Settings round-trip test: write all fields, recreate repo, read back identical +- [ ] Every invalid-value case from `settings-store.test.js` has a Kotlin twin +- [ ] Kill-and-restore test (Robolectric): live round state fully restored +- [ ] Auto-call virtual-time tests: cadence, mid-run speed change, exhaustion stop + +## Risk Assessment + +- **State model churn** once UI phases start. Signal: Phase 5/6 needs a + field the UI state doesn't carry. Response: add it here first with a + test — UI phases never reach into repositories directly. diff --git a/plans/260831-1131-native-android-rewrite/phase-05-player-board-ui.md b/plans/260831-1131-native-android-rewrite/phase-05-player-board-ui.md new file mode 100644 index 0000000..95ba403 --- /dev/null +++ b/plans/260831-1131-native-android-rewrite/phase-05-player-board-ui.md @@ -0,0 +1,78 @@ +--- +phase: 5 +title: "Player Board UI" +status: done +priority: P1 +effort: "1.5d" +dependencies: [2, 3, 4] +--- + +# Phase 5: Player Board UI + +## Overview + +The player-facing screen: the 9×9 card, tap-to-cross with haptics, +auto-cross, the chờ (waiting) presentation, and the Kinh (bingo) +celebration. Compose throughout, driven by `PlayerBoardViewModel`. + +## Requirements + +- Functional parity with `PlayerBoard.svelte` + the superseded upstream- + sync plan's specs (read `plans/260429-1511-loto-upstream-sync/` + phases 01–02 for the confetti tier rule and the chờ ring on the label + band before styling): + - 9×9 grid, empty cells tinted with the user's `emptyCellColor`, + numbers scaled by `boardTextScale`. + - Tap toggles cross; un-crossing a called number records a manual + untick (auto-cross must not re-cross it — Phase 2 logic). + - Auto-cross setting crosses the drawn number automatically. + - Chờ: ring/highlight on the waiting row's label band + toast; + optional voice "Chờ" (+ awaited number when `voiceWaitingNumber`). + - Kinh: modal with celebration copy, confetti (tiered per the + upstream-sync spec), voice "Kinh"; dismissible; idempotent (no + re-trigger on recomposition). + - "Tạo bảng mới" (new card) flow with confirm when a round is live — + match web copy exactly. +- Non-functional: haptics via `VIBRATE` on cell tap, suppressed when the + system reduced-motion/haptics-off signals say so (mirror wrapper + behavior recorded in `plans/todo.md` QA list); 60fps grid on API 24 + hardware (no per-cell recomposition storms — stable keys, immutable + cell state). + +## Architecture + +Package `com.miti99.loto.ui.player`: `PlayerBoardScreen.kt` (state +hookup), `PlayerCardGrid.kt`, `PlayerCell.kt`, `ChoIndicator.kt`, +`KinhDialog.kt`, `Confetti.kt` (Canvas-based; keep it small). Vietnamese +strings in `res/values/strings.xml` (default locale = vi copy, no +translation indirection yet — i18n is a non-goal). + +## Related Code Files + +- Create: files above + `app/src/test/.../PlayerBoardViewModelTest.kt` + additions, Compose UI test `PlayerBoardScreenTest.kt` (grid renders, + tap crosses, kinh dialog appears on completed row) +- Spec: `web/src/lib/PlayerBoard.svelte`, `player-auto-cross.js` + test, + `web/src/app.css` (cell colors/dims), superseded plan phases 01–03 + +## Implementation Steps + +1. Read the Svelte component + CSS; write a short visual-parity note + (colors, spacing, states) at the top of `PlayerCardGrid.kt`. +2. Build the static grid from ViewModel state; wire tap/haptics. +3. Chờ presentation (ring + toast) from `getWaitingNumber`. +4. Kinh dialog + confetti + voice trigger; idempotency guard tested. +5. New-card flow with live-round confirm. +6. Compose UI tests for the three critical interactions. + +## Success Criteria + +- [ ] Compose tests: tap-cross, chờ indicator, kinh dialog +- [ ] Manual untick never re-crossed by auto-cross (ViewModel test) +- [ ] Visual spot-check vs the web app side by side (screenshot pair in PR) + +## Risk Assessment + +- **Confetti performance on low-end devices.** Signal: jank on the API 24 + emulator. Response: cap particle count / drop confetti below API 26 — + cosmetic, cut before optimizing. diff --git a/plans/260831-1131-native-android-rewrite/phase-06-master-panel-ui.md b/plans/260831-1131-native-android-rewrite/phase-06-master-panel-ui.md new file mode 100644 index 0000000..29247d3 --- /dev/null +++ b/plans/260831-1131-native-android-rewrite/phase-06-master-panel-ui.md @@ -0,0 +1,79 @@ +--- +phase: 6 +title: "Master Panel UI" +status: done +priority: P1 +effort: "1.5d" +dependencies: [2, 3, 4] +--- + +# Phase 6: Master Panel UI + +## Overview + +The host (quản trò) surface: draw button / auto-call controls, the +current-number hero, called history, and the 11×9 ones-digit master +tracking board. Shown alone (`mode master`) or inline below the player +card (`mode both`), exactly as the web lays it out. + +## Requirements + +- Functional parity with `MasterPanel.svelte`, `MasterEmptyState.svelte`, + `AutoCountdown.svelte`: + - "Xổ số" draws one number; with `autoCallEnabled` the button becomes + Bắt đầu/Dừng and a countdown ticks at `autoCallSpeed` seconds + (visible countdown, matching `AutoCountdown.svelte`). + - Current-number hero (large display), called history (most recent + first or web order — copy the web), remaining count. + - 11×9 master board, ones-digit-aligned (90 sits at row 10 / col 8 — + the old port's `MasterBoardLayoutTest` documented these edge cells; + re-derive from the web source, then keep that test idea). + - Empty state before the first draw (ghost preview per + `MasterEmptyState.svelte`). + - Reset ("ván mới") with confirm; resets deck + hero + history and + broadcasts to the player board per web behavior. + - Voice announce on draw when `voiceEnabledMaster`; per-mode hint copy + (superseded plan phase 04 documents the exact strings). +- Non-functional: auto-call keeps ticking with the app foregrounded and + screen kept on (Phase 8 owns the keep-screen-on flag itself). + +## Architecture + +Package `com.miti99.loto.ui.master`: `MasterPanelScreen.kt`, +`CurrentNumberHero.kt`, `MasterBoard.kt`, `MasterControls.kt`, +`CalledHistory.kt`, `MasterEmptyState.kt`. Layout composes with the +player board in a shared scrollable root (`LotoAppRoot.kt` in +`com.miti99.loto.ui`) switching on `settings.mode`. + +## Related Code Files + +- Create: files above + Compose test `MasterPanelScreenTest.kt` + (draw updates hero + history; auto-call toggle relabels button), + `MasterBoardLayoutTest.kt` (unit: ones-digit cell mapping incl. 90) +- Spec: `web/src/lib/MasterPanel.svelte`, `MasterEmptyState.svelte`, + `AutoCountdown.svelte`, `master-store.svelte.js`, superseded plan + phase 04 (hint copy), `web/src/app.css` + +## Implementation Steps + +1. Read the three Svelte components; note layout, copy, and state + transitions (start/stop/reset edge cases: stop on exhaustion, confirm + on reset mid-round). +2. Build `MasterBoard` mapping + unit test first (pure function). +3. Hero, history, controls wired to `MasterPanelViewModel`. +4. Auto-call UI: countdown display, start/stop, disabled states. +5. Root layout switching on mode (player / master / both). +6. Compose tests for draw + auto-call toggle. + +## Success Criteria + +- [ ] Ones-digit mapping unit test green (incl. the 90 edge cell) +- [ ] Compose tests: draw updates hero/history; toggle relabels; reset confirm +- [ ] Mode both shows player card above master panel, single scroll — matches web + +## Risk Assessment + +- **Auto-call drift vs Phase 4 ticker.** UI must render the ViewModel's + countdown, never run its own timer. Signal: two counters visible or + countdown jumps on recomposition. Response: ViewModel exposes + `secondsUntilNextCall` in UI state; UI is render-only. diff --git a/plans/260831-1131-native-android-rewrite/phase-07-settings-ui.md b/plans/260831-1131-native-android-rewrite/phase-07-settings-ui.md new file mode 100644 index 0000000..077fab0 --- /dev/null +++ b/plans/260831-1131-native-android-rewrite/phase-07-settings-ui.md @@ -0,0 +1,73 @@ +--- +phase: 7 +title: "Settings UI" +status: done +priority: P1 +effort: "1d" +dependencies: [4, 5, 6] +--- + +# Phase 7: Settings UI + +## Overview + +The settings sheet (gear button, top-right as on the web): every field of +the Phase-4 `Settings` model gets a control, matching the web's +`SettingsButton.svelte` sheet content and Vietnamese copy. + +## Requirements + +- Functional — controls for: + - Chế độ (mode): player / master / both — segmented or radio. + - Giọng đọc (voice): picker over `VoiceCatalog.VOICES` labels; master + voice toggle, player voice toggle, chờ-number sub-toggle (enabled + only when player voice is on — copy the web's gating). + - Tự động gạch (auto-cross) toggle. + - Auto-call: enable toggle + speed slider/stepper 1..10s. + - Giao diện (theme): auto / light / dark. + - Màu ô trống (empty cell color): preset swatches + custom picker + (the web ships Excel-preset swatches and RGB input — mirror the + presets; a full RGB slider is in-scope only if the web sheet has it + today: verify `SettingsButton.svelte` first). + - Cỡ chữ bảng (board text scale): 0.9 / 1 / 1.15 / 1.3 rungs. + - Đặt lại (reset settings) with confirm. +- Every change applies immediately (live recomposition) and persists via + `SettingsViewModel` — no save button, same as the web. +- Non-functional: sheet is dismissible by back gesture (handled in + Phase 8's back stack) and scrollable on small screens. + +## Architecture + +Package `com.miti99.loto.ui.settings`: `SettingsSheet.kt` (ModalBottomSheet) ++ small control composables (`VoicePicker.kt`, `EmptyCellColorPicker.kt`, +`BoardTextScalePicker.kt`, …). Gear entry point lives in the Phase-6 root +top bar. + +## Related Code Files + +- Create: files above + Compose test `SettingsSheetTest.kt` (change mode + → root layout switches; change scale → grid text scales) +- Spec: `web/src/lib/SettingsButton.svelte`, + `settings-store.svelte.js`, superseded plan phase 03 (solid-color + palette), `web/src/app.css` + +## Implementation Steps + +1. Read `SettingsButton.svelte` end to end; inventory every control, + its copy, and its gating (write the inventory into the PR). +2. Build the sheet skeleton + wire `SettingsViewModel`. +3. Implement controls in the web's order; reuse Material 3 components, + no custom widgets unless the web design demands it. +4. Compose tests for the two cross-cutting settings (mode, text scale). + +## Success Criteria + +- [ ] Every `Settings` field reachable and live-applied from the sheet +- [ ] Copy matches the web sheet (Vietnamese, exact strings) +- [ ] Compose tests green + +## Risk Assessment + +- **Scope creep in the color picker.** Signal: building a color wheel. + Response: ship exactly what the web sheet has — presets (+ RGB rows if + present on the web today), nothing more. diff --git a/plans/260831-1131-native-android-rewrite/phase-08-platform-behaviors-and-branding.md b/plans/260831-1131-native-android-rewrite/phase-08-platform-behaviors-and-branding.md new file mode 100644 index 0000000..fc23157 --- /dev/null +++ b/plans/260831-1131-native-android-rewrite/phase-08-platform-behaviors-and-branding.md @@ -0,0 +1,84 @@ +--- +phase: 8 +title: "Platform Behaviors and Branding" +status: done +priority: P1 +effort: "1d" +dependencies: [5, 6, 7] +--- + +# Phase 8: Platform Behaviors and Branding + +## Overview + +Everything that made the wrapper feel like a real Android app, now +native-first: keep-screen-on, back handling, theming, splash, launcher +icons, edge-to-edge. The wrapper-era device-QA list in `plans/todo.md` +is the acceptance spec. + +## Requirements + +- **Keep screen on while a round is live** (auto-call runs untouched for + 15+ min): `FLAG_KEEP_SCREEN_ON` (or Modifier equivalent) held while + `MasterPanelViewModel` reports a live round in master/both mode; + released on round end. Player-only mode never holds it (web parity). +- **Back behavior:** back closes the top overlay first (Kinh dialog, + settings sheet — Compose dialogs/sheets get this free; verify), and at + root shows the Vietnamese exit confirm ("Ở lại" keeps state) via + `OnBackPressedCallback`/`BackHandler`. Same copy as the wrapper's + dialog — strings at wrapper `res/values/strings.xml:9-12` + (`exit_title` "Thoát Lô tô?", `exit_message` "Ván đang chơi sẽ được + lưu lại.", `exit_confirm` "Thoát", `exit_cancel` "Ở lại"); dialog + wiring reference in `MainActivity.java:68-71`. + +- **Theme:** Material 3 color schemes for light + dark from the web's + sky-blue brand (`#1565c0` light theme color, `#0a0f1f` dark — confirm + against `web/src/app.html`/`app.css`); `settings.theme` auto follows + system, light/dark force. `dynamicColor = false` (brand-fixed). +- **Splash:** `androidx.core:core-splashscreen`, brand mark + correct + light/dark background — no white flash cold-starting in dark mode. +- **Launcher icons:** regenerate from `web/static/icons/source.svg` at + the corrected glyph size (the wrapper's Android art was already fixed + at 200/160 — reuse that render recipe from `android/README.md` §Icons): + adaptive foreground/background, monochrome (themed icons, API 33+), + legacy mipmaps. Label: `Lô tô`. +- **Edge-to-edge:** content and the gear/footer clear of status bar + + gesture nav insets. +- **App metadata screens:** the web `PageFooter` content (version, + links) gets a native equivalent — decide placement (footer of the + scroll root, matching web). + +## Related Code Files + +- Create: `app/src/main/java/com/miti99/loto/ui/theme/{Color,Theme,Type}.kt`, + `KeepScreenOn.kt` modifier/effect, exit-confirm dialog composable, + `res/mipmap-*`, `res/drawable/` splash + icon vectors, + `res/values*/themes.xml`, `values-night/` +- Modify: `MainActivity.kt` (back callback, edge-to-edge), + `LotoAppRoot.kt` (footer, keep-screen-on binding) +- Spec: `plans/todo.md` device-QA list, wrapper `MainActivity.java` + + `res/` in git history, `web/src/app.css` (colors), `web/src/lib/PageFooter.svelte` + +## Implementation Steps + +1. Theme + dark mode pass across all screens (Phase 5–7 components use + theme tokens only — fix any hardcoded colors found). +2. Keep-screen-on effect bound to live-round state; unit-test the + predicate (master/both + round live). +3. BackHandler chain: sheet > dialog > exit confirm; manual test script. +4. Splash + icons + edge-to-edge; verify on API 24 (drawable splash + path) and 31+ (SplashScreen API path). +5. Footer/metadata. + +## Success Criteria + +- [ ] Wrapper-era QA items in `plans/todo.md` that are still applicable pass on an emulator (screen-awake, back chain, exit dialog copy, themed icon, dark splash) +- [ ] No hardcoded color literals outside `ui/theme` (lint check or grep) +- [ ] App label, icon, splash match the brand in light + dark + +## Risk Assessment + +- **Icon regeneration toolchain** (rsvg + Roboto Condensed ttf) is + host-dependent. Signal: fonts render wrong in generated PNGs. + Response: reuse the wrapper's committed PNGs from git history if the + glyph is unchanged — they were already rendered correctly. diff --git a/plans/260831-1131-native-android-rewrite/phase-09-wrapper-removal-and-ci-rewire.md b/plans/260831-1131-native-android-rewrite/phase-09-wrapper-removal-and-ci-rewire.md new file mode 100644 index 0000000..274003e --- /dev/null +++ b/plans/260831-1131-native-android-rewrite/phase-09-wrapper-removal-and-ci-rewire.md @@ -0,0 +1,84 @@ +--- +phase: 9 +title: "Wrapper Removal and CI Rewire" +status: done +priority: P1 +effort: "0.5d" +dependencies: [8] +--- + +# Phase 9: Wrapper Removal and CI Rewire + +## Overview + +Delete the Capacitor wrapper, move the native project into `android/`, +and rewire CI + release workflows to Gradle-only Android jobs. Web +pipeline stays untouched. + +## Requirements + +- Functional: `android/` = the native Gradle project root (no nested + `android/android/`, no `package.json`, no Capacitor config). +- CI `ci.yml`: the Android job no longer consumes the web build + artifact — it runs `lint`, `test`, `assembleDebug` and uploads the + debug APK artifact. Web jobs (test/build/deploy-firebase/deploy-pages/ + preview) unchanged. +- `android-release.yml` on `v*.*.*`: gradle test → signed AAB + APK → + GH Release assets → Play upload to track `alpha` (same action + same + secrets: `KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, + `KEY_PASSWORD`, `PLAY_SERVICE_ACCOUNT_JSON`). No web build, no + `cap sync`. +- **versionCode CI guard (closes the long-parked footgun):** the release + workflow fails fast if the tag's `versionCode` in + `android/app/build.gradle.kts` is not greater than the versionCode of + the previous release tag (git-diff based check step). +- `.github/actions/setup-android` keeps JDK setup; drop Node from the + Android path (`setup-web` remains for web jobs). Update the composite + action only where the Android side used it. +- Docs: root `README.md` (layout table, CI graph, quick start), + `android/README.md` full rewrite (native stack, build, release, + permissions rationale kept — no INTERNET), `docs/play-store-publishing.md` + spot-check (paths to build.gradle change), + `plans/todo.md`: drop wrapper-only items, keep still-valid ones. + +## Architecture + +Move, don't rewrite: `git rm -r` the Capacitor tree, `git mv +android-native android`, fix the audio-assets relative path +(`../web/static/audio` from `android/app/`), fix workflow paths. +History preserves the wrapper (like the old native port before it) — +note the swap in the commit message and `plans/todo.md` decisions record. + +## Related Code Files + +- Delete: `android/{package.json,package-lock.json,capacitor.config.json,assets,README.md}`, + `android/android/**` (entire Capacitor Gradle tree) +- Move: `android-native/**` → `android/**` +- Modify: `.github/workflows/ci.yml`, `.github/workflows/android-release.yml`, + `.github/actions/setup-android/action.yml`, root `README.md`, + `docs/play-store-publishing.md`, `plans/todo.md` +- Create: `android/README.md` (rewritten) + +## Implementation Steps + +1. Delete wrapper, move project, fix assets srcDir path; local + `assembleDebug` green. +2. Rewire `ci.yml` Android job (no web artifact dependency). +3. Rewire `android-release.yml`; add the versionCode guard step. +4. Update docs (README pair, publishing doc, todo.md decisions record: + "wrapper retired 2026-08-31 in favour of fresh native rewrite, + reversing the 2026-05-10 decision"). +5. Push branch; verify `ci` run green end-to-end including APK artifact. + +## Success Criteria + +- [ ] CI green on the PR: web jobs untouched and passing, Android job builds debug APK without web artifact +- [ ] `git grep -i capacitor` returns only historical docs/plans mentions +- [ ] versionCode guard demonstrably fails on an unbumped code (test with a scratch tag or workflow_dispatch dry run) +- [ ] Docs accurate against the new tree (link check) + +## Risk Assessment + +- **Play closed-test continuity:** nothing in this phase touches Play + until a tag is pushed (Phase 10 owns the release). Keep `main` + releasable: merge this PR only when Phase 10's QA is about to follow. diff --git a/plans/260831-1131-native-android-rewrite/phase-10-qa-and-release.md b/plans/260831-1131-native-android-rewrite/phase-10-qa-and-release.md new file mode 100644 index 0000000..8a41792 --- /dev/null +++ b/plans/260831-1131-native-android-rewrite/phase-10-qa-and-release.md @@ -0,0 +1,105 @@ +--- +phase: 10 +title: "QA and Release" +status: in-progress +priority: P1 +effort: "1d" +dependencies: [9] +--- + +# Phase 10: QA and Release + +## Overview + +Emulator/device QA against the carried checklist, then the first native +release: `v0.2.0`, versionCode 7, to the Play `alpha` closed track — +without breaking the running 14-day tester window. + +## Requirements + +- Functional: golden path on emulators API 24, 31, 36 — generate card → + manual draws → enable auto-call 3+ min (screen stays lit) → chờ → + complete row → Kinh modal + confetti + audio → reset → back-chain → + exit confirm. +- Audio QA: both voices; rapid draws cancel cleanly; chờ+N gapless; + volume rocker controls media stream; behavior on interruption + (the Phase-3 audio-attributes decision) observed and documented. +- Persistence QA: kill app mid-round → relaunch → identical state; + airplane mode cold start (offline hard guarantee). +- Accessibility spot-check: TalkBack reads cells and controls; system + font 200% doesn't break the grid (app-controlled `boardTextScale` + covers user needs); contrast in both themes. +- Instrumentation: run `connectedDebugAndroidTest` (Compose smoke + + VoicePlayer instrumentation) on at least one API level. +- Release: bump check (versionCode 7 already set in Phase 1 — verify), + tag `v0.2.0`, confirm GH Release assets + Play upload log + (`Validating tracks: 'alpha'`), then install the Play build via a + tester account and re-run the golden path once. +- Release notes flag: full native rewrite; settings and any in-progress + round from the previous version are not carried over. + +## Related Code Files + +- Modify: `android/app/build.gradle.kts` (only if versionCode/Name + need correction), `plans/todo.md` (retire completed QA items, carry + genuinely open ones — e.g. store-listing assets) +- No feature code — QA findings each become a fix commit or a tracked + todo item; do not batch silent fixes into the release commit. + +## Implementation Steps + + +0. **Environment setup (none exists yet):** install Android + Studio/SDK command-line tools on this machine, create AVDs for + API 24, 31, 36 (or attach a physical device via ADB). Budget ~0.5d + extra the first time. +1. Build QA matrix from `plans/todo.md` device-QA list + the checklist + above; run on API 24 / 31 / 36 emulators (BlueStacks optional). +2. File and fix blockers; re-run the affected checks. +3. Verify versionCode/Name; tag `v0.2.0`; watch `android-release` run. +4. Confirm Play console shows the build on `alpha`; monitor tester + opt-in count for the next days (window must not dip below 12). +5. Sweep `plans/todo.md`: mark superseded wrapper items, record the + decision reversal, keep the production-access log going (tester + feedback + exercised features — Play asks for both). + +## Success Criteria + +- [ ] QA matrix executed on 3 API levels, results recorded in the PR/plan +- [ ] `v0.2.0` on GH Releases with signed AAB + APK +- [ ] Play `alpha` track serving versionCode 7; testers unaffected or opted-in count stable +- [x] `plans/todo.md` reconciled with the new reality + +## QA log — 2026-08-31, API 36 emulator (Medium_Phone_API_36.1) + +Executed by the implementation session; screenshots in the session +scratchpad. API 24/31 images are not installed locally — that part of the +matrix plus tagging remain open. + +- [x] `connectedDebugAndroidTest`: 9/9 green (player golden path, master + draw/reset/auto relabel, settings mode switch, smoke) +- [x] Golden path manual: generate → cross 4 → "Chờ 70" toast + amber + cell/section rings → cross 5th → Kinh modal, exact web copy +- [x] Kill mid-round + airplane-mode cold start → exact state restored, + completed row keeps win styling, no re-announcement, fully offline +- [x] Master: Ván mới → 11×9 board (90 at row 10/col 8) → Xổ số → hero, + stats, history token, board mark; MediaCodec audio track confirmed + in logcat (voice announced) +- [x] Dark mode cold start: dark splash color, dark theme, state intact +- [x] Back at root → exit dialog with wrapper copy; "Ở lại" keeps state +- [x] `FLAG_KEEP_SCREEN_ON` held on the app window while remaining > 0 +- [ ] API 24 / API 31 runs (system images not installed) +- [ ] TalkBack + font-200% spot-check (manual) +- [ ] Tag `v0.2.0` → GH Release + Play alpha (user decision: ship when ready) + +## Risk Assessment + +- **Release timing (user decision, Validation Session 1):** tag + `v0.2.0` as soon as QA passes — do NOT hold for the closed-test + window to complete. The reset risk below was presented and accepted. +- **A tester-visible regression resets the 14-day window.** Signal: + opt-in count drops after rollout. Response: halt further releases, + fix-forward within days, keep spare testers engaged. If the rewrite + cannot stabilize quickly, the previous wrapper AAB (versionCode 6) can + be re-promoted from the Play console as an emergency rollback — verify + this path exists in the console before tagging. diff --git a/plans/260831-1131-native-android-rewrite/plan.md b/plans/260831-1131-native-android-rewrite/plan.md new file mode 100644 index 0000000..77cf31a --- /dev/null +++ b/plans/260831-1131-native-android-rewrite/plan.md @@ -0,0 +1,184 @@ +--- +title: "Native Android Rewrite" +description: "Rewrite android/ as a fresh native Kotlin + Jetpack Compose app, replacing the Capacitor WebView wrapper entirely" +status: in-progress +priority: P1 +effort: "6-9d" +tags: [android, kotlin, compose, rewrite] +created: 2026-08-31 +supersedes: [260429-1511-loto-upstream-sync] +--- + +# Native Android Rewrite + +## Overview + +Replace the Capacitor WebView wrapper in `android/` with a **fresh** native +Kotlin + Jetpack Compose app at full feature parity with the current +`web/` SvelteKit app. The web app's source under `web/src/lib/` is the +behavioral spec; the retired 2026-04 native port (git `f7cbb6e`) is +**reference reading only** — no code is resurrected (user decision, +2026-08-31). The wrapper is deleted in the same effort; the native app +takes over the package id, signing key, and tag-driven release pipeline. + +**Decision reversal on record:** `plans/todo.md` records "Native +Kotlin/Compose port retired (2026-05-10) in favour of the wrapper, for +maintenance parity." The user explicitly reversed this on 2026-08-31. +Trade-off accepted: every future `web/` feature must now be hand-ported +to Kotlin; the two apps no longer ship from one codebase. + +## Constraints (non-negotiable) + +- **Play continuity:** `applicationId com.miti99.loto`, same keystore + (env `LOTO_KEYSTORE_*`, CI secrets unchanged), `versionCode >= 7` + (wrapper shipped 6), releases keep flowing to the Play closed track + (`alpha`) via `v*.*.*` tags. +- **Fully offline, hard guarantee:** no `INTERNET` permission. `VIBRATE` + only. +- **Toolchain:** Kotlin 2.x + Jetpack Compose + Material 3, single `:app` + module, Gradle Kotlin DSL + version catalog, minSdk 24, targetSdk 36, + JDK 21. Manual DI (no Hilt). Media3 ExoPlayer for voice clips. + DataStore Preferences for settings. +- **Vietnamese-first UI**, matching current web copy and the sky-blue + brand (`d2c79d6` replaced the old green/rose accents). + +## Goals + +| # | Goal | Priority | +|---|------|----------| +| 1 | Native app at feature parity with `web/` (board, master panel, audio, settings, persistence) | P1 | +| 2 | Android platform behaviors kept from the wrapper era (keep-screen-on, back/overlay/exit-confirm, haptics, splash, adaptive icon, dark mode) | P1 | +| 3 | Capacitor wrapper deleted; CI + release pipeline rewired to Gradle-only Android jobs | P1 | +| 4 | Signed 0.2.0 (versionCode 7) AAB reaches the Play `alpha` closed track via the existing tag flow | P1 | + +## Non-goals + +- iOS target, multiplayer, i18n, background audio, share sheet — all stay + in the `plans/todo.md` parking lot. +- Migrating WebView `localStorage` (wrapper users' settings/round state) + into the native app — loss accepted (Validation Session 1); release + notes must state it. +- Web app changes of any kind (`web/` is untouched except docs links). + +## Phases + +| # | Phase | Status | Depends on | +|---|-------|--------|------------| +| 1 | [Project Scaffold and Build Foundation](./phase-01-start.md) | Done | — | +| 2 | [Game Logic Core](./phase-02-game-logic-core.md) | Done | 1 | +| 3 | [Audio Subsystem](./phase-03-audio-subsystem.md) | Done | 1 | +| 4 | [Settings and Game State Persistence](./phase-04-settings-and-game-state-persistence.md) | Done | 1, 2 | +| 5 | [Player Board UI](./phase-05-player-board-ui.md) | Done | 2, 3, 4 | +| 6 | [Master Panel UI](./phase-06-master-panel-ui.md) | Done | 2, 3, 4 | +| 7 | [Settings UI](./phase-07-settings-ui.md) | Done | 4, 5, 6 | +| 8 | [Platform Behaviors and Branding](./phase-08-platform-behaviors-and-branding.md) | Done | 5, 6, 7 | +| 9 | [Wrapper Removal and CI Rewire](./phase-09-wrapper-removal-and-ci-rewire.md) | Done | 8 | +| 10 | [QA and Release](./phase-10-qa-and-release.md) | In progress (API 36 QA done; API 24/31 + tag open) | 9 | + +Phases 2 and 3 are independent after 1. Phases 5 and 6 are independent +after 4. Everything else is sequential. + +## Behavioral spec sources (read before each phase) + +| Area | Authoritative source | +|------|----------------------| +| Card generation, chờ/kinh row logic | `web/src/lib/game-logic.js` + `game-logic.test.js` | +| Draw deck, master state | `web/src/lib/master-store.svelte.js` + test | +| Settings contract | `web/src/lib/settings-store.svelte.js` (DEFAULT_SETTINGS) | +| Voice semantics (cancellation, chờ+N sequence) | `web/src/lib/voice.js` + test | +| Auto-cross, auto-countdown | `web/src/lib/player-auto-cross.js`, `AutoCountdown.svelte` | +| Wrapper-era platform behaviors | `plans/todo.md` (device QA list), old `android/.../MainActivity.java`, `web/src/lib/wake-lock.js`, `overlay-history.js` | +| Visual parity details (confetti tiers, chờ ring, palette, hint copy) | superseded plan `plans/260429-1511-loto-upstream-sync/` phase files | +| Old port architecture (reference only, no code reuse) | git `f7cbb6e` tree, commit message of `90a9dd1` | + +## Success Criteria + +- [ ] `./gradlew :app:lint :app:test :app:assembleDebug` green in CI with no web-build dependency +- [ ] All game-logic unit tests ported from `web/src/lib/*.test.js` pass in Kotlin +- [ ] Golden path on emulator: generate card → draw numbers (manual + auto) → voice announces → chờ ring/toast → complete row → Kinh modal + confetti + audio +- [ ] Settings round-trip: every `DEFAULT_SETTINGS` field editable, persisted, restored after process death +- [ ] APK has no `INTERNET` permission; airplane-mode cold start fully functional +- [ ] `android/` contains no Capacitor/Node artifacts; root README + android README describe the native app +- [ ] Tag `v0.2.0` produces signed AAB+APK on the GH Release and uploads to Play track `alpha` (versionCode 7) + +## Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Play closed-test window (12 testers, 14-day continuous) breaks if the rewrite crashes for testers | Production-access clock resets | Full Phase-10 emulator QA before tagging; keep spare testers; release notes flag the rewrite | +| Wrapper→native update wipes WebView localStorage (settings + in-progress round) | Testers lose settings once | Accepted (Validation Session 1); state it in release notes | +| Maintenance parity regression (the original retirement reason) | Future web features need manual Kotlin ports | Accepted by user decision 2026-08-31; recorded above | +| versionCode footgun (manual bump before tag) | Play rejects upload | Phase 9 adds the long-parked CI guard: release workflow fails if tag's versionCode ≤ last released | +| 184 MP3s duplicated between web and android | Asset drift | Gradle `srcDir` points at `../web/static/audio` — single source of truth, no copy step | + +## Open questions + +None — both resolved in Validation Session 1 (see log). + +## Validation Log + +### Session 1 — 2026-08-31 +**Trigger:** `/ak:plan validate` after initial plan creation +**Questions asked:** 4 + +#### Verification Results +- **Tier:** Full (10 phases) +- **Claims checked:** 20 +- **Verified:** 18 | **Failed:** 1 | **Refined:** 1 + +Verified highlights: `web/static/audio/manifest.json` shape + 92×2 MP3s +incl. cho/kinh; theme colors `#1565c0`/`#0a0f1f` (`web/src/app.html:21-22`); +wrapper `versionCode 6` / `LOTO_KEYSTORE_*` env contract +(`android/android/app/build.gradle:10-29`); `tracks: alpha` + secrets +names (`.github/workflows/android-release.yml:47-93`); superseded plan +phase files; chờ = exactly-one-remaining-in-row +(`web/src/lib/game-logic.js:354`); all cited `web/src/lib/*` spec files. + +Failures/refinements (both propagated): +1. [Fact Checker] `plans/reports/researcher-260427-*` — not found; + phase 3 now cites only the `f7cbb6e:android/plans/todo.md` decision + record for the ExoPlayer pick. +2. [Fact Checker] Exit-dialog Vietnamese strings live in wrapper + `res/values/strings.xml:9-12`, not `MainActivity.java` — phase 8 + pointer corrected. + +#### Questions & Answers + +1. **[Assumptions]** Wrapper→native update wipes WebView localStorage — + accept, or one-time migration? + - Options: Accept the loss (Recommended) | One-time WebView migration + - **Answer:** Accept the loss + - **Rationale:** Rounds are minutes-long; avoids a WebView dependency + in a WebView-free app. Release notes must state it (Phase 10). +2. **[Risks]** First native release timing vs the running 14-day Play + closed-test window (completes ~2026-09-02)? + - Options: After production access (Recommended) | Ship when ready + - **Answer:** Ship when ready — **user overrode the recommendation**; + tag `v0.2.0` as soon as Phase 10 QA passes, window running or not. +3. **[Scope]** First native version identifier? + - Options: 0.2.0 / code 7 (Recommended) | 1.0.0 | 0.1.3 + - **Answer:** 0.2.0 / versionCode 7 (as planned). +4. **[Assumptions]** Android SDK/emulator availability for Phase 10 QA? + - Options: Emulator available | Physical device only | Neither yet + - **Answer:** Neither yet — Phase 10 gains a step 0: install Android + Studio/SDK + create API 24/31/36 AVDs (or attach a device via ADB). + +#### Confirmed Decisions +- localStorage migration: none — accept one-time loss, note in release notes +- Release timing: ship when Phase 10 QA passes (user decision, risk accepted) +- Version: `0.2.0` / versionCode 7 +- QA environment: SDK/emulator setup is now part of Phase 10 + +#### Impact on Phases +- Phase 3: dropped stale researcher-report citation +- Phase 8: exit-string spec pointer → `res/values/strings.xml` +- Phase 10: added SDK/emulator setup step; release-timing decision recorded + +### Whole-Plan Consistency Sweep +- Files reread: plan.md, phase-01 … phase-10 (all current in session) +- Decision deltas checked: 4 (migration, timing, version, QA env) +- Reconciled stale references: 2 (Non-goals + Risks table both said the + migration question was open; now marked resolved) +- Unresolved contradictions: 0 + + diff --git a/plans/journals/2026-08-31-native-android-rewrite-implementation.md b/plans/journals/2026-08-31-native-android-rewrite-implementation.md new file mode 100644 index 0000000..2b57609 --- /dev/null +++ b/plans/journals/2026-08-31-native-android-rewrite-implementation.md @@ -0,0 +1,68 @@ +# 2026-08-31 — Native Android rewrite: phases 1–9 implemented, phase 10 QA on API 36 + +## What happened + +Executed `plans/260831-1131-native-android-rewrite/` end to end in one +session. Phases 1–9 done; phase 10 QA ran on the API 36 emulator (the only +installed system image). API 24/31 runs and the `v0.2.0` tag remain open. + +- **Scaffold:** fresh Gradle project (AGP 8.13.0 + Gradle 8.14.3 — the pair + the wrapper already proved on this machine and CI), Kotlin 2.2.20, + Compose BOM 2025.09.00. Audio mounts from `../web/static` with an + `ignoreAssetsPattern` excluding `icons`/`manifest.webmanifest`. +- **Parity ports:** game logic, auto-cross, settings contract, voice + semantics ported one-to-one from `web/src/lib/`; every web test case has + a named Kotlin twin (100 unit tests). +- **UI:** Compose player board / master panel / settings sheet matching + the web pixel-for-pixel where practical (verified by emulator + screenshots vs the Svelte sources). +- **Wrapper deleted**, project moved to `android/`, CI rewired to + Gradle-only, versionCode guard added to the release workflow. + +## Hard-won lessons + +1. **Media3 merges `ACCESS_NETWORK_STATE` into the manifest.** The + offline guarantee ("VIBRATE only") needed + `tools:node="remove"` — caught by `aapt dump permissions`, not by any + build error. +2. **File-backed DataStore cannot be unit-tested on a Windows JVM.** Its + atomic rename fails whenever the target exists (`File.renameTo` + semantics), so every *second* write throws. Repositories now test + against an in-memory `DataStore` fake; "process death" = new repository + over the same store instance. +3. **JUnit `@Before` must return void.** `fun setUp() = runBlocking {...}` + returns `Preferences` and kills the runner with an opaque + `initializationError`. Wrap the body instead. +4. **Instrumentation tests share the app's DataStore.** Persisted rounds + from one test class turn "Tạo bảng mới" into a confirm dialog in the + next. Tests must reset settings in `@Before` and click through the + confirm dialog when present. +5. **The emulator booted in landscape**, which put the sheet's mode + section below the fold — taps silently hit the scrim. Rotating to + portrait fixed the "failing" test; the real product fix that fell out: + the settings sheet now opens fully expanded (`skipPartiallyExpanded`), + which is also closer to the web's full-height modal. +6. **Windows holds directory locks aggressively.** `mv android-native + android` failed (Gradle daemon + shell CWD + an editor handle); + copy-then-delete worked. Stop daemons before moving project roots. + +## Code review + +`code-reviewer` returned DONE_WITH_CONCERNS (report: +`plans/reports/code-reviewer-260831-native-android-rewrite.md`): logic +parity verified statement-by-statement; defects clustered where Svelte's +reactive effects became explicit call sites. Fixed same-session: DataStore +corruption/IO handling everywhere (H1), chờ/kinh detection after +generate/clear replays (H2), auto-call lifecycle pause on background (H3), +color sliders commit on drag end (H4), R8 `assembleRelease` in CI (H5), +auto-cross cursor clamp + ván-mới on shrink (M1), wall-clock countdown +immune to animator scale (M3), `grep -Fvx` in the tag guard (L2). Four new +tests cover H2/H3/M1. Minors deferred to `plans/todo.md` tech debt. + +## State for the next session + +- `git status`: one large staged+unstaged change set on `main` (wrapper + deletion + native app + CI + docs). Not committed — user decides. +- Open: API 24/31 emulator QA (system images not installed), TalkBack + + font-scale spot check, tag `v0.2.0` (user decision: ship when ready, + window running or not), CI green run verification after push. diff --git a/plans/journals/2026-08-31-plan-native-android-rewrite-of-loto.md b/plans/journals/2026-08-31-plan-native-android-rewrite-of-loto.md new file mode 100644 index 0000000..4615b05 --- /dev/null +++ b/plans/journals/2026-08-31-plan-native-android-rewrite-of-loto.md @@ -0,0 +1,32 @@ +--- +title: "Plan: native Android rewrite of loto" +date: 2026-08-31 +summary: Planned and validated the fresh Kotlin/Compose rewrite replacing the Capacitor wrapper +--- + +# Plan: native Android rewrite of loto + +## What happened + +Planned a full native Android rewrite of loto (`plans/260831-1131-native-android-rewrite/`, 10 phases, ~6-9d). The user reversed the 2026-05-10 "wrapper for maintenance parity" decision. Notably a complete Kotlin/Compose port already exists at git `f7cbb6e` — the user chose **fresh rewrite** over resurrecting it, and full wrapper replacement over dual-track transition. + +Scouted: web drift since the old port's retirement is only 3 commits (sky-blue theme, wrapper behavior gaps, deps). Verification pass (Full tier, 20 claims): 18 verified against source; 2 corrections propagated (missing researcher-260427 reports; exit-dialog strings live in `res/values/strings.xml:9-12`, not MainActivity.java). + +Marked `plans/260429-1511-loto-upstream-sync` superseded (it targeted the retired old port); its phases 01-04 remain referenced as behavior specs (confetti tiers, chờ ring, palette, hint copy). + +## Decisions (validation interview, 4 questions) + +- WebView localStorage migration: **accept the one-time loss**, state in release notes. +- Release timing: **ship when QA passes** — user overrode the "hold until Play production access" recommendation despite the 14-day closed-test window completing ~2026-09-02. +- Version: 0.2.0 / versionCode 7 (wrapper shipped 6). +- QA env: no Android SDK/emulator on this machine yet — Phase 10 gained a setup step 0. + +## Constraints locked + +`com.miti99.loto`, same `LOTO_KEYSTORE_*` signing contract, no INTERNET permission, minSdk 24 / targetSdk 36, audio assets mounted from `web/static/audio` via Gradle srcDir (no copy), Play `alpha` track via existing tag flow. Phase 9 adds the long-parked versionCode CI guard. + +## Next steps + +`/ak:cook D:/tiennm99/loto/plans/260831-1131-native-android-rewrite/plan.md` (whole-plan consistency sweep: 0 unresolved contradictions). Note: `.claude/scripts/set-active-plan.cjs` does not exist in this repo; `ak plan use` holds the active-plan pointer. + +> Historical work record — not durable authority. Prefer docs/specs/ADRs for current decisions. diff --git a/plans/reports/code-reviewer-260831-native-android-rewrite.md b/plans/reports/code-reviewer-260831-native-android-rewrite.md new file mode 100644 index 0000000..25df464 --- /dev/null +++ b/plans/reports/code-reviewer-260831-native-android-rewrite.md @@ -0,0 +1,357 @@ +# Code Review — Native Android Rewrite (Kotlin + Compose) + +Reviewer pass over `android/app/src/**` (~5,570 LOC Kotlin, 38 main + 15 test files), +`android/app/build.gradle.kts`, `android/gradle/libs.versions.toml`, `AndroidManifest.xml`, +and the CI rewire in `.github/`. Behavioral spec: `web/src/lib/`. + +Advisory only — no code or plan files were modified. + +## Scope + +- Game logic parity: `CardGenerator` / `PlayerCard` / `DrawDeck` / `PlayerAutoCross` vs + `game-logic.js`, `master-store.svelte.js`, `player-auto-cross.js` +- Business logic: `PlayerBoardViewModel` / `MasterPanelViewModel` vs + `PlayerBoard.svelte` / `MasterPanel.svelte` +- Public contracts: applicationId, versionCode/Name, signing env, release secrets, Play track, assets +- Concurrency/lifecycle: DataStore, `viewModelScope` collectors, StateFlow conflation, ExoPlayer +- Release-build risk: R8 / `bundleRelease` + +## Overall Assessment + +The pure game-logic port is genuinely faithful — I walked +`generateGrid`/`pickFilledCols`/`pickFilledColsOnce`/`randomNumbersInCol`/`combinations`/ +`hasThreeInARow`, `findUncrossedCell`/`isRowComplete`/`getWaitingNumber`, `DrawDeck`, and +`applyMasterCalls` statement-by-statement against the JS and found no algorithmic divergence. +The settings contract (10 fields, defaults, per-field validation with independent fallback) +matches `DEFAULT_SETTINGS` exactly. The manifest permission posture is correct. The test suite +is behavioral, not phantom. + +The defects cluster in the **layer the JS spec expressed as reactive `$effect`s** and in +**platform error/lifecycle boundaries the web got for free**. The ViewModels replaced Svelte's +automatic dependency tracking with explicit call sites, and two call sites were missed. Separately, +the persistence layer has no error handling at all, despite KDoc claiming otherwise. + +## Critical Issues + +None. No trust-boundary defect, no data exposure, no auth surface, no breaking public-contract change. + +## High Priority + +### H1 — No error handling on any DataStore read or write (major) + +`GameStateRepository.kt:41,58,64,70`, `SettingsRepository.kt:47,68-100`, +`LotoApplication.kt:70-75` + +Every DataStore call is unguarded. `dataStore.data` throws `CorruptionException` (an `IOException`) +when the preferences file is unreadable, and `edit {}` throws on write failure. There is no +`corruptionHandler` on either `preferencesDataStore` delegate and no `.catch {}` on either flow. + +Propagation paths, all of which terminate in an uncaught exception: + +- `LotoApplication.onCreate` → `appScope.launch { masterStore.restore() }` → + `dataStore.data.first()`. `appScope` uses a bare `SupervisorJob` with no + `CoroutineExceptionHandler`, so the throw reaches the thread's default handler. +- `settingsState = settingsFlow.stateIn(appScope, Eagerly, defaults)` — an upstream throw + fails the sharing coroutine identically. +- `persist()` in `PlayerBoardViewModel.kt:288` and `publishAndSave()` in `MasterStore.kt:52` + launch bare `edit {}` calls. + +Failure scenario: a corrupt `loto_game_state.preferences_pb` (interrupted write, filesystem +damage, restored-from-backup mismatch) crashes the app on every launch. The user cannot recover +without clearing app data. + +This is also a **false doc claim**. `GameStateRepository`'s KDoc states: *"every payload is +shape-validated on load and falls back to 'no saved round' rather than crashing (the web's +safeParse posture)."* That is true for shape problems only. The web's actual posture wraps +**every** localStorage touch in `try/catch` — `saveGrid`, `loadGrid`, `loadMaster`, +`saveMaster`, `loadSettings`, `saveSettings` all swallow and fall back. The port kept the +comment and dropped the guarantee. + +Fix: pass `ReplaceFileCorruptionHandler { emptyPreferences() }` to both delegates, add +`.catch { if (it is IOException) emit(emptyPreferences()) else throw it }` upstream of the +`.map`, and wrap the write paths. + +### H2 — `generate()` and `clearMarks()` never run chờ/kinh detection (major) + +`PlayerBoardViewModel.kt:141-176` (`generate`), `:178-208` (`clearMarks`) + +Both methods clear `celebratedRows` / `notifiedWaitingRows`, replay the master's `called[]` onto +the board (in both mode), then call `persist()` and `publish()` — but never `detectRowEvents()`. + +In the web this is not a decision, it is a consequence: the row-detection `$effect` in +`PlayerBoard.svelte` depends on `crossed`, `celebratedRows`, and `notifiedWaitingRows`, so +`handleGenerate` / `handleClear` mutating all three re-runs it automatically, after the +`showCongrats = false` assignment at the end of the handler. + +Failure scenario: both mode, master has called 40 numbers, player taps "Xoá đánh dấu". The +replay re-crosses everything and completes a row. Web: Kinh modal + `kinh` clip. Android: +silent — no modal, no voice, no toast. Because the trackers were just cleared, the row is not +in `celebratedRows`, so the announcement fires **late**, on the next master draw. If the deck +is exhausted or the host stops calling, it never fires at all. The same applies to "Tạo bảng +mới" mid-game, where the replay can land a row into chờ with no toast and no `cho` clip. + +The existing test `clearMarks in both mode re-crosses all called numbers (unticks wiped)` +(`PlayerBoardViewModelTest.kt:247`) asserts only `crossed[0][col]` and therefore does not +catch this. + +Fix: call `detectRowEvents()` before `publish(showCongrats = false)` in both methods — or, +better, fold detection into `publish()` so no future call site can forget it. + +### H3 — Auto-call ticker has no lifecycle gating (major) + +`MasterPanelViewModel.kt:43-67` + +The ticker runs in `viewModelScope`, which is alive for the ViewModel's whole lifetime and +independent of `Lifecycle.State`. Backgrounding the app during auto-call keeps drawing numbers +and playing MP3s through ExoPlayer. + +The web behaves differently and this is load-bearing, not incidental: a hidden tab's +`setInterval` is throttled (and a backgrounded Capacitor WebView is paused outright), and +`MasterPanel.svelte`'s wake-lock effect explicitly notes *"Android drops the lock whenever the +page hides, so a backgrounded app holds nothing."* The Compose `KeepScreenOn` helper +(`KeepScreenOn.kt:22`) only releases on composition dispose, not on `onStop`. + +Failure scenario: host backgrounds the app to answer a message; the deck keeps draining and +Vietnamese number clips keep playing over their phone call / music. On return, 15 numbers have +been called that nobody at the table heard. + +Fix: gate the ticker with `repeatOnLifecycle(Lifecycle.State.STARTED)` at the collection site, +or expose a paused flag the Activity drives from `onStop`/`onStart`. + +### H4 — Per-frame DataStore commits from the colour-picker sliders (major) + +`EmptyCellColorPicker.kt:126-129`, `SettingsViewModel.kt:34-36` + +The three RGB `Slider`s use `valueRange = 0f..255f` with **no `steps`**, so `onValueChange` +fires continuously during a drag. Each call goes +`onPick → setEmptyCellColor → viewModelScope.launch { dataStore.edit {} }` — a full +preferences-file rewrite plus fsync, per touch frame. A single full-range drag can issue +~250 commits; three channels compound it. Each commit also re-emits `settingsState`, +recomposing every settings consumer. + +The web does the same thing against `localStorage`, where a synchronous in-memory write is +cheap. Ported verbatim to DataStore it becomes real disk I/O and write amplification. + +The `autoCallSpeed` slider (`SettingsSheet.kt:165-171`) is fine — `steps = 8` snaps +`onValueChange` to 10 tick values. + +Fix: hold the colour in local Compose state during the drag and commit on +`onValueChangeFinished`. + +### H5 — R8 is never exercised before the release tag (major) + +`.github/workflows/ci.yml:129-133`, `.github/workflows/android-release.yml:57-70`, +`android/app/build.gradle.kts:64-75` + +`isMinifyEnabled = true` and `isShrinkResources = true` are new in this rewrite, and +`proguard-rules.pro` is empty (comments only). CI's `android-debug` job runs +`:app:lint :app:test :app:assembleDebug` — no minified variant. The first R8 run in the +project's life happens in `android-release.yml`, on a tag, after the keystore has been decoded. + +I found no reflection in the app itself: `LotoViewModelFactory` uses `isAssignableFrom` for +dispatch but constructs each ViewModel explicitly, `org.json` parsing in `VoiceCatalog` is +non-reflective, and Compose/Media3/DataStore ship consumer rules. So `bundleRelease` will +probably pass. "Probably" is the problem — a failure here is discovered at tag time, and the +tag has already been pushed. + +Note `isShrinkResources` only touches `res/`, not `assets/`, so the 184 MP3s are safe. + +Fix: add `:app:assembleRelease` to the `android-debug` CI job. Without `LOTO_KEYSTORE_PATH` +the `signingConfig` is skipped and AGP produces an unsigned release APK — full R8 coverage, +no secrets needed. + +## Medium Priority + +### M1 — `lastHandledIndex` is never clamped when `called[]` shrinks + +`PlayerAutoCross.kt:38-40`, `PlayerBoardViewModel.kt:113-125` + +The ván-mới reset in `onMasterState` triggers only on the exact transition +`prev > 0 && len == 0`. If the observed `called` size drops from N to a non-zero value, +neither branch recovers: the reset is skipped, and `applyMasterCalls` short-circuits on +`lastHandledIndex >= called.size` **without resetting the cursor**. Auto-cross is then dead +until the new round's `called` exceeds N, and the previous round's marks are never cleared. + +I could not construct a UI-reachable trigger. `StateFlow` + `Dispatchers.Main.immediate` +resumes the collector inline from the emitting main-thread call, and the two mutation sites +(`newGame` and `drawNext`) are always separated by a touch event or a `delay`. But the code +path is demonstrably live: `PlayerBoardViewModelTest.kt:161-173` conflates 31 emissions into +one under `StandardTestDispatcher` and only passes because the cursor happens to be 0. The +`prevCalledLen`-based reset is a strictly weaker signal than the web's, where Svelte effects +re-read `masterState.called` on every flush. + +Fix: treat any `called.size < lastHandledIndex` as a round reset (`lastHandledIndex = 0`) +inside `applyMasterCalls`, and key the ván-mới clear on `len < prev` rather than `len == 0`. + +Related inherited limitation (present in the web too, so not a regression): across a process +restart, `prevCalledLen` starts at 0, so a ván-mới that happened while the player VM was dead +is never detected and stale marks survive into the new round. + +### M2 — ExoPlayer is never released + +`LotoApplication.kt:63-65`, `VoicePlayer.kt:97-99` + +`VoicePlayerApi.release()` has zero call sites (grep-verified across `app/src/`). The +Application-scoped `ExoVoicePlayer` holds its playback thread, renderers, and — once prepared — +a `MediaCodec`/`AudioTrack` for the entire process lifetime. Both `onCleared()` overrides call +`cancel()`, not `release()`, which is correct for a shared instance but leaves nothing that +ever releases it. + +Bounded by process death, so not a growing leak, but `release()` is dead API surface that reads +as if cleanup were handled. + +### M3 — `AutoCountdown` freezes at "1" when system animations are off + +`AutoCountdown.kt:49-63` + +`progress.animateTo(tween(...))` is a Compose animation, and Compose scales animation durations +by the system `MotionDurationScale` sourced from `ANIMATOR_DURATION_SCALE`. With "Remove +animations" enabled — the exact condition `rememberReducedMotion()` detects — the tween +completes instantly, `progress.value` jumps to 1f, and `secondsRemaining` clamps to 1 for the +whole interval. + +The inline comment claims the opposite: *"Reduced motion keeps a static full ring; the number +still ticks."* It does not tick. Fix: drive `secondsRemaining` from a `withFrameNanos`/wall-clock +countdown independent of the animation clock, or from the ViewModel. + +### M4 — `rememberReducedMotion()` reads the setting once and never re-reads + +`ReducedMotion.kt:15-24` + +`remember {}` with no key caches for the composition's lifetime. Toggling "Remove animations" +in system settings and returning to the app leaves stale behaviour until the process restarts. +The web calls `matchMedia(...).matches` freshly at each use site. Low impact; worth a +`LocalLifecycleOwner`-keyed re-read. + +### M5 — Manifest-parse asset I/O on the main thread at startup + +`LotoApplication.kt:39` → `VoiceCatalog.load()` (`VoiceCatalog.kt:60-70`) + +`voices` is resolved through the `settingsRepository` → `settingsState` lazy chain, which is +first touched from `appScope` (`Dispatchers.Main.immediate`). `assets.open(...).readText()` plus +`JSONObject` parsing runs on the main thread during `onCreate`. The manifest is tiny so this is +sub-millisecond, but it is a StrictMode violation and an easy thing to regress if the manifest +grows. + +## Low Priority + +### L1 — `ignoreAssetsPattern` is a denylist over an externally-owned directory + +`app/build.gradle.kts:36-45` + +`assets.srcDir("../../web/static")` mounts the whole web static tree; the exclusion pattern +names `icons` and `manifest.webmanifest` explicitly. I verified `web/static/` currently holds +exactly `audio/`, `icons/`, and `manifest.webmanifest`, so today the APK gets audio only — +matching the stated contract. But any file added to `web/static` by web-side work ships into +the APK silently. A comment on the web side (or an allowlist-shaped guard) would make the +coupling visible to whoever adds the next file. + +### L2 — `grep -vx` treats the tag name as a regex + +`.github/workflows/android-release.yml:33` + +`grep -vx "${GITHUB_REF_NAME}"` — the dots in `v0.2.0` are regex wildcards, so `v0x2y0` would +also be excluded. Harmless with the current tag set. Use `grep -Fvx`. + +### L3 — Empty `KEYSTORE_BASE64` produces a confusing failure + +`.github/workflows/android-release.yml:61-64` + +If the secret is unset, `printf '%s' "" | base64 --decode` writes a zero-byte `keystore.p12` +and exits 0. `LOTO_KEYSTORE_PATH` is still set, so `signingConfig` is applied and Gradle fails +deep in signing with a keystore-format error. A `[ -n "$KEYSTORE_BASE64" ] || { echo "::error::..."; exit 1; }` +guard makes the diagnosis immediate. Also, `keystore.p12` is left in the workspace with no +cleanup step — no leak today (the release upload globs only `android/app/build/outputs/**`), +but worth a `rm -f` in an `always()` step. + +### L4 — `allowBackup="true"` with no backup rules + +`AndroidManifest.xml:39` + +Both DataStore files are included in Google auto-backup. Contents are game state and UI +preferences — no PII, no secrets — so this is fine as a product decision. Flagging only because +it is implicit; a `dataExtractionRules` entry would make it deliberate. + +### L5 — `LaunchedEffect(tickKey)` re-scrolls the hero after a config change + +`MasterPanelScreen.kt:71-73` + +`tickKey` lives in the Activity-scoped ViewModel and survives rotation, so the effect re-fires +on recomposition and yanks the hero into view. The web guards this with a `scrollOnNextDraw` +user-interaction flag. The `tickKey > 0` guard correctly handles the restore-on-launch case +(cold start has `tickKey == 0`), so this is rotation-only and cosmetic. + +## Verified Contracts (no action) + +- `applicationId = "com.miti99.loto"`, `versionCode = 7`, `versionName = "0.2.0"` — correct bump from 6 / 0.1.2 +- Signing env: `LOTO_KEYSTORE_PATH` / `LOTO_KEYSTORE_PASSWORD` / `LOTO_KEY_ALIAS` / `LOTO_KEY_PASSWORD`, consistent between `build.gradle.kts` and the workflow +- Release secrets unchanged: `KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`, `PLAY_SERVICE_ACCOUNT_JSON` +- Play: `packageName: com.miti99.loto`, `tracks: alpha`, `status: completed` +- Assets: `web/static/audio/**` only; `icons` and `manifest.webmanifest` excluded — verified against the actual directory listing +- Permissions: `VIBRATE` only; no `INTERNET`; `ACCESS_NETWORK_STATE` stripped via `tools:node="remove"` +- Settings contract: all 10 fields, defaults, and validation rules match `DEFAULT_SETTINGS`. The + web-only `masterMode` → `mode: "both"` migration is correctly documented as out of scope. +- Voice semantics: `speak()` replaces the playlist (cancellation), and + `waitingClips(n, voiceWaitingNumber, modeIsBoth)` reproduces + `voiceWaitingNumber && mode !== "both"` exactly. +- Announce gating: `voiceEnabledPlayer || (voiceEnabledMaster && mode == BOTH)` matches + `PlayerBoard.svelte` verbatim. +- `celebrationTier`: Kotlin's `celebratedRows.size >= 2 || hasActiveCho` is equivalent to the + web's `size >= 2 || (size >= 1 && hasActiveCho)` — `add(i)` precedes the check in both, so + `size >= 1` is always true there. +- Auto-call teardown/re-arm: `combine(...).distinctUntilChanged().collectLatest` reproduces the + web's single `$effect` over `(autoRunning, autoCallEnabled, autoCallSpeed)`, including the + disable-mid-run auto-stop and the deck-exhausted stop. +- Wake lock: `shouldKeepScreenOn(mode, remaining) = mode != PLAYER && remaining > 0` matches the + web's `setWakeLock(remaining.length > 0)` inside a panel that only mounts when `mode != player`. +- `MasterBoardLayout.BOARD` matches `buildBoard()` cell-for-cell. +- Input validation is correctly two-layered: bounded at the UI (`coerceIn`, `valueRange`) **and** + re-validated per-field on read in `SettingsRepository`. + +## Positive Observations (risk calibration) + +Recorded because they narrow where remaining risk can hide: + +- The game-logic port is line-for-line faithful, including non-obvious details: the Fisher-Yates + loop bounds, `sorted()` after `take(num)`, the `combinations` ordering, and the 200-attempt + rejection-sampling structure. RNG differs by necessity but is injectable and seeded in tests. +- Tests assert behaviour, not execution: seeded determinism, 1,000-card invariant sweeps, + process-death round-trips, corrupt-payload fallbacks, and cross-restart manual-untick + suppression. No phantom tests found. +- No parallel reimplementation of existing utilities, no `any`-equivalent widening, no lint + suppression beyond the one necessary `@Suppress("UNCHECKED_CAST")` in the ViewModel factory, + and no scope drift. Manual DI is proportionate to the app's size. + +## Recommended Actions + +1. **H1** — add corruption handlers and I/O guards to both DataStore surfaces; correct the + `GameStateRepository` KDoc. +2. **H2** — call `detectRowEvents()` from `generate()` and `clearMarks()`, and extend + `PlayerBoardViewModelTest` to assert the announcement, not just the crossed matrix. +3. **H5** — add `:app:assembleRelease` to the CI Android job (unsigned, no secrets) so R8 is + proven before tagging. Cheapest fix on this list; do it first. +4. **H3** — gate the auto-call ticker on `Lifecycle.State.STARTED`. +5. **H4** — commit the colour picker on `onValueChangeFinished`. +6. **M1** — clamp `lastHandledIndex` on shrink and key the ván-mới clear on `len < prev`. +7. **M3** — decouple the countdown number from the animation clock; fix the contradicted comment. +8. **M2, M4, M5, L1–L5** — batch as follow-up. + +## Metrics + +- Type coverage: 100% (Kotlin, no platform-type leakage in reviewed code; one justified + `@Suppress("UNCHECKED_CAST")` at `LotoViewModelFactory.kt:16`) +- Test coverage: 101 unit + 9 instrumentation tests, reported green (not re-run in this pass) +- Lint: reported green (not re-run); no new suppressions introduced +- Release-build coverage: **0%** — `bundleRelease` / R8 unexercised in CI (H5) + +## Unresolved Questions + +1. Is H3 intended? A caller app that keeps announcing while backgrounded is arguably a feature, + but it diverges from the web and from the wrapper's behaviour. If intentional, it needs a + foreground service (Play policy) and a comment; if not, it needs lifecycle gating. +2. Was `isMinifyEnabled = true` a deliberate change from the Capacitor-era config, or an AGP + template default that came along with the rewrite? It shifts release-build risk without a + corresponding CI gate. +3. H2 and M1 both stem from replacing Svelte's automatic dependency tracking with explicit + call sites. Is it worth folding detection into `publish()` so the class has one funnel + instead of five call sites to keep in sync? diff --git a/plans/todo.md b/plans/todo.md index 8150d03..f3861d5 100644 --- a/plans/todo.md +++ b/plans/todo.md @@ -1,67 +1,47 @@ # Next-session TODO -Hand-off list as of 2026-08-19. Covers both `web/` (SvelteKit PWA, -deployed to GitHub Pages at the `/loto/` base) and `android/` (Capacitor -wrapper). Shipped plan folders are swept after release; residual and new -items live here directly. +Hand-off list as of 2026-08-31. Covers both `web/` (SvelteKit PWA, +deployed to GitHub Pages at the `/loto/` base) and `android/` (native +Kotlin + Jetpack Compose app — the Capacitor wrapper was retired +2026-08-31). Shipped plan folders are swept after release; residual and +new items live here directly. ## Highest leverage (start here) -### Android device QA (blocked: no device or emulator) +### Android device QA -Carried from the shipped Android experience pass. The native code is -merged and statically reviewed, but every item below is device-only — -this environment is headless ARM64 with no emulator and no Android SDK. +The acceptance spec for the native rewrite's release QA +(`plans/260831-1131-native-android-rewrite/phase-10-qa-and-release.md`). +Every item is device/emulator-only. -- [ ] Tap a cell → vibrates; enable reduced-motion → silent. +- [ ] Tap a cell → vibrates; system "remove animations" → silent. - [ ] Auto-call 3+ min untouched → screen stays lit. Stop → sleeps normally. - [ ] Background during auto-call, return → still lit. - [ ] Finish a round (remaining 0) → lock releases. - [ ] Back with bingo modal open → modal closes, app stays. - [ ] Back with settings open → sheet closes. - [ ] Back at root → Vietnamese exit dialog; "Ở lại" keeps state. -- [ ] System font 200% → grid intact; Settings → Cỡ chữ bảng resizes; - survives reload. +- [ ] System font 200% → grid intact (Compose sp scaling); Settings → + Cỡ chữ bảng resizes; survives reload. - [ ] Gesture nav + status bar clear of the settings gear and footer. - [ ] Volume rocker during a call → media slider. - [ ] Launcher shows `Lô tô` with the brand icon; themed-icon mode tints it. - [ ] Cold start in dark mode → dark splash, no white flash. - -Two assumptions the checklist doubles as a test for: the WebView has no -history at rest (SvelteKit hydrates with `replaceState`), so back at root -should exit rather than navigate; and `OnBackPressedCallback` / `Bridge` -resolve transitively through `appcompat` / `capacitor-android` — never -compiled here, so a build failure would show up first. - -### Android wrapper — open items - -Merged from `android/plans/todo.md` on 2026-08-19. Items that the Android -experience pass already shipped (haptics, back button, safe-area insets, -launcher icon, splash + night variant) were dropped rather than carried; -verified present in `MainActivity.java`, `web/src/app.css`, and -`android/android/app/src/main/res/`. - -- [ ] Sideload onto real devices (API 24, 31, 36) and BlueStacks. Overlaps - the device QA list above — do both in one sitting. -- [ ] Cold start: app loads `https://localhost/`, no white flash > 1s. -- [ ] Offline: airplane mode, kill app, relaunch — must work. -- [ ] Audio: tap a number → Vietnamese voice plays; both voices switch - correctly (`hoai-my` ↔ `nam-minh`). -- [ ] First-launch SW registration: confirm Workbox precaches the bundled - audio without errors (should be idempotent — assets already on disk). -- [ ] Audio focus: the WebView `