mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-15 00:17:46 +00:00
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:
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.
|
||||
+95
@@ -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
@@ -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?
|
||||
Reference in new issue
Block a user