Lô tô — Android (Capacitor wrapper)
Fully-offline Android wrapper around the web/ SvelteKit PWA. All
assets — HTML, JS, CSS, and 184 voice MP3s — are bundled into the APK at build
time. No network is required at runtime.
Note: the previous native Kotlin/Compose port lives in git history at the commit titled
docs: add post-implementation todo listand earlier. This is now a thin wrapper overweb/; the web app evolves and we rebuild + ship.
How it works
../web/
└── pnpm run build → ../web/build/ (SvelteKit static output)
↓
npx cap sync
↓
android/app/src/main/assets/public/ (bundled into APK)
↓
WebView serves https://localhost/* off-disk
Capacitor's bridge serves the bundled site from https://localhost, which is
loopback only (no INTERNET permission requested). Workbox precache, IndexedDB,
and the <audio> element all work offline.
Stack
- Capacitor 8 (Android wrapper)
- Web app in
web/: SvelteKit 2 + Vite 8 +@sveltejs/adapter-static+@vite-pwa/sveltekit - minSdk 24 · targetSdk 36 · JDK 21 · Node 22 (Capacitor 8 requires both)
Setup
git clone https://github.com/tiennm99/loto.git
cd loto/android
npm ci
npm run build # builds web/ + cap sync into android/
Build
Debug APK
npm run build # build web/ + cap sync (must run after any web/ change)
npm run assemble:debug # → android/app/build/outputs/apk/debug/app-debug.apk
Release AAB + APK (signed)
export LOTO_KEYSTORE_PATH=$HOME/.android/miti99-apps.p12
export LOTO_KEYSTORE_PASSWORD=<store-password>
export LOTO_KEY_ALIAS=<key-alias>
export LOTO_KEY_PASSWORD=<key-password>
npm run build
npm run assemble:release
# → android/app/build/outputs/{apk/release/*.apk, bundle/release/*.aab}
Open in Android Studio
npx cap open android
Picking up web/ changes
web/ and android/ live in the same repository, so there is no pin to bump —
rebuild and re-sync after any change under web/:
npm run build # rebuild web/ + re-sync into android/
Why no INTERNET permission?
The whole web build (HTML, JS, CSS, fonts, manifest, icons, all 184 MP3s)
ships inside the APK. The WebView loads from https://localhost, which is
loopback. No remote fetches happen at runtime, so the permission is omitted —
this makes "fully offline" a hard guarantee, not a convention.
If you ever add a remote feature (analytics, sync, etc.), add this back to
android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
Running on BlueStacks / NoxPlayer / Android emulators
The APK has no native libraries (lib/ is empty), so it's architecture-
independent — same APK installs on x86_64 emulators and ARM phones.
- Download the APK from the Actions artifact (debug) or Releases (signed).
- Drag-drop the APK onto the BlueStacks window, or use Install APK from the sidebar.
- Launch "Lo To" from the BlueStacks home screen.
If the app shows a blank white screen on first launch, open chrome://inspect on the host machine while BlueStacks is running, click Inspect on the WebView, and check the console — the WebView debugging is enabled in debug builds (Capacitor default behavior, no INTERNET permission needed because chrome://inspect uses ADB).
Manifest declares touchscreen, faketouch, screen.portrait, and
screen.landscape as optional so the Play Store and emulators don't
filter the app out.
CI / CD
Workflows live at the repository root in .github/workflows/.
| Workflow | Trigger | Result |
|---|---|---|
android-build-debug |
push to main, any PR touching web/ or android/ |
unsigned debug APK uploaded as artifact |
android-release |
tag v*.*.* |
signed AAB + APK attached to GH Release |
Both install npm deps in android/, build web/, cap sync, then run Gradle
inside android/android/.
GitHub Secrets (release only)
| Secret | Required for | Description |
|---|---|---|
KEYSTORE_BASE64 |
signed build | base64 -w0 miti99-apps.p12 |
KEYSTORE_PASSWORD |
signed build | Keystore password |
KEY_ALIAS |
signed build | Key alias |
KEY_PASSWORD |
signed build | Key password |
PLAY_SERVICE_ACCOUNT_JSON |
Play Store auto-publish (optional) | Full JSON content of Google Cloud service account key |
Never commit *.jks, *.keystore, *.p12, service-account JSON, or .env.
Google Play Store
One-time manual setup (cannot be automated)
- Sign up at play.google.com/console ($25 one-time)
- Create the app entry with package name
com.miti99.loto - Build a signed AAB (
npm run assemble:releaselocally, or push av*.*.*tag to useandroid-release.yml) and upload manually to the Internal Testing track via the Play Console UI — Google requires the first upload to be manual - Fill out store listing: icon (512×512), feature graphic (1024×500), 2–8 screenshots, short + full description, category, content rating, target audience, privacy policy URL (host on GH Pages), data safety form (declare "No data collected" since the app is offline)
- Submit for review (1–7 days first time)
Auto-publish setup (after first manual upload)
- Create/select a Google Cloud project and enable the Google Play Android Developer API
- In Google Cloud Console, create a Service Account without granting broad Cloud project roles
- Keys → Add Key → JSON — download the JSON file
- In Play Console → Users and permissions, invite the service-account email and grant app-scoped Release apps to testing tracks + View app information permissions for Lo To
- Copy the entire JSON contents into a GitHub repo secret named
PLAY_SERVICE_ACCOUNT_JSON - Tag a release (
git tag v1.0.1 && git push origin v1.0.1) —android-release.ymlwill:- Build signed AAB + APK
- Upload to GitHub Release
- If the secret is set: upload AAB to Play Console Internal track
- Promote internal → closed → open → production via the Play Console UI (or change
tracks: internalinandroid-release.ymlto automate further)
Important: every release must increment versionCode in android/app/build.gradle before tagging — Play Console rejects duplicate versionCodes.
Tag a release:
git tag v1.0.0
git push origin v1.0.0
Version bump
- Edit
versionCodeandversionNameinandroid/app/build.gradle. - Commit, tag, push.
App ID
com.miti99.loto — set in capacitor.config.json and android/app/build.gradle.
Audio
Bundled by the web app under web/static/audio/{hoai-my,nam-minh}/{1..90,cho,kinh}.mp3,
served by the wrapper from https://localhost/audio/.... No audio post-processing
on the Android side.
License
Apache-2.0 — see LICENSE at the repository root.