From 7803f336c4bdca9e4fa33d330d2bfbfe689890f5 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 14 Aug 2026 15:21:03 +0700 Subject: [PATCH] docs: add the Android experience pass plan and reports Keeps the reasoning next to the change: the brainstorm that scoped it, the phase plan, and an implementation report carrying the device QA checklist for everything that cannot be verified without a phone. --- .../phase-01-tier1-defects.md | 137 ++++++++++++ .../phase-02-tier2-defensive.md | 132 +++++++++++ .../phase-03-store-polish.md | 82 +++++++ .../plan.md | 82 +++++++ ...814-1004-android-experience-pass-report.md | 205 ++++++++++++++++++ ...d-experience-pass-implementation-report.md | 131 +++++++++++ 6 files changed, 769 insertions(+) create mode 100644 plans/260814-1317-android-experience-pass/phase-01-tier1-defects.md create mode 100644 plans/260814-1317-android-experience-pass/phase-02-tier2-defensive.md create mode 100644 plans/260814-1317-android-experience-pass/phase-03-store-polish.md create mode 100644 plans/260814-1317-android-experience-pass/plan.md create mode 100644 plans/reports/brainstorm-android-ux-260814-1004-android-experience-pass-report.md create mode 100644 plans/reports/from-cook-to-review-260814-1317-android-experience-pass-implementation-report.md diff --git a/plans/260814-1317-android-experience-pass/phase-01-tier1-defects.md b/plans/260814-1317-android-experience-pass/phase-01-tier1-defects.md new file mode 100644 index 0000000..86422cb --- /dev/null +++ b/plans/260814-1317-android-experience-pass/phase-01-tier1-defects.md @@ -0,0 +1,137 @@ +# Phase 1 — Tier 1 defects + +APK-only bugs. None reproduce in a desktop browser. + +## W1 · VIBRATE permission + +`android/android/app/src/main/AndroidManifest.xml` — add: + +```xml + +``` + +Normal permission: no runtime prompt, no Play data-safety change. Caller is +`PlayerBoard.svelte:341-346`, already guarded by `prefers-reduced-motion`. + +## W2 · Screen wake lock + +New `web/src/lib/wake-lock.js` (plain `.js` — no reactive state, matches +`game-logic.js` / `player-auto-cross.js` convention; runes files are only those +holding `$state`). + +API — single entry point so callers cannot desync: + +```js +export function setWakeLock(on) // idempotent +export function _resetForTest() // test-only teardown +``` + +Behaviour: + +- `setWakeLock(true)` → `navigator.wakeLock.request("screen")`, installs a + `visibilitychange` listener that re-acquires on return to visible. + Android silently releases the lock whenever the page hides — without the + re-acquire the lock is dead after the first backgrounding. +- `setWakeLock(false)` → releases sentinel, removes listener. +- Generation token guards the async gap: a `request()` that resolves after the + caller flipped to `false` must release immediately rather than leak a lock. + Same pattern as `activeToken` in `voice.js`. +- No `navigator.wakeLock` → no-op (old WebViews at minSdk 24). +- `request()` rejects on battery saver / hidden page → swallow, do not throw. + +Consumer — `web/src/lib/MasterPanel.svelte`, new `$effect`: + +```js +$effect(() => { + setWakeLock(autoRunning || hasGame); + return () => setWakeLock(false); +}); +``` + +`autoRunning` (line 47) and `hasGame` (lines 55-57) already exist locally. +MasterPanel only mounts in master/both mode, so player-only mode never holds a +lock — intended: player taps keep their own screen awake. + +Tests — new `web/src/lib/wake-lock.test.js`: acquire, release, re-acquire on +`visibilitychange`, no-op when unsupported, late-resolve after `false` releases. + +## W3 · Back button + +### Web half — `web/src/lib/overlay-history.js` (new) + +Each open overlay pushes one sentinel history entry. `popstate` closes the +newest. Works in the browser too — browser back closes the modal, a real PWA win. + +```js +export function pushOverlay(close) // returns dispose() for programmatic close +``` + +- LIFO stack of `{ id, close }`. +- `popstate` → pop top, run its `close()`, leave history alone. +- `dispose()` (Escape / Xong / backdrop) → remove entry, then `history.back()` + to drop the sentinel, with a suppression counter so the resulting `popstate` + does not re-run `close()`. +- Single shared `popstate` listener, installed on first push, removed when the + stack empties. + +Consumers: + +- `PlayerBoard.svelte` — `showCongrats` modal. +- `SettingsButton.svelte` — settings sheet. + +Both already have Escape handlers in an `$effect`; the overlay push belongs in +the same effect so open/close stays one code path. + +Tests — new `web/src/lib/overlay-history.test.js`: single open/close, nested +LIFO ordering, programmatic close does not double-fire, stack drains. + +### Native half — `MainActivity.java` + +```java +getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override public void handleOnBackPressed() { + WebView wv = getBridge().getWebView(); + if (wv != null && wv.canGoBack()) { wv.goBack(); return; } // pops a sentinel + confirmExit(); + } +}); +``` + +`history.pushState` adds to the WebView back-forward list, so `canGoBack()` is +true exactly while an overlay is open, and `goBack()` fires `popstate`. + +Exit confirm: `androidx.appcompat.app.AlertDialog`, strings from +`strings.xml` (`exit_title`, `exit_message`, `exit_confirm`, `exit_cancel`), +positive button → `finish()`. + +Also add `android:enableOnBackInvokedCallback="true"` to ``. +Default at targetSdk 36; explicit is self-documenting. + +**Unconfirmed, device-only:** whether the activity theme at dialog time is +AppCompat-derived. Activity theme is `AppTheme.NoActionBarLaunch` +(parent `Theme.SplashScreen`); Capacitor's splash flow normally swaps to +`AppTheme.NoActionBar` post-splash. If the dialog throws on a non-AppCompat +theme, pass an explicit dialog theme to the builder. Flagged in QA checklist. + +## Files + +| File | Change | +|------|--------| +| `android/android/app/src/main/AndroidManifest.xml` | VIBRATE, enableOnBackInvokedCallback | +| `android/android/app/src/main/java/com/miti99/loto/MainActivity.java` | back callback + confirm dialog | +| `android/android/app/src/main/res/values/strings.xml` | 4 exit-dialog strings | +| `web/src/lib/wake-lock.js` | new | +| `web/src/lib/wake-lock.test.js` | new | +| `web/src/lib/overlay-history.js` | new | +| `web/src/lib/overlay-history.test.js` | new | +| `web/src/lib/MasterPanel.svelte` | wake-lock effect | +| `web/src/lib/PlayerBoard.svelte` | overlay push for bingo modal | +| `web/src/lib/SettingsButton.svelte` | overlay push for settings sheet | + +## Validation + +- `pnpm test` — new suites green, existing 6 suites unaffected. +- `pnpm lint`, `pnpm build`. +- Device QA: tap buzzes; reduced-motion silent; 3-min auto-call, screen stays + lit; background/return keeps it lit; back closes modal; back at root confirms; + confirm dialog renders (theme check). diff --git a/plans/260814-1317-android-experience-pass/phase-02-tier2-defensive.md b/plans/260814-1317-android-experience-pass/phase-02-tier2-defensive.md new file mode 100644 index 0000000..882d200 --- /dev/null +++ b/plans/260814-1317-android-experience-pass/phase-02-tier2-defensive.md @@ -0,0 +1,132 @@ +# Phase 2 — Tier 2, designed defensively + +Suspected but unconfirmed without a device. Every change here is correct whether +or not the bug reproduces, and costs nothing if it does not. + +## W4 · Safe-area insets + +`web/src/app.html` — viewport meta gains `viewport-fit=cover`: + +```html + +``` + +`web/src/app.css` — pad `body` by the insets: + +```css +body { + padding-top: env(safe-area-inset-top); + padding-right: env(safe-area-inset-right); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); +} +``` + +Why `body` and not the page container: `html` carries the background colour and +`body` is already `transparent` + `min-h-full flex flex-col`, so padding here +insets content while the background stays full-bleed. `fixed inset-0` overlays +(bingo modal, settings sheet, inactive-tab curtain) are viewport-relative and +deliberately unaffected — their backdrops should cover the whole screen; their +content is centred and already clear of the bars. + +Bonus: fixes the iOS PWA notch for free. + +Zero effect where insets are 0 (desktop, older Android). + +## W5 · Font scale + +### Native pin + +`MainActivity.java`, after `super.onCreate`: + +```java +getBridge().getWebView().getSettings().setTextZoom(100); +``` + +Stops the system font multiplier from breaking a 9-column fixed grid. + +### In-app replacement control + +Taking the system control away obliges giving one back. Both ship or neither. + +`web/src/lib/settings-store.svelte.js`: + +- `boardTextScale: 1` in `DEFAULT_SETTINGS`. +- `validBoardTextScale(v)` — allowlist `[0.9, 1, 1.15, 1.3]`, same + per-key-validator pattern as the existing keys. +- `applyBoardTextScale()` sets `--board-text-scale` on `documentElement`, + mirroring `applyEmptyCellColor()`; called from `applyAll()`. +- Wire into `loadSettings()` and `resetSettings()`. + +`web/src/app.css` — new classes. `.tan-tan-num` sets font-family/weight only; +sizes come from Tailwind utilities on the elements, so overriding there would be +a specificity fight. Dedicated classes instead, replacing the utilities: + +```css +/* Player card cell — was text-xl sm:text-2xl md:text-3xl */ +.board-num { font-size: calc(1.25rem * var(--board-text-scale, 1)); } +@media (min-width: 640px) { .board-num { font-size: calc(1.5rem * var(--board-text-scale, 1)); } } +@media (min-width: 768px) { .board-num { font-size: calc(1.875rem * var(--board-text-scale, 1)); } } + +/* Master tracking token — was text-xl sm:text-2xl */ +.master-num { font-size: calc(1.25rem * var(--board-text-scale, 1)); } +@media (min-width: 640px) { .master-num { font-size: calc(1.5rem * var(--board-text-scale, 1)); } } +``` + +Two classes, not one: the master board has two breakpoint rungs, the player card +three. Merging them would silently enlarge the master board on desktop. + +Consumers: + +- `PlayerBoard.svelte:451-462` — swap `text-xl sm:text-2xl md:text-3xl` → `board-num`. +- `MasterPanel.svelte:287` — swap `text-xl sm:text-2xl` → `master-num`. + +Scope: grid cells only. Master hero number, called-history chips, and all UI +chrome keep their Tailwind sizes — clipping is a grid problem. + +### Settings UI + +`web/src/lib/SettingsButton.svelte` — new fieldset "Cỡ chữ bảng", 4 buttons +(Nhỏ / Vừa / Lớn / Rất lớn), same `aria-pressed` button-group pattern as the +existing theme/mode pickers. + +Tests — extend `web/src/lib/settings-store.test.js`: default, valid values +persist, invalid/out-of-set falls back to 1, reset restores 1. + +## W6 · Volume rocker → media stream + +`MainActivity.java`: + +```java +setVolumeControlStream(AudioManager.STREAM_MUSIC); +``` + +One line. Volume keys adjust media rather than ringtone even before first +playback. Unambiguously right for an app whose job is calling numbers aloud. + +## Deferred — audio focus / MediaSession + +Still unconfirmed as broken. Building a MediaSession layer around 1-second clips +for a hypothetical problem is speculative work. Revisit with device evidence. + +## Files + +| File | Change | +|------|--------| +| `web/src/app.html` | `viewport-fit=cover` | +| `web/src/app.css` | safe-area body padding, `.board-num`, `.master-num` | +| `web/src/lib/settings-store.svelte.js` | `boardTextScale` + validator + apply | +| `web/src/lib/settings-store.test.js` | coverage for the new key | +| `web/src/lib/SettingsButton.svelte` | size picker fieldset | +| `web/src/lib/PlayerBoard.svelte` | `board-num` class swap | +| `web/src/lib/MasterPanel.svelte` | `master-num` class swap | +| `android/.../MainActivity.java` | textZoom pin, volume stream | + +## Validation + +- `pnpm test` — settings-store suite covers the new key; existing 369 lines of + settings tests must stay green (this file has the most regression surface). +- `pnpm lint`, `pnpm build`. +- Device QA: system font at 200% → grid intact; in-app size control changes + numbers and survives reload; gesture-nav and status bar clear of gear/footer; + volume rocker shows the media slider during a call. diff --git a/plans/260814-1317-android-experience-pass/phase-03-store-polish.md b/plans/260814-1317-android-experience-pass/phase-03-store-polish.md new file mode 100644 index 0000000..347306b --- /dev/null +++ b/plans/260814-1317-android-experience-pass/phase-03-store-polish.md @@ -0,0 +1,82 @@ +# Phase 3 — Store polish + +Independent of phases 1-2. Pure config/resource work. + +## Launcher name + +`android/android/app/src/main/res/values/strings.xml`: + +- `app_name`: `Lo To` → `Lô tô` +- `title_activity_main`: `Lo To` → `Lô tô` + +Vietnamese users currently get an ASCII-mangled launcher label while the app +itself is "Lô tô — Hội chợ TN1". Short form matches the webmanifest +`short_name`. AAPT handles UTF-8; the file already declares +`encoding='utf-8'`. + +Leave `package_name` and `custom_url_scheme` alone — identifiers, not labels. + +## Themed icon (Android 13+) + +`android/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml` — add a +`` layer so the launcher can tint the icon to the user's theme. +Without it Android 13+ falls back to the full-colour icon on themed home +screens. + +Source glyph: `web/static/icons/source.svg`. Monochrome layers must be a flat +silhouette on transparent — no gradients, no background plate. Add as +`res/drawable/ic_launcher_monochrome.xml` (vector) reusing the existing +foreground geometry. + +## Dark splash + +New `android/android/app/src/main/res/values-night/styles.xml` overriding +`AppTheme.NoActionBarLaunch` with a dark splash drawable. The app has a full +dark theme; the splash currently flashes light before the WebView paints. + +Needs a `drawable-night/splash.xml` (or a night colour the existing +`@drawable/splash` references). + +## Version bump + +`android/android/app/build.gradle`: + +- `versionCode 3` → `4` (Play rejects duplicates — already documented in + `android/README.md`). +- `versionName "0.0.3"` → `"0.1.0"`. Still internal track; 1.0.0 would + overclaim. + +## Docs + +`android/README.md`: + +- Permissions: document VIBRATE and why (haptic cell feedback). +- New "Back button" note: closes overlays, confirms exit at root. +- New "Screen wake lock" note: held while a round is active. +- Version-bump section: reflect the new numbers. +- **Leave the "Why no INTERNET permission?" section verbatim.** The offline + guarantee is unchanged and the wording is deliberate. + +## Reassessed, no change + +`allowBackup=true` was listed as a gap in the brainstorm. Stored data is grid, +crossed cells, and UI settings in localStorage — nothing sensitive, and backup +means a player restores their card on a new phone. Correct as-is. + +## Files + +| File | Change | +|------|--------| +| `android/.../res/values/strings.xml` | diacritic app name | +| `android/.../res/mipmap-anydpi-v26/ic_launcher.xml` | monochrome layer | +| `android/.../res/drawable/ic_launcher_monochrome.xml` | new | +| `android/.../res/values-night/styles.xml` | new, dark splash | +| `android/.../res/drawable-night/splash.xml` | new | +| `android/android/app/build.gradle` | versionCode 4, versionName 0.1.0 | +| `android/README.md` | permissions, back button, wake lock, versions | + +## Validation + +- No test surface — resources and docs only. +- Device QA: launcher shows `Lô tô`; themed-icon home screen tints correctly; + cold start in dark mode does not flash light. diff --git a/plans/260814-1317-android-experience-pass/plan.md b/plans/260814-1317-android-experience-pass/plan.md new file mode 100644 index 0000000..be605dc --- /dev/null +++ b/plans/260814-1317-android-experience-pass/plan.md @@ -0,0 +1,82 @@ +--- +status: complete +created: 2026-08-14 +completed: 2026-08-14 +branch: main +source: plans/reports/brainstorm-android-ux-260814-1004-android-experience-pass-report.md +report: plans/reports/from-cook-to-review-260814-1317-android-experience-pass-implementation-report.md +--- + +## Deviations from plan + +1. **Icon scope grew.** Discovered mid-plan that the APK ships Capacitor's + stock logo as launcher icon *and* splash. User approved regenerating from + `source.svg`. Supersedes the originally-planned monochrome-layer and + dark-splash items, which would have themed the wrong logo. +2. **`source.svg` is broken.** `font-size="240"` overflows the 512 canvas; + `web/static/icons/*.png` are visibly clipped. Android art rendered at a + corrected 200/160. Web PWA icons still carry the bug — not fixed, out of + approved scope. +3. **Wake-lock condition tightened** from `autoRunning || hasGame` to + `masterState.remaining.length > 0`. `hasGame` never returns to false once a + round starts, which would have pinned the screen on for a finished board. +4. **Splash rebuilt rather than duplicated.** 11 density PNGs replaced by one + layer-list + night variant + `windowSplashScreen*` for API 31+. + +# Android experience pass + +Close Android-only behaviour gaps in the Capacitor wrapper. Offline guarantee +(no INTERNET permission) untouched. + +## Phases + +| # | Phase | File | Depends on | +|---|-------|------|------------| +| 1 | Tier 1 defects | [phase-01-tier1-defects.md](phase-01-tier1-defects.md) | — | +| 2 | Tier 2 defensive | [phase-02-tier2-defensive.md](phase-02-tier2-defensive.md) | 1 (shares MainActivity) | +| 3 | Store polish | [phase-03-store-polish.md](phase-03-store-polish.md) | — | + +## Resolved before planning + +Brainstorm left W3 (back button) as a fork. Resolved by research: + +- Android 16 / API 36 no longer calls `onBackPressed()` and no longer dispatches + `KEYCODE_BACK`. Project targets 36. +- `OnBackPressedCallback` is the forward-compatible mechanism and keeps working + under predictive back. +- Capacitor 8 `BridgeActivity`/`Bridge` show no back handling on `main`, so + Capacitor's default "back navigates WebView history" cannot be assumed to + survive targetSdk 36 either. + +Consequence: back handling is implemented natively via `OnBackPressedCallback`. +This is not a fallback — it is the only mechanism that can be relied on. Kills +brainstorm risks 1 and 3 (`web/` never takes a Capacitor dependency). + +## Acceptance criteria + +1. Cell tap vibrates on device; `prefers-reduced-motion` still suppresses. +2. Auto-call runs 3+ min untouched without screen dimming; stop releases lock; + background → foreground re-acquires. +3. Back closes an open overlay (bingo modal, settings sheet) instead of quitting; + back at root shows a Vietnamese exit confirm. +4. Player grid legible at 200% system font; in-app board-size control resizes and + persists. +5. Status bar / gesture nav do not overlap the settings gear or footer. +6. Volume rocker controls media stream during a call. +7. Launcher shows `Lô tô` with diacritics. +8. `pnpm test` green, `pnpm lint` clean, `pnpm build` succeeds. + +## Out of scope + +Multi-device sync, INTERNET permission, audio focus/MediaSession, APK size, +`allowBackup` changes, service-worker asset dedup. + +## Verification limits + +No Android device, emulator, or browser in this environment (headless ARM64, no +Chrome — see workspace CLAUDE.md). Gradle/Android SDK not installed. So: + +- Verifiable here: unit tests, lint, web build, static review of native code. +- Device-only: items 1, 2 (real screen), 3, 4 (system font), 5, 6. + +Phase files carry a manual QA checklist for the device-only items. diff --git a/plans/reports/brainstorm-android-ux-260814-1004-android-experience-pass-report.md b/plans/reports/brainstorm-android-ux-260814-1004-android-experience-pass-report.md new file mode 100644 index 0000000..f3e6002 --- /dev/null +++ b/plans/reports/brainstorm-android-ux-260814-1004-android-experience-pass-report.md @@ -0,0 +1,205 @@ +# Brainstorm — Android experience pass + +- Date: 2026-08-14 +- Branch: `main` (c6c839f) +- Mode: brainstorm (no `--html`, no `--wiki`) +- Status: design approved, ready for `/ck:plan` + +## Problem statement + +App ships two targets from one commit: static web (`web/`) and a Capacitor 8 APK +(`android/`). Wrapper is thin — bare `BridgeActivity`, zero plugins, no native code. +Consequence: several behaviours diverge between browser and APK, and none of the +divergences reproduce on desktop where development happens. + +Goal: close the Android-only gaps without breaking the offline guarantee. + +## Locked constraints + +| Constraint | Decision | +|---|---| +| INTERNET permission | Never. Non-negotiable. Offline is a hard guarantee, not a convention. | +| Implementation bias | Web-first. Native only where the web platform cannot reach. | +| Multi-device sync | Out of scope this round. | +| Play data safety | Must stay "no data collected". | + +## Findings (verified against source) + +### Tier 1 — defects that exist only in the APK + +1. **Haptics dead.** `PlayerBoard.svelte:341-346` calls `navigator.vibrate(10)`. + `AndroidManifest.xml` declares no `android.permission.VIBRATE`. Works in Chrome + (Chrome holds the permission), silent no-op in the WebView. +2. **Screen sleeps mid-round.** Auto-call is `setInterval` at 1–10 s/number + (`MasterPanel.svelte:85-104`); a 90-number round is 7.5–15 min. Zero wake-lock + usage anywhere (grepped). Caller device dims, timers throttle, calls stop. + Worst real-world failure; invisible on desktop. +3. **Back button exits instantly.** Bare `BridgeActivity` + single-route SPA = no + history, so back quits. Bingo modal and settings close on Escape + (`PlayerBoard.svelte:218-226`) but not on back. State survives in localStorage; + an in-flight auto-call run does not. + +### Tier 2 — suspected, no device available to confirm + +4. **Edge-to-edge collision.** targetSdk 36 → Android 15+ forces edge-to-edge. + No `viewport-fit=cover` in `app.html`, no `env(safe-area-inset-*)` in CSS. + Settings gear is absolutely positioned at header `top: 0`. +5. **System font scale breaks the grid.** WebView honours system font size. Board is + 9 fixed columns, `text-xl…text-3xl` in `aspect-[3/4]` cells. Audience skews toward + users who enlarge system fonts. Note: unconfirmed whether container-relative units + (`cqw`/`clamp`) survive Android's `textZoom` multiplier — likely not. +6. **Audio focus.** `new Audio()` in WebView may not take Android audio focus; + other apps' audio would play over number calls. Not confirmed broken. + +### Tier 3 — product, deferred by decision + +7. **Every phone is an island.** `active-tab.svelte.js` is explicitly a same-origin + same-device coordinator. Real lô tô = one caller, many players. Cross-device sync + is the single largest available UX win and is blocked by the offline vow. + Owner kept the vow; sync stays out. + +### Checked and dismissed + +- **APK bloat** — audio totals 2.2 MB across both voices. Non-issue. +- **Service-worker asset duplication** — SW re-caches ~2 MB already on disk in the + APK. Real but not worth engineering time. +- **`allowBackup=true`** — stored data is grid, crossed cells, UI settings. Nothing + sensitive; backup lets a player restore their card on a new phone. Correct as-is. + +## Decisions + +| Question | Options weighed | Chosen | Why | +|---|---|---|---| +| Scope | Tier 1 / Tier 2 / Tier 3 / store polish | Tier 1 + Tier 2 + polish | Tier 3 blocked by offline vow | +| Offline vow | keep / LAN-only / server | Keep, non-negotiable | Owner decision | +| Native code | web-only / web-first / native-free | Web-first, native where forced | Keeps wrapper thin, PWA benefits | +| Back button | silent exit / confirm / double-back | Close overlays, confirm exit | Losing a live round warrants one first-party plugin | +| Font scale | pin only / pin + setting / CSS only / defer | Pin `textZoom` + in-app size control | Only option that fixes grid without removing a11y | +| Wake lock | always / active round / toggle | While round active | Caller device sits untouched; player taps self-serve | +| Tier 2 verification | defensive / wait / checklist | Design defensively | Fixes are correct regardless, cost nothing if bug absent | + +## Work items + +### Phase 1 — Tier 1 (no device needed) + +**W1 · VIBRATE permission.** Add `` +to `android/android/app/src/main/AndroidManifest.xml`. Normal permission, no runtime +prompt, no data-safety change. Existing reduced-motion guard unaffected. +*Accept:* cell tap buzzes on device; reduced-motion still silent. + +**W2 · Wake lock.** New `web/src/lib/wake-lock.svelte.js` wrapping +`navigator.wakeLock.request("screen")`. Must re-acquire on `visibilitychange` — +Android auto-releases when the page hides. Consumed by `$effect` in +`MasterPanel.svelte` keyed on `autoRunning || hasGame` (both already local, lines +47/55-57). No-ops when `navigator.wakeLock` absent (old WebView, minSdk 24). +*Accept:* 3+ min auto-call untouched, screen stays lit; stop releases; background → +foreground re-acquires. + +**W3 · Back button.** +- Web half: new `web/src/lib/overlay-history.js`. Overlay open pushes history entry; + single `popstate` listener closes topmost. Careful case: close via button/Escape + must pop the sentinel without re-triggering close. Touches `PlayerBoard.svelte` + (`showCongrats`) and `SettingsButton.svelte`. +- Native half (exit guard): **unresolved fork.** targetSdk 36 enables predictive back + by default and ignores legacy `onBackPressed` interception. Verify whether + Capacitor 8 `App.addListener('backButton')` still fires before writing code. + Fallback: AndroidX `OnBackPressedCallback` in `MainActivity.java` + Vietnamese + confirm dialog from `strings.xml`. Fallback also avoids giving `web/` a Capacitor + dependency. + +*Accept:* back closes an open modal rather than quitting; back at root during a live +round asks first. + +### Phase 2 — Tier 2, defensive + +**W4 · Safe areas.** `viewport-fit=cover` in `web/src/app.html`; +`env(safe-area-inset-*)` padding in `app.css` / `+page.svelte` container. +Also fixes iOS PWA notch. +*Accept:* gear + footer fully visible on Android 15/16 with gesture nav. + +**W5 · Font scale.** `MainActivity.java` pins `textZoom = 100`. Paired with new +`boardTextScale` setting (0.9 / 1.0 / 1.15 / 1.3) in `settings-store.svelte.js`, +own validator following the existing per-key pattern, UI in the settings sheet, +drives a CSS var multiplier on `.tan-tan-num`. +*Accept:* grid intact at 200% system font; in-app control resizes numbers; persists +across reload. + +**W6 · Volume rocker.** `setVolumeControlStream(STREAM_MUSIC)` in `MainActivity.java`. +One line, unambiguously correct for an audio-centric app. + +**Deferred: audio focus / MediaSession.** Unconfirmed problem. Building a MediaSession +layer around 1-second clips speculatively violates YAGNI. Revisit with device evidence. + +### Phase 3 — Store polish + +- `strings.xml`: `app_name` → `Lô tô` (diacritics). Currently ASCII-mangled `Lo To` + in the launcher while the app itself is "Lô tô — Hội chợ TN1". +- Monochrome layer in `mipmap-anydpi-v26/ic_launcher.xml` (Android 13+ themed icons), + derived from `web/static/icons/source.svg`. +- `values-night/styles.xml` — dark splash. App has a full dark theme; splash flashes light. +- `versionCode` + `versionName` bump per existing documented process. Suggest 0.1.0, + not 1.0.0 — still internal track. +- `android/README.md`: document VIBRATE, wake lock, back-button behaviour. Leave the + "no INTERNET permission" section verbatim. + +## Touchpoints + +| File | Work | +|---|---| +| `android/android/app/src/main/AndroidManifest.xml` | W1 | +| `android/android/app/src/main/java/com/miti99/loto/MainActivity.java` | W3 (fallback), W5, W6 | +| `android/android/app/src/main/res/values/strings.xml` | W3 (dialog strings), P3 | +| `android/android/app/src/main/res/values-night/styles.xml` | P3 (new) | +| `android/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml` | P3 | +| `android/android/app/build.gradle` | P3 version bump | +| `web/src/lib/wake-lock.svelte.js` | W2 (new) | +| `web/src/lib/overlay-history.js` | W3 (new) | +| `web/src/lib/MasterPanel.svelte` | W2 | +| `web/src/lib/PlayerBoard.svelte` | W3 | +| `web/src/lib/SettingsButton.svelte` | W3, W5 | +| `web/src/lib/settings-store.svelte.js` | W5 | +| `web/src/app.html` | W4 | +| `web/src/app.css` | W4, W5 | +| `android/README.md` | P3 docs | + +## Risks + +1. **Predictive back vs Capacitor 8** (W3) — resolve before implementation; fallback + identified. Highest-uncertainty item in the plan. +2. **`textZoom` pin overrides accessibility** — mitigated only by W5's in-app control. + They ship together or not at all; cutting the setting means cutting the pin. +3. **`web/` gaining a Capacitor dependency** — if the exit guard uses `@capacitor/app`, + the standalone GH Pages build inherits it. Native fallback avoids this. +4. **No device in the working environment** — W4/W5 designed on reasoning, not + observation. Both safe-by-default; harmless if the bug does not reproduce. + +## Validation + +- `cd web && pnpm test` — settings-store gains `boardTextScale` validator coverage. +- New unit coverage: `overlay-history` push/pop/dismiss ordering; `wake-lock` + re-acquire on visibility change. +- Manual on Android 15/16 device: haptics, 3-min auto-call with screen untouched, + back from modal, back at root mid-round, system font at 200%, gesture-nav insets, + volume rocker during a call. +- `cd android && npm run build && npm run assemble:debug` must stay green. + +## Out of scope + +Multi-device sync, INTERNET permission, audio focus/MediaSession, APK size, +`allowBackup` changes, service-worker asset dedup. + +## Open questions + +1. W3 native path — Capacitor 8 `backButton` under predictive back: fires or not? + Decides whether `web/` takes a Capacitor dependency. +2. Confirm-on-exit trigger — only when a round is live (needs the flag plumbed to + native), or unconditionally at root (simpler, mildly noisier)? +3. `boardTextScale` steps — are 0.9/1.0/1.15/1.3 the right rungs, or is a continuous + slider preferred alongside the existing colour picker? + +## Next step + +`/ck:plan` with this report as input. Default mode recommended over `--tdd`: work is +mostly additive across new modules and native config, not a refactor of covered +behaviour. Exception — W5 touches `settings-store.svelte.js`, which has 369 lines of +existing tests worth preserving. diff --git a/plans/reports/from-cook-to-review-260814-1317-android-experience-pass-implementation-report.md b/plans/reports/from-cook-to-review-260814-1317-android-experience-pass-implementation-report.md new file mode 100644 index 0000000..69e0e11 --- /dev/null +++ b/plans/reports/from-cook-to-review-260814-1317-android-experience-pass-implementation-report.md @@ -0,0 +1,131 @@ +# Android experience pass — implementation report + +- Date: 2026-08-14 +- Branch: `main` (uncommitted) +- Plan: [plans/260814-1317-android-experience-pass/plan.md](../260814-1317-android-experience-pass/plan.md) +- Brainstorm: [brainstorm-android-ux-260814-1004](brainstorm-android-ux-260814-1004-android-experience-pass-report.md) + +## Verified here + +| Gate | Result | +|------|--------| +| `pnpm test` | 8 files, 121 tests pass (was 6 files / 105) | +| `pnpm lint` | clean | +| `pnpm build` | succeeds; `viewport-fit`, safe-area CSS, `.board-num`/`.master-num`, `--board-text-scale` confirmed present in output | + +## NOT verified — no device, emulator, browser, or Android SDK + +Gradle cannot run here (`ANDROID_HOME` unset, no SDK, no `adb`). **The Java and +all Android resources are uncompiled.** Static review only. + +Device QA required for: haptics, wake lock, back button + exit dialog, safe-area +insets, system font scale, volume rocker, launcher icon, splash. + +## Delivered + +### Phase 1 — Tier 1 defects + +- **W1 VIBRATE** — `AndroidManifest.xml`. Normal permission; no runtime prompt, + no data-safety change. +- **W2 wake lock** — new `web/src/lib/wake-lock.js` + 8 tests. Re-acquires on + `visibilitychange` (Android drops the lock on hide). Generation token stops a + late-resolving `request()` leaking a lock after the caller turned it off. + Consumed by a `$effect` in `MasterPanel.svelte`. +- **W3 back button** — native `OnBackPressedCallback` in `MainActivity.java` + + new `web/src/lib/overlay-history.js` (8 tests). Overlays push a history + sentinel; `canGoBack()` therefore means "an overlay is open". Root back shows + a Vietnamese confirm dialog. + +### Phase 2 — Tier 2, defensive + +- **W4 safe areas** — `viewport-fit=cover` + `env(safe-area-inset-*)` padding on + `body`. Also fixes the iOS PWA notch. +- **W5 font scale** — `textZoom = 100` pinned natively; new `boardTextScale` + setting (0.9/1/1.15/1.3) with validator, CSS var, and a "Cỡ chữ bảng" picker + in the settings sheet. 7 new tests. +- **W6 volume rocker** — `setVolumeControlStream(STREAM_MUSIC)`. + +### Phase 3 — Store polish + +- `app_name` → `Lô tô` (was ASCII `Lo To`). +- **Launcher icon and splash rebuilt from the brand mark** (see finding below). + Legacy + round + adaptive foreground + monochrome at 5 densities; adaptive + background as a gradient vector; splash as a themed layer-list with a night + variant plus `windowSplashScreen*` for API 31+. +- 11 stock splash PNGs and the template robot vector deleted. +- `versionCode 4`, `versionName 0.1.0`. +- `android/README.md`: permissions, Android-specific behaviour, icon pipeline. + +## Findings + +### 1. The APK was shipping Capacitor's logo (fixed) + +Launcher icon, round icon, adaptive foreground and splash were all the stock +Capacitor template — the blue "X" mark on a teal grid plate. The brand icon +existed only in `web/static/icons/`. A published Play Store app was showing +another project's logo in the launcher and on cold start. + +### 2. `source.svg` overflows its canvas (NOT fixed — web scope) + +`font-size="240"` renders "Lô tô" at ~437px against a 400px inner panel, and the +original PNGs were rendered without Roboto Condensed so a wider fallback pushed +it past the 512px canvas entirely. **`web/static/icons/icon-192.png`, +`icon-512.png` and `icon-maskable-512.png` are visibly clipped** — the `L` and +the trailing `ô` are cut off. These are what the PWA, GH Pages install prompt, +and the OG/Twitter card images use. + +Android art was rendered at a corrected size (200 legacy / 160 adaptive) with +the real font. The web PNGs were left alone — outside approved scope. + +### 3. Predictive back changes the mechanism (resolved during planning) + +Android 16 stops calling `onBackPressed()` and stops dispatching `KEYCODE_BACK` +at targetSdk 36. Capacitor 8 shows no back handling in `BridgeActivity`/`Bridge` +on `main`, so its default "back navigates WebView history" cannot be assumed to +survive either — meaning a pure-web History-API approach could not have worked +alone. `OnBackPressedCallback` is the only reliable mechanism. + +### 4. `allowBackup` reassessed — no change + +Flagged as a gap in the brainstorm. Stored data is grid, crossed cells, and UI +settings. Nothing sensitive; backup restores a player's card on a new phone. +Correct as-is. + +## Risks carried into device QA + +1. **Exit dialog theme.** Activity theme is `AppTheme.NoActionBarLaunch` + (parent `Theme.SplashScreen`). Mitigated two ways: explicit + `AppTheme.ExitDialog` (`Theme.AppCompat.DayNight.Dialog.Alert`) passed to the + builder, and `postSplashScreenTheme` added. If the dialog still throws, the + activity theme is the cause. +2. **`canGoBack()` baseline.** Assumes the WebView has no history at rest. + SvelteKit hydrates with `replaceState`, so it should be false — verify back + at the root confirms exit rather than navigating. +3. **`textZoom` pin is an accessibility override.** Only defensible alongside + the in-app size control. If that setting is ever cut, cut the pin too. +4. **Uncompiled Java.** `OnBackPressedCallback` and `Bridge` resolve + transitively through `appcompat` / `capacitor-android`; not proven. + +## Device QA checklist + +- [ ] Tap a cell → vibrates; enable reduced-motion → 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. +- [ ] 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. + +## Unresolved questions + +1. Fix the clipped web PWA icons (finding 2)? Same root cause, different scope — + changes GH Pages / PWA / OG card images. +2. `boardTextScale` rungs 0.9/1/1.15/1.3 — enough range for users who ran + system font at 200%? +3. Exit confirm currently fires at root regardless of round state. Worth + plumbing "is a round live" to native so an idle app exits without a prompt?