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.
This commit is contained in:
tiennm99 committed 2026-08-31 13:28:48 +07:00
1 parent e314ae01c8
commit 194a51abda
16 files changed
+1555 -57

No files matched your search

@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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 <default from manifest>`, `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<Settings>`;
`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.
@@ -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.
@@ -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.
@@ -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.
@@ -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`.
<!-- Updated: Validation Session 1 - exit-string pointer corrected -->
- **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.
@@ -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.
@@ -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
<!-- Updated: Validation Session 1 - SDK/emulator setup added; ship-when-ready timing -->
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.
@@ -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
<!-- slug: native-android-rewrite -->
@@ -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.
@@ -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.
@@ -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?
+55 -57
View File
@@ -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 `<audio>` never claims `AUDIOFOCUS_GAIN`.
Test an incoming call mid-round — does playback duck or pause?
Explicitly out of scope for the experience pass; still unanswered.
- [ ] Confirm `android:supportsRtl="true"` (Capacitor default) does not
flip the Vietnamese UI.
- [ ] Offline: airplane mode, kill app, relaunch — fully functional.
- [ ] Audio: draw a number → Vietnamese voice plays; both voices switch
correctly (`hoai-my` ↔ `nam-minh`); rapid draws cut each other off;
chờ + number plays gaplessly when the setting is on.
- [ ] Audio focus: playback layers over background music without pausing
it (decided: no focus request — see `ExoVoicePlayer`); incoming
call behaves normally.
- [ ] Sideload onto real devices (API 24, 31, 36) and BlueStacks.
- [ ] Store listing assets: feature graphic 1024×500, 2–8 phone
screenshots, short (≤80) and full (≤4000) descriptions.
**versionCode footgun (unresolved).** Every release must increment
`versionCode` in `android/android/app/build.gradle` — currently 4, at
versionName 0.1.0 — before tagging, or Play rejects the upload. Still
manual discipline. Options never decided: a pre-tag CI check that fails
on an unbumped code, or deriving it from the git tag.
**versionCode footgun (resolved 2026-08-31).** `android-release.yml` now
fails fast when the tag's `versionCode` in `android/app/build.gradle.kts`
is not greater than the previous release tag's. Bumping stays manual;
forgetting is no longer silent.
### Play production access (clock running)
@@ -134,20 +114,29 @@ iOS Safari. No code; verification only.
## Parked (Android)
- `@capacitor/share` for sharing the called-numbers card.
- Background audio (play numbers while the screen is off). WebView audio
is foreground-only; needs a native plugin or service shim.
- iOS target (`npx cap add ios`) — same wrapper, near-zero extra code.
- Share sheet for the called-numbers card (native `Intent.ACTION_SEND`).
- Background audio (play numbers while the screen is off) — needs a
foreground service.
- iOS target — would now mean a separate SwiftUI app or reviving a
cross-platform layer; substantially bigger than in the wrapper era.
## Decisions record (Android)
- **Capacitor 8 wrapper, not TWA** — chosen for the first-launch offline
guarantee; a TWA needs network for the first PWA fetch.
- **Fresh native Kotlin/Compose rewrite** (2026-08-31) replaced the
Capacitor wrapper, reversing the 2026-05-10 retirement decision (user
decision; trade-off accepted: web features now need manual Kotlin
ports). Fresh code — nothing resurrected from the old port. Wrapper
history preserved in git, like the old port before it.
- **No INTERNET permission** — makes "fully offline" a hard guarantee.
The native app strips Media3's merged `ACCESS_NETWORK_STATE` too.
Would have to come back if any remote feature is introduced.
- **Native Kotlin/Compose port retired** (2026-05-10) in favour of the
wrapper, for maintenance parity. The ExoPlayer + Compose UI lives at
commits `fe52232` / `9a35686` if it is ever wanted back.
- **No audio focus** (2026-08-31): voice clips are ~1s speech cues; the
player never claims focus so background music keeps playing, but pauses
on becoming-noisy (headphones unplugged). See `ExoVoicePlayer`.
- Historical: **Capacitor 8 wrapper, not TWA** (2026-08) — chosen for the
first-launch offline guarantee. **Native Kotlin/Compose port retired**
(2026-05-10) in favour of the wrapper, for maintenance parity; that
port lives at commits `fe52232` / `9a35686`.
## UX polish (carried over)
@@ -158,6 +147,18 @@ iOS Safari. No code; verification only.
## Tech debt
- **Code-review follow-ups (native rewrite, 2026-08-31).** Deferred minors
from `plans/reports/code-reviewer-260831-native-android-rewrite.md`
(H1–H5, M1, M3, L2 were fixed the same day): `VoicePlayerApi.release()`
has no call site (app-scoped player lives for the process — fine, but
dead API); `rememberReducedMotion()` reads the animator scale once per
composition (stale until restart if toggled mid-session); voice-manifest
parse runs on the main thread at startup (sub-ms, StrictMode-visible);
`ignoreAssetsPattern` is a denylist over `web/static` (a new web static
file would silently ship in the APK — add a web-side note or allowlist);
empty `KEYSTORE_BASE64` yields a confusing zero-byte keystore failure in
the release workflow.
- **`web/package.json` overrides — re-checked 2026-08-19.**
`cookie` is still doing work: `@sveltejs/kit` declares `^0.6.0`, and
the override is what forces 0.7.2. `serialize-javascript` is now
@@ -187,7 +188,4 @@ iOS Safari. No code; verification only.
- `boardTextScale` rungs are 0.9 / 1 / 1.15 / 1.3. Enough range for
users who already run system font at 200%?
- The exit confirm fires at root regardless of round state. Worth
plumbing "is a round live" to native so an idle app just exits?
- `textZoom` is pinned as an accessibility override. It is only
defensible alongside the in-app size control — if that setting is
ever cut, cut the pin too.
gating on "is a round live" so an idle app just exits?