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.
10 KiB
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
- Haptics dead.
PlayerBoard.svelte:341-346callsnavigator.vibrate(10).AndroidManifest.xmldeclares noandroid.permission.VIBRATE. Works in Chrome (Chrome holds the permission), silent no-op in the WebView. - Screen sleeps mid-round. Auto-call is
setIntervalat 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. - 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
- Edge-to-edge collision. targetSdk 36 → Android 15+ forces edge-to-edge.
No
viewport-fit=coverinapp.html, noenv(safe-area-inset-*)in CSS. Settings gear is absolutely positioned at headertop: 0. - System font scale breaks the grid. WebView honours system font size. Board is
9 fixed columns,
text-xl…text-3xlinaspect-[3/4]cells. Audience skews toward users who enlarge system fonts. Note: unconfirmed whether container-relative units (cqw/clamp) survive Android'stextZoommultiplier — likely not. - 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
- Every phone is an island.
active-tab.svelte.jsis 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; singlepopstatelistener closes topmost. Careful case: close via button/Escape must pop the sentinel without re-triggering close. TouchesPlayerBoard.svelte(showCongrats) andSettingsButton.svelte. - Native half (exit guard): unresolved fork. targetSdk 36 enables predictive back
by default and ignores legacy
onBackPressedinterception. Verify whether Capacitor 8App.addListener('backButton')still fires before writing code. Fallback: AndroidXOnBackPressedCallbackinMainActivity.java+ Vietnamese confirm dialog fromstrings.xml. Fallback also avoids givingweb/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-mangledLo Toin 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 fromweb/static/icons/source.svg. values-night/styles.xml— dark splash. App has a full dark theme; splash flashes light.versionCode+versionNamebump 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
- Predictive back vs Capacitor 8 (W3) — resolve before implementation; fallback identified. Highest-uncertainty item in the plan.
textZoompin overrides accessibility — mitigated only by W5's in-app control. They ship together or not at all; cutting the setting means cutting the pin.web/gaining a Capacitor dependency — if the exit guard uses@capacitor/app, the standalone GH Pages build inherits it. Native fallback avoids this.- 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 gainsboardTextScalevalidator coverage.- New unit coverage:
overlay-historypush/pop/dismiss ordering;wake-lockre-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:debugmust stay green.
Out of scope
Multi-device sync, INTERNET permission, audio focus/MediaSession, APK size,
allowBackup changes, service-worker asset dedup.
Open questions
- W3 native path — Capacitor 8
backButtonunder predictive back: fires or not? Decides whetherweb/takes a Capacitor dependency. - Confirm-on-exit trigger — only when a round is live (needs the flag plumbed to native), or unconditionally at root (simpler, mildly noisier)?
boardTextScalesteps — 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.