feat: migrate to Capacitor wrapper for fully-offline Android

Replace the native Kotlin/Compose port with a Capacitor 8 wrapper around
the upstream loto SvelteKit PWA. Loto is consumed as a git submodule and
its static build is bundled into the APK at sync time. The wrapper has
no INTERNET permission, so fully offline is enforced rather than
conventional.

- loto/ submodule pinned to dfb1c1e
- android/ scaffolded by cap add android; signing wired via env vars
- All 184 voice MP3s + service worker bundled in assets/public/
- CI checkouts submodule, runs npm build, then Gradle inside android/
- Native port preserved in git history at e7fb3d0 and 8b8d46e
This commit is contained in:
tiennm99 committed 2026-05-10 02:03:16 +07:00
1 parent 0d3d822b36
commit 7840aa419d
315 files changed
+2672 -4836

No files matched your search

@@ -0,0 +1,254 @@
# Research Report: Wrapping the `loto` SvelteKit PWA as an Android App (No Source Changes)
**Date:** 2026-05-10
**Question:** Can we wrap `tiennm99/loto` inside an Android app — writing only Android-side code, leaving the loto codebase untouched?
---
## TL;DR
**Yes, easily.** The loto project is already a near-perfect candidate:
- SvelteKit + `@sveltejs/adapter-static` → ships as a static `build/` folder
- Already a PWA (`@vite-pwa/sveltekit`, hand-written `manifest.webmanifest`, Workbox SW)
- Audio is bundled MP3s in `static/audio/` → no network needed at runtime once cached
- Already deploys to GitHub Pages at `/loto` (`BUILD_PROFILE=gh`)
Three viable Android-only wrapper approaches, ranked:
| # | Approach | Modifies loto? | Effort | Best when |
|---|----------|----------------|--------|-----------|
| 1 | **TWA via Bubblewrap** | No | XS | You're OK requiring HTTPS hosting + Chrome on device |
| 2 | **Capacitor (web-shell)** | No (just consumes `build/`) | S | You want fully offline-from-install, Play Store distribution |
| 3 | **Raw Android WebView** | No | M | You want pure Android code, no Node toolchain in the wrapper repo |
The current `loto-android` repo is a *native Kotlin port* — that's a much bigger surface than any of the above. If maintenance burden is a concern, a wrapper would have been ~10× less code.
---
## 1. Approach Overview
### Option 1 — Trusted Web Activity (TWA) via Bubblewrap **[recommended]**
Bubblewrap is Google's CLI that generates an Android Studio project that launches a deployed PWA in a Chrome Custom Tab branded as your app. ([GoogleChromeLabs/bubblewrap](https://github.com/GoogleChromeLabs/bubblewrap))
**How it fits loto:**
- Loto already deploys to GitHub Pages → `https://tiennm99.github.io/loto/` (HTTPS ✓)
- `manifest.webmanifest` already exists ✓
- Bubblewrap reads the manifest and generates the Android project; you publish to Play Store
**Steps (high-level):**
```bash
npm i -g @bubblewrap/cli
bubblewrap init --manifest=https://tiennm99.github.io/loto/manifest.webmanifest
bubblewrap build # produces signed APK + AAB
```
Plus one server-side step: publish `assetlinks.json` at `https://tiennm99.github.io/.well-known/assetlinks.json` to verify domain ownership. Without it, the URL bar shows up — minor but ugly.
**Pros**
- Zero loto changes, zero JS shim, zero WebView quirks (it *is* Chrome)
- App auto-updates the moment the PWA deploys — no Play Store release needed for content
- Smallest APK (~2 MB) and best audio/perf since it's the real Chromium
- Official Google path, supported on Play Store
**Cons**
- Requires Chrome (or any TWA-capable browser) on the device — true for ~99% of Android phones with Play Services
- Needs network for first launch (PWA caches kick in afterwards). If first launch must be offline → use Capacitor.
- `assetlinks.json` setup needed for clean address-bar-less UX
- Min Android 5.0 (Lollipop) — well below loto-android's current `minSdk 24`
---
### Option 2 — Capacitor
Capacitor wraps any static web build (HTML/CSS/JS) inside a native Android (and iOS) shell, serving assets locally via `https://localhost`. Officially supported with SvelteKit `adapter-static`. ([capacitorjs.com/solution/svelte](https://capacitorjs.com/solution/svelte), [bryanhogan.com](https://bryanhogan.com/blog/web-to-app-sveltekit-capacitor))
**How it fits loto:**
- Build loto as static (`npm run build` → `./build/`)
- Point Capacitor's `webDir` at `loto/build`
- `cap add android` generates an Android Studio project; `cap sync` copies assets in
- Audio MP3s in `static/` end up bundled in the APK
**Configuration nuance:** loto's `BUILD_PROFILE=gh` sets `paths.base = "/loto"`. For Capacitor use the **default profile** (`npm run build`, base = `""`) so assets resolve at `https://localhost/`.
**Pros**
- Fully offline from first launch (audio + app shell shipped in APK)
- No reliance on user having Chrome/TWA
- Plugin ecosystem if you ever want native features (haptics, share, etc.)
- Wrapper lives in a separate repo, loto is consumed as a built artifact
**Cons**
- Wrapper repo needs Node + `npx cap` tooling, not pure-Android
- APK ~10–15 MB larger (bundled WebView assets + audio)
- WebView ≠ Chrome; less common quirks but they exist
- The ServiceWorker layer adds a footgun under WebView (see option 3 caveat)
---
### Option 3 — Raw Android WebView (pure Android code)
Plain `WebView` inside an `Activity`, loading either:
- **(a)** the remote PWA URL (`https://tiennm99.github.io/loto/`) — like a TWA but uglier and you maintain it, or
- **(b)** the bundled `build/` copied into `app/src/main/assets/` and served via `WebViewAssetLoader` at a virtual `https://appassets.androidplatform.net/...` host.
**Pros**
- 100% Android code in the wrapper repo — no Node toolchain
- Full control over Activity lifecycle, splash, intents, deep links
**Cons**
- You re-implement what TWA/Capacitor give for free: back-button handling, file uploads, permissions, audio focus, etc.
- **Service Worker pitfall:** WebView's SW lifecycle is shaky — registrations can drop on cold start unless you wire up `ServiceWorkerController` + `ServiceWorkerClient` correctly. ([w3tutorials.net](https://www.w3tutorials.net/blog/how-to-use-service-workers-in-android-webview/))
- Audio MIME types, autoplay policy, `loadWithOverviewMode` quirks all become your problem.
- Updates require Play Store release if (b); requires network if (a).
Use this only if you have a hard "no Node in the build pipeline" rule.
---
## 2. Comparative Analysis
| Dimension | TWA / Bubblewrap | Capacitor | Raw WebView |
|---|---|---|---|
| Loto code changes | none | none | none |
| First-launch offline | ✗ (cache after 1st load) | ✓ | ✓ if assets bundled |
| APK size | ~2 MB | ~10–15 MB | ~5–15 MB |
| Audio/perf fidelity | Chrome (best) | System WebView | System WebView |
| Auto-update on PWA deploy | ✓ | ✗ (need new APK) | ✓ if remote, ✗ if bundled |
| Wrapper toolchain | Node CLI once, then Android | Node + `npx cap` ongoing | Pure Android |
| Maintenance burden | Lowest | Low–Medium | Medium |
| Play Store eligibility | ✓ (official) | ✓ | ✓ |
---
## 3. Recommendation for loto
**Pick Bubblewrap/TWA unless first-launch offline is a hard requirement.** Reasons:
1. The loto PWA already has manifest + SW + bundled audio — the Workbox `additionalManifestEntries` precaches the default voice's clips on first visit, so once a user opens the app once, they're offline-capable. Acceptable for a fairground bingo app where users typically install before the event.
2. Cuts maintenance to ~near zero. Loto evolves; TWA app body never needs touching.
3. `assetlinks.json` is the only friction. Place at `https://tiennm99.github.io/.well-known/assetlinks.json` → done. (Note GitHub Pages serves the user-page apex domain, not project-page subpaths, so you'd need a thin `tiennm99.github.io` repo with a `static.json`/`.well-known/` route — or use a custom domain.)
**If first-launch offline is required** (e.g., users install at the venue with no Wi-Fi): use **Capacitor**. Slightly larger APK, but everything is in the bundle.
**Skip raw WebView** unless the requirement is explicitly "no Node tooling." It's the most code with the worst payoff.
---
## 4. Implication for the existing `loto-android` repo
The current repo is a full-blown Kotlin/Compose **rewrite** of loto: `GameLogicTest`, `VietnameseNumberTest`, ExoPlayer audio, DataStore settings, Compose UI, etc. That's ~30+ files of native code that must track every loto feature change.
A wrapper approach (any of the three) would have been:
- 1 `MainActivity` + manifest + a few drawables (TWA), or
- A Capacitor scaffold + 1 plugin-wiring file
That's the trade-off. Native gives you native feel + tighter Play Store store presence; wrapper gives you ~zero divergence cost. The `loto-android` README already documents a parallel codebase ("Native Android port of tiennm99/loto"), which is the long-term cost you've taken on.
If the goal of this question is **"should we replace loto-android with a wrapper?"** — yes, you can, and the loto code itself doesn't need to change. The question is whether you want to throw away the existing native UI work. If the native port already shipped and tests pass, sunk cost is real.
---
## 5. Quick-Start Snippets
### Bubblewrap
```bash
# In a new directory, NOT the loto repo
npm i -g @bubblewrap/cli
bubblewrap init --manifest=https://tiennm99.github.io/loto/manifest.webmanifest
# Answer prompts: app id (com.miti99.loto.twa), display name, etc.
bubblewrap build
# Output: app-release-bundle.aab + app-release-signed.apk
# Get the SHA256 fingerprint Bubblewrap printed and put this in
# https://tiennm99.github.io/.well-known/assetlinks.json:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.miti99.loto.twa",
"sha256_cert_fingerprints": ["<FROM BUBBLEWRAP>"]
}
}]
```
### Capacitor
```bash
# In a new wrapper repo:
npm init -y
npm i @capacitor/core @capacitor/android
npm i -D @capacitor/cli
npx cap init "Lo To" com.miti99.loto --web-dir=../loto/build
# Build loto first (default profile, NOT BUILD_PROFILE=gh)
( cd ../loto && npm run build )
npx cap add android
npx cap sync
npx cap open android # opens in Android Studio
```
### Raw WebView (sketch)
```kotlin
// MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/assets/", AssetsPathHandler(this))
.build()
val webView = WebView(this).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest) =
assetLoader.shouldInterceptRequest(request.url)
}
loadUrl("https://appassets.androidplatform.net/assets/index.html")
}
setContentView(webView)
}
}
```
You'd also need `ServiceWorkerController.getInstance().setServiceWorkerClient(...)` so SW fetches go through the same `assetLoader`.
---
## 6. Common Pitfalls
- **Capacitor + GH base path:** if you `BUILD_PROFILE=gh` before `cap sync`, all asset URLs become `/loto/...` and break under `https://localhost/`. Use the default build profile.
- **Bubblewrap + no `assetlinks.json`:** app launches with a Chrome-style URL bar. Fixable post-launch but jarring for first impressions.
- **WebView SW persistence:** if the offline experience randomly breaks after app restart, it's the Service Worker registration getting GC'd. Configure `ServiceWorkerController`.
- **Audio autoplay:** WebView blocks autoplay by default. Set `settings.mediaPlaybackRequiresUserGesture = false` (only for option 3 — TWA/Capacitor handle this).
- **Play Store policy:** TWAs and Capacitor apps are explicitly allowed; raw WebView shells that are "thin browsers" are sometimes flagged. Add native value (offline-first, push, etc.) to be safe.
---
## 7. Resources
### Official
- [GoogleChromeLabs/bubblewrap (CLI)](https://github.com/GoogleChromeLabs/bubblewrap)
- [TWA Quick Start — Android Developers](https://developer.android.com/develop/ui/views/layout/webapps/guide-trusted-web-activities-version2)
- [Adding Your PWA to Google Play (codelab)](https://developers.google.com/codelabs/pwa-in-play)
- [Capacitor + Svelte](https://capacitorjs.com/solution/svelte)
- [SvelteKit Service Workers](https://svelte.dev/docs/kit/service-workers)
- [vite-pwa/sveltekit](https://github.com/vite-pwa/sveltekit) (already in use by loto)
### Walkthroughs
- [From Web to Native: SvelteKit & Capacitor — Bryan Hogan](https://bryanhogan.com/blog/web-to-app-sveltekit-capacitor)
- [Cross-Platform SvelteKit & Capacitor — Ionic Blog](https://ionic.io/blog/cross-platform-sveltekit-capacitor-application-yes-its-possible)
- [PWA + Bubblewrap — Thinktecture](https://www.thinktecture.com/en/pwa/twa-bubblewrap/)
- [Submitting a PWA via Bubblewrap — Vaadin](https://vaadin.com/blog/submitting-a-pwa-to-google-play-store-using-bubblewrap)
- [Service Workers in Android WebView](https://www.w3tutorials.net/blog/how-to-use-service-workers-in-android-webview/)
---
## Unresolved Questions
1. Is the current native `loto-android` already published to Play Store / shipped to users? If yes, ABI-compat (`com.miti99.loto`) means a wrapper migration would replace the existing app — UX/data-migration plan needed (DataStore settings won't survive).
2. Do users typically install **before** going to the venue (likely yes — fairground game) or at the venue with no Wi-Fi (would force Capacitor over TWA)?
3. Does loto need any feature that pure-web can't do well (background audio while screen off, lock-screen controls, system media notification)? If yes, native (current path) wins; if no, wrapper wins.
4. Custom domain plan? `assetlinks.json` on `tiennm99.github.io` requires writing to the user-page repo — using a custom domain (e.g., `loto.miti99.com`) sidesteps this cleanly.
+57 -43
View File
@@ -1,62 +1,76 @@
# Lô tô Android — TODO
Outstanding work, grouped by priority. The 11 implementation phases from
`260427-1153-loto-android-port/` are complete and pushed to GitHub.
## Direction change (2026-05-10)
## P0 — Verify build (cannot do here, no JDK/SDK)
Repo migrated from **native Kotlin/Compose port** to a **Capacitor wrapper**
around the upstream `tiennm99/loto` SvelteKit PWA. Native code lives in git
history at commits `9a35686` and `fe52232` — recoverable but no longer the
ship target.
- [ ] Open `/config/workspace/tiennm99/loto-android/` in Android Studio Ladybug+
- [ ] Sync Gradle, fix any version-resolution gaps
- [ ] `./gradlew :app:lint :app:test :app:assembleDebug`
- [ ] Run on emulator (API 24, API 31, API 35) — golden path: generate card → tap row to bingo → "Kinh!" modal + audio
- [ ] Run instrumentation: `./gradlew :app:connectedDebugAndroidTest` (`MainActivityComposeSmokeTest`)
- [ ] Sanity-check first-build CI run: GitHub Actions tab → `build-debug` workflow
Reason: maintenance parity. Loto evolves; the wrapper rebuilds and ships
without re-porting features.
## P1 — Tests not written by the agent
## P0 — Verify CI build (cannot do locally, no JDK/SDK in dev sandbox)
- [ ] `CallBusTest` — Turbine: broadcast/reset/monotonic id (specced in phase-06)
- [ ] `PlayerBoardViewModelTest` — bingo idempotency, forward-only auto-tick, mode gating, voice announce rule (specced in phase-06).
Tier rule (post-260429-1511 sync): `tier == 2` when `celebrated.size >= 2` OR `(celebrated.size >= 1 AND any other row in Chờ)`. Also assert `sectionHasWaiting[s]` flips amber when any row in section `s` has 1 uncrossed number, drops once that row completes.
- [ ] `MasterPanelViewModelTest` — `runTest` with `advanceTimeBy` for auto-call cadence, settings-mid-run cancel, remaining-empty stop (specced in phase-06)
- [ ] `VoicePlayerInstrumentationTest` — assets resolve, clip plays end-to-end (specced in phase-04)
- [ ] `MasterBoardLayoutTest` — verify the 11×9 ones-digit layout (90 in row10/col8, edge cells correct)
- [ ] `VoiceManifestTest` — JSON parse, malformed input fallback
- [ ] Push to a PR branch → confirm `build-debug` workflow assembles APK
- [ ] Sideload the artifact onto a real device (API 24, API 31, API 36)
- [ ] Smoke test cold start: app loads `https://localhost/`, no white flash > 1s
- [ ] Smoke test offline: enable airplane mode, kill app, relaunch — must work
- [ ] Smoke test audio: tap any number → Vietnamese voice plays
- [ ] Verify both voices switch correctly (`hoai-my` ↔ `nam-minh`)
## P1 — Known concerns flagged during build
## P0 — Wrapper-specific concerns
- [ ] `BringIntoViewRequester` carries `@OptIn(ExperimentalFoundationApi::class)` in `MasterPanelScreen.kt` — check if Compose BOM 2025.01+ has stabilized this; if so, drop the annotation
- [ ] Confetti fall-speed multiplier is `screenHeightDp * 3f` — heuristic, tune on real devices if particles look off
- [ ] Auto-call coroutine restarts on every speed change — verify the cancel/restart doesn't leak the prior delay timer
- [ ] First-launch SW registration: confirm Workbox precaches the bundled audio
(should be idempotent since assets already on-disk, but verify no errors)
- [ ] Back button behavior: Capacitor default closes app — decide whether to
add `App.addListener('backButton', …)` for in-app history navigation
- [ ] Safe-area / status-bar inset: SvelteKit UI may render under the status bar.
Check; add `<StatusBar>` plugin if needed
- [ ] Audio focus: WebView `<audio>` doesn't claim AUDIOFOCUS_GAIN. Test what
happens when a phone call comes in mid-call (does playback duck/pause?)
- [ ] Verify `android:supportsRtl="true"` (Capacitor default) doesn't flip the
Vietnamese UI
## P2 — Polish before public release
## P1 — Polish before public release
- [ ] Replace the placeholder `ic_launcher_foreground.xml` (stylized "L" stroke) with a real adaptive icon — rose→amber wordmark or a fairground motif
- [ ] App-screenshot generation for Play Store / GitHub README (3–5 phone shots: cold start, mid-game, master panel, settings, bingo modal)
- [ ] Splash screen via `androidx.core:core-splashscreen` (currently the white themes.xml flash is acceptable but unbranded)
- [ ] Dark mode visual pass — verify each cell/token combination, especially dark-mode empty-cell overlay
- [ ] Per-density rasters for the launcher icon (`ic_launcher.webp` at mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi) for pre-API 26 devices
- [ ] Accessibility: TalkBack content descriptions on every interactive cell + toast; verify 4.5:1 contrast in both themes
- [ ] Replace placeholder launcher icon (Capacitor ships a generic Material icon).
Use loto's existing `static/icons/` rasters; consider `@capacitor/assets`
to generate adaptive + splash from a single source
- [ ] Splash screen: `@capacitor/splash-screen` plugin with brand colors
- [ ] Status bar styling: brand-colored, light/dark text via `@capacitor/status-bar`
- [ ] App-store screenshots (3–5 phone shots): cold start, mid-game, master panel,
settings, bingo modal
## P2 — Distribution
## P1 — Distribution
- [ ] Generate release keystore: `keytool -genkey -v -keystore ~/.android/loto-release.jks -keyalg RSA -keysize 4096 -validity 36500 -alias loto`
- [ ] Add GitHub secrets: `KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`
- [ ] Tag `v1.0.0` and push → exercises `release.yml` (signed AAB+APK to GH Release)
- [ ] (Optional) Play Console listing — privacy policy, content rating, target audience, store listing in Vietnamese
- [ ] Tag `v1.0.0` and push → exercises `release.yml`
- [ ] (Optional) Play Console listing in Vietnamese — privacy policy, content
rating, target audience
## P3 — Future features (not committed)
## P2 — Supply-chain hygiene
- [ ] Host's own player card (web app supports it via `loto_master_card` prefix but doesn't render it; same here)
- [ ] Undo last crossed cell
- [ ] Sound effects on bingo (separate from voice)
- [ ] PWA-style "add to home screen" — N/A on Android (already an app)
- [ ] Multiplayer sync via WebSocket
- [ ] English i18n — `values-en/strings.xml`
- [ ] `npm audit` — wrapper repo (currently 0 vuln)
- [ ] `npm audit` — loto submodule (currently 2 high — bump and re-pin)
- [ ] Dependabot / Renovate for both `package.json` and the loto submodule SHA
## P2 — Future features (not committed)
- [ ] Add `@capacitor/share` for sharing the called-numbers card
- [ ] Add `@capacitor/haptics` for bingo confirmation
- [ ] Background audio (play numbers while screen off) — WebView audio is
foreground-only; would need a native plugin or service shim
- [ ] iOS target (`npx cap add ios`) — same wrapper, ~zero extra code
## Decisions record
- **Plan archive**: `/config/workspace/plans/260427-1153-loto-android-port/plan.md` (overview only; phase docs deleted post-implementation)
- **Research reports**: `/config/workspace/plans/reports/researcher-260427-1153-{loto-codebase-analysis,android-stack,android-audio}.md`
- **Audio framework pick**: Media3 ExoPlayer (over SoundPool) — see audio research report
- **DI pick**: manual construction in `LotoApp.onCreate` (no Hilt) — revisit at >10 surfaces
- **Theme**: brand-fixed colors, `dynamicColor = false` — Material You system colors would mute the rose→amber identity
- **Architecture**: Capacitor 8 wrapper, not TWA — chosen for first-launch
offline guarantee (TWA needs network for first PWA fetch)
- **Loto integration**: git submodule pinned to a SHA, not npm package — loto
isn't published; submodule keeps the wrapper reproducible
- **No INTERNET permission**: enforces "fully offline" as a hard guarantee.
Add back if any remote feature is introduced
- **Native port retired**: lives at commits `fe52232`/`9a35686` if someone
ever wants the ExoPlayer + Compose UI back