feat: initial Android port of tiennm99/loto

Native Android port of the SvelteKit Lô tô hội chợ Tân Tân web app.

Stack:
- Kotlin 2.1 + Jetpack Compose + Material 3
- Single Activity, single Gradle module (:app)
- Audio: AndroidX Media3 ExoPlayer with bundled MP3 voice clips
- Settings: DataStore Preferences with legacy masterMode migration
- minSdk 24, targetSdk 35, JDK 17

Game logic:
- 9x9 player card with exactly 5 numbers per row AND per column
- Soft constraint: no 3 consecutive filled columns (rejection-sampled)
- 11x9 ones-digit-aligned master tracking board
- Bingo / Cho ("waiting") state machine ported from PlayerBoard.svelte
- Forward-only auto-tick from MasterPanel via app-scoped CallBus

Audio:
- 184 MP3s pre-generated with Microsoft Edge TTS (Hoai My, Nam Minh)
- ExoPlayer playlist for cho + N gapless sequence
- Token-based cancellation matching the web voice.js semantics

CI:
- build-debug.yml: lint + test + assembleDebug on push/PR
- release.yml: signed AAB+APK on v* tag, env-driven keystore
This commit is contained in:
2026-04-27 19:33:16 +07:00
commit fe52232a53
255 changed files with 4790 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
name: build-debug
on:
pull_request:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v3
- name: Lint + unit tests + assemble debug
run: ./gradlew lint test :app:assembleDebug
- name: Upload debug APK
uses: actions/upload-artifact@v4
with:
name: debug-apk
path: app/build/outputs/apk/debug/*.apk
retention-days: 7
+39
View File
@@ -0,0 +1,39 @@
name: release
on:
push:
tags: ['v*.*.*']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v3
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > keystore.jks
- name: Build release bundle and APK
run: ./gradlew :app:bundleRelease :app:assembleRelease
env:
LOTO_KEYSTORE_PATH: ${{ github.workspace }}/keystore.jks
LOTO_KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
LOTO_KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
LOTO_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
app/build/outputs/apk/release/*.apk
app/build/outputs/bundle/release/*.aab
+15
View File
@@ -0,0 +1,15 @@
*.iml
.gradle
.idea
local.properties
.DS_Store
build
captures
.externalNativeBuild
.cxx
*.apk
*.aab
*.keystore
*.jks
google-services.json
proguard/
+122
View File
@@ -0,0 +1,122 @@
# Lô tô — Android
![build-debug](https://github.com/tiennm99/loto-android/actions/workflows/build-debug.yml/badge.svg)
Native Android port of [tiennm99/loto](https://github.com/tiennm99/loto) (SvelteKit web app).
Lô tô hội chợ TN1 — Vietnamese fairground bingo with host (quản trò) + player modes,
called-number speech in Vietnamese (Hoài My / Nam Minh voices).
## Stack
- Kotlin 2.1 + Jetpack Compose + Material 3
- Single Activity, single Gradle module (`:app`)
- Audio: AndroidX Media3 ExoPlayer (bundled MP3s in `assets/audio/`)
- Settings persistence: DataStore Preferences
- minSdk 24 · targetSdk 35 · JDK 17
## Build
### Debug APK (no signing required)
```bash
./gradlew :app:assembleDebug
# Output: app/build/outputs/apk/debug/app-debug.apk
```
### Install on connected device/emulator
```bash
./gradlew :app:installDebug
```
### Open in Android Studio
Open the repo root in Android Studio Ladybug (2024.2) or newer. The IDE picks
up the Gradle wrapper automatically. Run the `app` configuration on any API 24+
device or emulator.
## Tests
### Unit tests (JVM, no device needed)
```bash
./gradlew :app:test
```
Covers: `GameLogicTest`, `VietnameseNumberTest`, `VoiceManifestTest`,
`SettingsRepositoryTest`, `CallBusTest`, `PlayerBoardViewModelTest`,
`MasterPanelViewModelTest`, `MasterBoardLayoutTest`.
### Instrumentation tests (requires connected device or emulator)
```bash
./gradlew :app:connectedDebugAndroidTest
```
Includes `MainActivityComposeSmokeTest` — cold-start → generate grid → assert 81 cells.
## Release signing
Release builds read signing credentials from environment variables so the
keystore is never committed to the repository.
```bash
export LOTO_KEYSTORE_PATH=/path/to/loto-release.jks
export LOTO_KEYSTORE_PASSWORD=<store-password>
export LOTO_KEY_ALIAS=<key-alias>
export LOTO_KEY_PASSWORD=<key-password>
./gradlew :app:assembleRelease # signed APK
./gradlew :app:bundleRelease # signed AAB for Play Store
```
Local convention: store the keystore at `~/.android/loto-release.jks`.
### GitHub Secrets (for CI)
Set the following secrets in the repository settings under **Settings → Secrets → Actions**:
| Secret | Description |
|--------|-------------|
| `KEYSTORE_BASE64` | `base64 -w0 loto-release.jks` output |
| `KEYSTORE_PASSWORD` | Keystore password |
| `KEY_ALIAS` | Key alias inside the keystore |
| `KEY_PASSWORD` | Key password |
**Never commit `keystore.jks`, `.env`, or any file containing these values.**
## CI / CD
| Workflow | Trigger | Tasks |
|----------|---------|-------|
| `build-debug` | push to `main`, any PR | `lint test assembleDebug` |
| `release` | tag `v*.*.*` | `bundleRelease assembleRelease` + sign + upload to GH Release |
Tag a release:
```bash
git tag v1.0.0
git push origin v1.0.0
```
The `release` workflow will sign and attach the APK + AAB to the GitHub Release page.
## Version bump
1. Increment `versionCode` and `versionName` in `app/build.gradle.kts`.
2. Commit, tag, push tag.
## App ID
`com.miti99.loto` — registered in `AndroidManifest.xml` and `app/build.gradle.kts`.
## Audio
Bundled MP3 voice clips under `assets/audio/{hoai-my,nam-minh}/{1..90,cho,kinh}.mp3`.
Generated from the upstream repo's `scripts/generate-audio.py` using Microsoft Edge TTS.
Re-generate by running that script against a Python 3.10+ environment with `edge-tts` installed.
## License
Same license as upstream [`tiennm99/loto`](https://github.com/tiennm99/loto).
+112
View File
@@ -0,0 +1,112 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.android.junit5)
}
android {
namespace = "com.miti99.loto"
compileSdk = 35
defaultConfig {
applicationId = "com.miti99.loto"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
val keystorePath = System.getenv("LOTO_KEYSTORE_PATH")
if (keystorePath != null) {
storeFile = file(keystorePath)
storePassword = System.getenv("LOTO_KEYSTORE_PASSWORD")
keyAlias = System.getenv("LOTO_KEY_ALIAS")
keyPassword = System.getenv("LOTO_KEY_PASSWORD")
}
}
}
buildTypes {
debug {
isMinifyEnabled = false
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Only sign when CI env vars are set; local release builds omit signing
val keystorePath = System.getenv("LOTO_KEYSTORE_PATH")
signingConfig = if (keystorePath != null) signingConfigs.getByName("release") else null
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = false
}
androidResources {
// MP3 voice clips ship pre-compressed; never deflate them.
noCompress += "mp3"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose.ui)
// Audio
implementation(libs.media3.exoplayer)
implementation(libs.media3.common)
// Persistence + Serialization
implementation(libs.datastore.preferences)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
// Java 17 desugaring (java.time, etc. on minSdk 24)
coreLibraryDesugaring(libs.desugar.jdk.libs)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
// JUnit 5 (JVM unit tests)
testImplementation(libs.junit.jupiter)
testImplementation(libs.junit.jupiter.params)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine)
// Instrumentation tests (added in phase-04 / phase-11)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.compose.ui.test.junit4)
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
+24
View File
@@ -0,0 +1,24 @@
# Android ProGuard / R8 rules
# Default mode (not fullMode) keeps reflection-heavy Kotlin/AndroidX intact.
# kotlinx.serialization keep companion @Serializer references R8 can't infer
-keep,includedescriptorclasses class com.miti99.loto.**$$serializer { *; }
-keepclassmembers class com.miti99.loto.** {
*** Companion;
}
-keepclasseswithmembers class com.miti99.loto.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep Kotlin metadata for runtime reflection
-keep class kotlin.Metadata { *; }
# Media3 / ExoPlayer internals prevent stripping of codec/extractor classes
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**
# DataStore
-keep class androidx.datastore.** { *; }
# Compose tooling only exists in debug; suppress release warnings
-dontwarn androidx.compose.ui.tooling.**
@@ -0,0 +1,46 @@
package com.miti99.loto
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Compose UI smoke test — verifies the full cold-start → generate-grid flow.
*
* Requires a connected device or emulator. Run via:
* ./gradlew :app:connectedDebugAndroidTest
*
* Phase 11 spec: launch MainActivity, assert "Tạo bảng mới" shown,
* click it, assert all 81 player_cell nodes are rendered.
*/
@RunWith(AndroidJUnit4::class)
class MainActivityComposeSmokeTest {
@get:Rule
val rule = createAndroidComposeRule<MainActivity>()
@Test
fun generate_button_is_visible_on_cold_start() {
rule.onNodeWithText("Tạo bảng mới").assertExists()
}
@Test
fun generate_button_creates_81_player_cells() {
// Tap the generate button — no confirmation dialog on first tap (grid is null)
rule.onNodeWithText("Tạo bảng mới").performClick()
// Wait up to 5 s for all 81 cells to appear (grid generation is synchronous
// but Compose may take a frame to recompose)
rule.waitUntil(timeoutMillis = 5_000) {
rule.onAllNodesWithTag("player_cell").fetchSemanticsNodes().size == 81
}
rule.onAllNodesWithTag("player_cell").assertCountEquals(81)
}
}
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:name=".LotoApp"
android:allowBackup="false"
android:dataExtractionRules="@xml/backup_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="false"
android:theme="@style/Theme.Loto">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden|uiMode"
android:screenOrientation="portrait"
android:theme="@style/Theme.Loto">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More