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.
This commit is contained in:
2026-08-14 15:21:03 +07:00
parent 8aff576fe2
commit 7803f336c4
6 changed files with 769 additions and 0 deletions
@@ -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
<uses-permission android:name="android.permission.VIBRATE" />
```
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 `<application>`.
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).
@@ -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
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
```
`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.
@@ -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
`<monochrome>` 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.
@@ -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.
@@ -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 110 s/number
(`MasterPanel.svelte:85-104`); a 90-number round is 7.515 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 `<uses-permission android:name="android.permission.VIBRATE" />`
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.
@@ -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?