mirror of
https://github.com/tiennm99/loto-android.git
synced 2026-06-09 02:14:48 +00:00
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:
@@ -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
|
||||
@@ -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
@@ -0,0 +1,15 @@
|
||||
*.iml
|
||||
.gradle
|
||||
.idea
|
||||
local.properties
|
||||
.DS_Store
|
||||
build
|
||||
captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
*.apk
|
||||
*.aab
|
||||
*.keystore
|
||||
*.jks
|
||||
google-services.json
|
||||
proguard/
|
||||
@@ -0,0 +1,122 @@
|
||||
# Lô tô — Android
|
||||
|
||||

|
||||
|
||||
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).
|
||||
@@ -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()
|
||||
}
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
# Lô tô 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)
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user