Plans were split across three trees: plans/, web/plans/, and android/plans/. Merge them into plans/ at the repo root. Sweep the shipped Android experience pass and the two reports for it, carrying the residual forward into plans/todo.md: the device QA checklist (open only because this environment has no device or emulator), the clipped web PWA icons defect, and the untested native assumptions. Merge android/plans/todo.md in the same pass, dropping items the experience pass already delivered -- haptics, back button, safe-area insets, launcher icon, and splash -- each verified against MainActivity.java, web/src/app.css, and the res/ tree. Correct two stale claims while merging: web/ is a plain directory in this monorepo, not a git submodule, and the serialize-javascript override is held by @rollup/plugin-terser rather than workbox-build. The retired native Kotlin/Compose port plan moves as-is; it is superseded by the Capacitor wrapper but not yet swept.
13 KiB
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 staticbuild/folder - Already a PWA (
@vite-pwa/sveltekit, hand-writtenmanifest.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)
How it fits loto:
- Loto already deploys to GitHub Pages →
https://tiennm99.github.io/loto/(HTTPS ✓) manifest.webmanifestalready exists ✓- Bubblewrap reads the manifest and generates the Android project; you publish to Play Store
Steps (high-level):
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.jsonsetup 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, bryanhogan.com)
How it fits loto:
- Build loto as static (
npm run build→./build/) - Point Capacitor's
webDiratloto/build cap add androidgenerates an Android Studio project;cap synccopies 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 captooling, 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 intoapp/src/main/assets/and served viaWebViewAssetLoaderat a virtualhttps://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+ServiceWorkerClientcorrectly. (w3tutorials.net) - Audio MIME types, autoplay policy,
loadWithOverviewModequirks 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:
- The loto PWA already has manifest + SW + bundled audio — the Workbox
additionalManifestEntriesprecaches 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. - Cuts maintenance to ~near zero. Loto evolves; TWA app body never needs touching.
assetlinks.jsonis the only friction. Place athttps://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 thintiennm99.github.iorepo with astatic.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
# 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
# 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)
// 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=ghbeforecap sync, all asset URLs become/loto/...and break underhttps://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)
- TWA Quick Start — Android Developers
- Adding Your PWA to Google Play (codelab)
- Capacitor + Svelte
- SvelteKit Service Workers
- vite-pwa/sveltekit (already in use by loto)
Walkthroughs
- From Web to Native: SvelteKit & Capacitor — Bryan Hogan
- Cross-Platform SvelteKit & Capacitor — Ionic Blog
- PWA + Bubblewrap — Thinktecture
- Submitting a PWA via Bubblewrap — Vaadin
- Service Workers in Android WebView
Unresolved Questions
- Is the current native
loto-androidalready 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). - 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)?
- 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.
- Custom domain plan?
assetlinks.jsonontiennm99.github.iorequires writing to the user-page repo — using a custom domain (e.g.,loto.miti99.com) sidesteps this cleanly.