mirror of
https://github.com/tiennm99/loto.git
synced 2026-08-14 12:26:33 +00:00
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:
@@ -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 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 `<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.
|
||||
+131
@@ -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?
|
||||
Reference in New Issue
Block a user