From e314ae01c80f4fff9725a3609368f97b9dd5e37e Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 31 Aug 2026 13:28:48 +0700 Subject: [PATCH] feat(android)!: replace the Capacitor wrapper with a native Kotlin/Compose app Fresh Kotlin 2.2 + Jetpack Compose app at feature parity with web/ (game logic, settings contract, voice semantics ported one-to-one with twinned tests; 104 unit + 9 instrumentation tests). Fully offline: VIBRATE is the only permission; Media3's ACCESS_NETWORK_STATE is stripped. Voice clips mount from web/static/audio at build time. Same applicationId, signing env contract, and Play alpha track; versionCode 7 / 0.2.0. CI Android job is Gradle-only (lint, test, debug + R8 release APK) and the release workflow fails fast on an unbumped versionCode. BREAKING CHANGE: WebView localStorage (settings, in-progress round) is not migrated; wrapper users start clean. --- .github/actions/setup-android/action.yml | 17 +- .github/workflows/android-release.yml | 58 +- .github/workflows/ci.yml | 22 +- README.md | 37 +- android/.gitignore | 36 +- android/README.md | 294 +---- android/android/.gitignore | 101 -- android/android/app/.gitignore | 2 - android/android/app/build.gradle | 72 - android/android/app/capacitor.build.gradle | 19 - android/android/app/proguard-rules.pro | 21 - .../myapp/ExampleInstrumentedTest.java | 26 - .../android/app/src/main/AndroidManifest.xml | 55 - .../java/com/miti99/loto/MainActivity.java | 74 -- .../app/src/main/res/layout/activity_main.xml | 12 - .../app/src/main/res/values/strings.xml | 13 - .../app/src/main/res/values/styles.xml | 37 - .../app/src/main/res/xml/file_paths.xml | 5 - .../getcapacitor/myapp/ExampleUnitTest.java | 18 - android/android/build.gradle | 29 - android/android/capacitor.settings.gradle | 3 - android/android/gradle.properties | 22 - android/android/settings.gradle | 5 - android/android/variables.gradle | 16 - android/app/build.gradle.kts | 105 ++ android/app/proguard-rules.pro | 3 + .../com/miti99/loto/MasterPanelScreenTest.kt | 80 ++ .../com/miti99/loto/PlayerBoardScreenTest.kt | 94 ++ .../java/com/miti99/loto/SettingsSheetTest.kt | 64 + .../java/com/miti99/loto/SmokeTest.kt | 20 + android/app/src/main/AndroidManifest.xml | 56 + .../java/com/miti99/loto/LotoApplication.kt | 84 ++ .../main/java/com/miti99/loto/MainActivity.kt | 105 ++ .../com/miti99/loto/audio/VoiceCatalog.kt | 83 ++ .../java/com/miti99/loto/audio/VoicePlayer.kt | 101 ++ .../com/miti99/loto/game/CardGenerator.kt | 156 +++ .../java/com/miti99/loto/game/DrawDeck.kt | 60 + .../com/miti99/loto/game/PlayerAutoCross.kt | 60 + .../java/com/miti99/loto/game/PlayerCard.kt | 62 + .../java/com/miti99/loto/settings/Settings.kt | 70 + .../loto/settings/SettingsRepository.kt | 114 ++ .../miti99/loto/state/GameStateRepository.kt | 114 ++ .../miti99/loto/state/LotoViewModelFactory.kt | 39 + .../miti99/loto/state/MasterPanelViewModel.kt | 115 ++ .../java/com/miti99/loto/state/MasterStore.kt | 52 + .../miti99/loto/state/PlayerBoardViewModel.kt | 354 +++++ .../miti99/loto/state/SettingsViewModel.kt | 40 + .../java/com/miti99/loto/ui/ColorParsing.kt | 13 + .../java/com/miti99/loto/ui/KeepScreenOn.kt | 26 + .../java/com/miti99/loto/ui/LotoAppRoot.kt | 175 +++ .../java/com/miti99/loto/ui/ReducedMotion.kt | 24 + .../miti99/loto/ui/master/AutoCountdown.kt | 106 ++ .../loto/ui/master/MasterBoardLayout.kt | 23 + .../miti99/loto/ui/master/MasterEmptyState.kt | 120 ++ .../loto/ui/master/MasterPanelScreen.kt | 327 +++++ .../com/miti99/loto/ui/player/Confetti.kt | 68 + .../com/miti99/loto/ui/player/KinhDialog.kt | 95 ++ .../loto/ui/player/PlayerBoardScreen.kt | 267 ++++ .../miti99/loto/ui/player/PlayerCardGrid.kt | 173 +++ .../com/miti99/loto/ui/player/PlayerCell.kt | 141 ++ .../loto/ui/settings/EmptyCellColorPicker.kt | 187 +++ .../loto/ui/settings/SettingsControls.kt | 103 ++ .../miti99/loto/ui/settings/SettingsSheet.kt | 310 +++++ .../java/com/miti99/loto/ui/theme/Theme.kt | 211 +++ .../src/main/res/drawable-night/splash.xml | 0 .../res/drawable/ic_launcher_background.xml | 0 .../app/src/main/res/drawable/splash.xml | 0 .../res/mipmap-anydpi-v26/ic_launcher.xml | 0 .../mipmap-anydpi-v26/ic_launcher_round.xml | 0 .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin .../mipmap-hdpi/ic_launcher_foreground.png | Bin .../mipmap-hdpi/ic_launcher_monochrome.png | Bin .../res/mipmap-hdpi/ic_launcher_round.png | Bin .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin .../mipmap-mdpi/ic_launcher_foreground.png | Bin .../mipmap-mdpi/ic_launcher_monochrome.png | Bin .../res/mipmap-mdpi/ic_launcher_round.png | Bin .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin .../mipmap-xhdpi/ic_launcher_foreground.png | Bin .../mipmap-xhdpi/ic_launcher_monochrome.png | Bin .../res/mipmap-xhdpi/ic_launcher_round.png | Bin .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin .../mipmap-xxhdpi/ic_launcher_monochrome.png | Bin .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin .../mipmap-xxxhdpi/ic_launcher_monochrome.png | Bin .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin .../app/src/main/res/values-night/colors.xml | 0 .../app/src/main/res/values-night/themes.xml | 8 + .../app/src/main/res/values/colors.xml | 0 android/app/src/main/res/values/strings.xml | 104 ++ android/app/src/main/res/values/themes.xml | 19 + .../java/com/miti99/loto/InMemoryDataStore.kt | 34 + .../com/miti99/loto/audio/FakeVoicePlayer.kt | 32 + .../com/miti99/loto/audio/VoiceCatalogTest.kt | 62 + .../com/miti99/loto/audio/VoicePlayerTest.kt | 59 + .../com/miti99/loto/game/CardGeneratorTest.kt | 145 ++ .../java/com/miti99/loto/game/DrawDeckTest.kt | 83 ++ .../miti99/loto/game/PlayerAutoCrossTest.kt | 166 +++ .../com/miti99/loto/game/PlayerCardTest.kt | 116 ++ .../loto/settings/SettingsRepositoryTest.kt | 189 +++ .../loto/state/GameStateRepositoryTest.kt | 80 ++ .../loto/state/MasterPanelViewModelTest.kt | 184 +++ .../loto/state/PlayerBoardViewModelTest.kt | 306 +++++ .../com/miti99/loto/ui/KeepScreenOnTest.kt | 26 + .../loto/ui/master/MasterBoardLayoutTest.kt | 41 + android/assets/google-play/app-icon.png | Bin 72517 -> 0 bytes android/assets/google-play/app-icon.svg | 46 - .../assets/google-play/feature-graphic.png | Bin 129124 -> 0 bytes .../assets/google-play/feature-graphic.svg | 71 - android/build.gradle.kts | 5 + android/capacitor.config.json | 8 - android/gradle.properties | 9 + android/gradle/libs.versions.toml | 45 + .../gradle/wrapper/gradle-wrapper.jar | Bin .../gradle/wrapper/gradle-wrapper.properties | 0 android/{android => }/gradlew | 0 android/{android => }/gradlew.bat | 188 +-- android/package-lock.json | 1165 ----------------- android/package.json | 21 - android/settings.gradle.kts | 24 + docs/play-store-publishing.md | 22 +- web/README.md | 5 +- web/docs/deployment-guide.md | 8 +- 126 files changed, 6390 insertions(+), 2275 deletions(-) delete mode 100644 android/android/.gitignore delete mode 100644 android/android/app/.gitignore delete mode 100644 android/android/app/build.gradle delete mode 100644 android/android/app/capacitor.build.gradle delete mode 100644 android/android/app/proguard-rules.pro delete mode 100644 android/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java delete mode 100644 android/android/app/src/main/AndroidManifest.xml delete mode 100644 android/android/app/src/main/java/com/miti99/loto/MainActivity.java delete mode 100644 android/android/app/src/main/res/layout/activity_main.xml delete mode 100644 android/android/app/src/main/res/values/strings.xml delete mode 100644 android/android/app/src/main/res/values/styles.xml delete mode 100644 android/android/app/src/main/res/xml/file_paths.xml delete mode 100644 android/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java delete mode 100644 android/android/build.gradle delete mode 100644 android/android/capacitor.settings.gradle delete mode 100644 android/android/gradle.properties delete mode 100644 android/android/settings.gradle delete mode 100644 android/android/variables.gradle create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/androidTest/java/com/miti99/loto/MasterPanelScreenTest.kt create mode 100644 android/app/src/androidTest/java/com/miti99/loto/PlayerBoardScreenTest.kt create mode 100644 android/app/src/androidTest/java/com/miti99/loto/SettingsSheetTest.kt create mode 100644 android/app/src/androidTest/java/com/miti99/loto/SmokeTest.kt create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/miti99/loto/LotoApplication.kt create mode 100644 android/app/src/main/java/com/miti99/loto/MainActivity.kt create mode 100644 android/app/src/main/java/com/miti99/loto/audio/VoiceCatalog.kt create mode 100644 android/app/src/main/java/com/miti99/loto/audio/VoicePlayer.kt create mode 100644 android/app/src/main/java/com/miti99/loto/game/CardGenerator.kt create mode 100644 android/app/src/main/java/com/miti99/loto/game/DrawDeck.kt create mode 100644 android/app/src/main/java/com/miti99/loto/game/PlayerAutoCross.kt create mode 100644 android/app/src/main/java/com/miti99/loto/game/PlayerCard.kt create mode 100644 android/app/src/main/java/com/miti99/loto/settings/Settings.kt create mode 100644 android/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt create mode 100644 android/app/src/main/java/com/miti99/loto/state/GameStateRepository.kt create mode 100644 android/app/src/main/java/com/miti99/loto/state/LotoViewModelFactory.kt create mode 100644 android/app/src/main/java/com/miti99/loto/state/MasterPanelViewModel.kt create mode 100644 android/app/src/main/java/com/miti99/loto/state/MasterStore.kt create mode 100644 android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt create mode 100644 android/app/src/main/java/com/miti99/loto/state/SettingsViewModel.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/ColorParsing.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/KeepScreenOn.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/LotoAppRoot.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/ReducedMotion.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/master/AutoCountdown.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/master/MasterBoardLayout.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/master/MasterEmptyState.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/master/MasterPanelScreen.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/player/Confetti.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/player/KinhDialog.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/player/PlayerBoardScreen.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/player/PlayerCardGrid.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/player/PlayerCell.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/settings/EmptyCellColorPicker.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/settings/SettingsControls.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/settings/SettingsSheet.kt create mode 100644 android/app/src/main/java/com/miti99/loto/ui/theme/Theme.kt rename android/{android => }/app/src/main/res/drawable-night/splash.xml (100%) rename android/{android => }/app/src/main/res/drawable/ic_launcher_background.xml (100%) rename android/{android => }/app/src/main/res/drawable/splash.xml (100%) rename android/{android => }/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml (100%) rename android/{android => }/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml (100%) rename android/{android => }/app/src/main/res/mipmap-hdpi/ic_launcher.png (100%) rename android/{android => }/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png (100%) rename android/{android => }/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png (100%) rename android/{android => }/app/src/main/res/mipmap-hdpi/ic_launcher_round.png (100%) rename android/{android => }/app/src/main/res/mipmap-mdpi/ic_launcher.png (100%) rename android/{android => }/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png (100%) rename android/{android => }/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png (100%) rename android/{android => }/app/src/main/res/mipmap-mdpi/ic_launcher_round.png (100%) rename android/{android => }/app/src/main/res/mipmap-xhdpi/ic_launcher.png (100%) rename android/{android => }/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png (100%) rename android/{android => }/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png (100%) rename android/{android => }/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png (100%) rename android/{android => }/app/src/main/res/mipmap-xxhdpi/ic_launcher.png (100%) rename android/{android => }/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png (100%) rename android/{android => }/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png (100%) rename android/{android => }/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png (100%) rename android/{android => }/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png (100%) rename android/{android => }/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png (100%) rename android/{android => }/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png (100%) rename android/{android => }/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png (100%) rename android/{android => }/app/src/main/res/values-night/colors.xml (100%) create mode 100644 android/app/src/main/res/values-night/themes.xml rename android/{android => }/app/src/main/res/values/colors.xml (100%) create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/test/java/com/miti99/loto/InMemoryDataStore.kt create mode 100644 android/app/src/test/java/com/miti99/loto/audio/FakeVoicePlayer.kt create mode 100644 android/app/src/test/java/com/miti99/loto/audio/VoiceCatalogTest.kt create mode 100644 android/app/src/test/java/com/miti99/loto/audio/VoicePlayerTest.kt create mode 100644 android/app/src/test/java/com/miti99/loto/game/CardGeneratorTest.kt create mode 100644 android/app/src/test/java/com/miti99/loto/game/DrawDeckTest.kt create mode 100644 android/app/src/test/java/com/miti99/loto/game/PlayerAutoCrossTest.kt create mode 100644 android/app/src/test/java/com/miti99/loto/game/PlayerCardTest.kt create mode 100644 android/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.kt create mode 100644 android/app/src/test/java/com/miti99/loto/state/GameStateRepositoryTest.kt create mode 100644 android/app/src/test/java/com/miti99/loto/state/MasterPanelViewModelTest.kt create mode 100644 android/app/src/test/java/com/miti99/loto/state/PlayerBoardViewModelTest.kt create mode 100644 android/app/src/test/java/com/miti99/loto/ui/KeepScreenOnTest.kt create mode 100644 android/app/src/test/java/com/miti99/loto/ui/master/MasterBoardLayoutTest.kt delete mode 100644 android/assets/google-play/app-icon.png delete mode 100644 android/assets/google-play/app-icon.svg delete mode 100644 android/assets/google-play/feature-graphic.png delete mode 100644 android/assets/google-play/feature-graphic.svg create mode 100644 android/build.gradle.kts delete mode 100644 android/capacitor.config.json create mode 100644 android/gradle.properties create mode 100644 android/gradle/libs.versions.toml rename android/{android => }/gradle/wrapper/gradle-wrapper.jar (100%) rename android/{android => }/gradle/wrapper/gradle-wrapper.properties (100%) rename android/{android => }/gradlew (100%) mode change 100755 => 100644 rename android/{android => }/gradlew.bat (96%) delete mode 100644 android/package-lock.json delete mode 100644 android/package.json create mode 100644 android/settings.gradle.kts diff --git a/.github/actions/setup-android/action.yml b/.github/actions/setup-android/action.yml index e161800..89b63ec 100644 --- a/.github/actions/setup-android/action.yml +++ b/.github/actions/setup-android/action.yml @@ -1,19 +1,12 @@ name: Set up Android toolchain description: > - Installs Node, JDK and Gradle, then restores android/ dependencies from the - lockfile. Covers the Capacitor CLI only — the web bundle it syncs is built - separately and must already exist at web/build. + Installs the JDK and configures Gradle caching for the native Android + project in android/. The app is pure Kotlin/Compose — no Node, no web + build artifact. runs: using: composite steps: - # Keep in lockstep with .github/actions/setup-web. - - uses: actions/setup-node@v7 - with: - node-version: '24' - cache: npm - cache-dependency-path: android/package-lock.json - - uses: actions/setup-java@v5 with: distribution: temurin @@ -22,7 +15,3 @@ runs: - uses: gradle/actions/setup-gradle@v6 with: cache-provider: basic - - - run: npm ci - shell: bash - working-directory: android diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index fa9a60c..be9002f 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -23,23 +23,43 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false + # Full history + tags for the versionCode guard below. + fetch-depth: 0 - - uses: ./.github/actions/setup-web - - # ci.yml does not run on tags, so this is the only gate before signing. - - name: Test web - run: npm test - working-directory: web - - # Base "" — the APK loads the bundle from the domain root. - - name: Build web - run: npm run build - working-directory: web + # Play rejects an upload whose versionCode is not higher than the last + # one, but only after the whole build has run — fail fast instead. + # Compares this tag's versionCode against the previous v*.*.* tag, + # reading the wrapper-era path for tags that predate the native app. + - name: Guard against an unbumped versionCode + run: | + set -eu + current=$(grep -oP 'versionCode = \K\d+' android/app/build.gradle.kts) + echo "versionCode at ${GITHUB_REF_NAME}: $current" + prev_tag=$(git tag --list 'v*.*.*' --sort=-v:refname | grep -Fvx "${GITHUB_REF_NAME}" | head -n1 || true) + if [ -z "$prev_tag" ]; then + echo "No previous release tag; skipping comparison." + exit 0 + fi + prev=$(git show "$prev_tag:android/app/build.gradle.kts" 2>/dev/null | grep -oP 'versionCode = \K\d+' || true) + if [ -z "$prev" ]; then + # Wrapper-era layout (Capacitor project under android/android/). + prev=$(git show "$prev_tag:android/android/app/build.gradle" 2>/dev/null | grep -oP 'versionCode \K\d+' || true) + fi + if [ -z "$prev" ]; then + echo "::warning::Could not read versionCode at $prev_tag; skipping comparison." + exit 0 + fi + echo "versionCode at $prev_tag: $prev" + if [ "$current" -le "$prev" ]; then + echo "::error::versionCode $current at ${GITHUB_REF_NAME} must be greater than $prev at $prev_tag. Bump it in android/app/build.gradle.kts before tagging." + exit 1 + fi - uses: ./.github/actions/setup-android - - name: Sync web bundle into the native project - run: npm run sync + # ci.yml does not run on tags, so this is the only gate before signing. + - name: Lint and test + run: ./gradlew :app:lint :app:test working-directory: android - name: Decode keystore @@ -48,7 +68,7 @@ jobs: run: printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$GITHUB_WORKSPACE/keystore.p12" - name: Build signed AAB + APK - working-directory: android/android + working-directory: android env: LOTO_KEYSTORE_PATH: ${{ github.workspace }}/keystore.p12 LOTO_KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} @@ -60,8 +80,8 @@ jobs: uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: files: | - android/android/app/build/outputs/apk/release/*.apk - android/android/app/build/outputs/bundle/release/*.aab + android/app/build/outputs/apk/release/*.apk + android/app/build/outputs/bundle/release/*.aab # Auto-publish to Google Play (gated on the service-account secret). # Skips silently if PLAY_SERVICE_ACCOUNT_JSON is not configured, so @@ -89,8 +109,8 @@ jobs: with: serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} packageName: com.miti99.loto - releaseFiles: android/android/app/build/outputs/bundle/release/*.aab + releaseFiles: android/app/build/outputs/bundle/release/*.aab tracks: alpha status: completed - # Bumps versionCode automatically? No — must be incremented - # manually in android/android/app/build.gradle before tagging. + # versionCode is bumped manually in android/app/build.gradle.kts + # before tagging; the guard step above catches a forgotten bump. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5c9abf..48b3e7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,9 +3,11 @@ # The web app is built twice and only twice — once per base path — and every # consumer downloads an artifact instead of rebuilding: # -# build (root, base "") -> deploy-firebase, preview-firebase, android-debug +# build (root, base "") -> deploy-firebase, preview-firebase # build (gh, base /loto) -> deploy-pages # +# The Android app is native Kotlin/Compose and builds independently of the +# web bundle (it mounts web/static/audio straight from the checkout). # Tags are handled by android-release.yml, which builds standalone. name: ci @@ -154,28 +156,20 @@ jobs: entryPoint: web android-debug: - needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: actions/download-artifact@v7 - with: - name: web-build - # capacitor.config.json declares `webDir: "../web/build"`. - path: web/build - uses: ./.github/actions/setup-android - # `npm run build` would rebuild web/; the bundle is already here. - - name: Sync web bundle into the native project - run: npm run sync + # assembleRelease runs R8 with no secrets needed (unsigned without + # LOTO_KEYSTORE_PATH) so a missing keep rule fails CI, not the tag. + - name: Lint, test and assemble debug + release APKs + run: ./gradlew :app:lint :app:test :app:assembleDebug :app:assembleRelease working-directory: android - - name: Assemble debug APK - run: ./gradlew :app:assembleDebug - working-directory: android/android - uses: actions/upload-artifact@v7 with: name: app-debug.apk - path: android/android/app/build/outputs/apk/debug/*.apk + path: android/app/build/outputs/apk/debug/*.apk archive: false retention-days: 7 diff --git a/README.md b/README.md index 1e137ee..e1f0c81 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Lô Tô *Lô tô hội chợ* — Vietnamese fairground bingo. One number caller, two shipping -targets: a static web app and a fully-offline Android APK that wraps it. +targets: a static web app and a fully-offline native Android app. Live: [tiennm99.github.io/loto](https://tiennm99.github.io/loto) @@ -10,11 +10,12 @@ Live: [tiennm99.github.io/loto](https://tiennm99.github.io/loto) | Path | What it is | |------|------------| | [`web/`](web) | SvelteKit static app — the game itself. npm, Vitest, deployed to GitHub Pages and Firebase Hosting. | -| [`android/`](android) | Capacitor wrapper that bundles `web/build` into an APK. npm + Gradle; the native project sits in `android/android/`. | +| [`android/`](android) | Native Kotlin + Jetpack Compose app at feature parity with `web/`. Pure Gradle — no Node. | -`android/` builds `web/` as part of its own build, so a change under `web/` -reaches the APK on the next `npm run build` in `android/`. There is no version -pin between them — they ship from the same commit. +The two apps share the voice clips: the Android build mounts +`web/static/audio/` straight into the APK's assets, so the MP3s exist once in +the repo. Game logic is hand-ported — a behavior change in `web/src/lib/` +needs a matching Kotlin change (the web app is the behavioral spec). ## Quick start @@ -22,8 +23,8 @@ pin between them — they ship from the same commit. # web cd web && npm install && npm run dev -# android debug APK (builds web/ first) -cd android && npm ci && npm run build && npm run assemble:debug +# android debug APK (requires JDK 21 + Android SDK) +cd android && ./gradlew :app:assembleDebug ``` Per-project detail lives in [`web/README.md`](web/README.md) and @@ -38,26 +39,27 @@ only the tag-driven release stands apart. | Workflow | Trigger | Result | |----------|---------|--------| | `ci` | push to `main` or PR touching `web/` or `android/` | see the job graph below | -| `android-release` | tag `v*.*.*` | tests, then a signed AAB + APK on the GH Release, plus Play Store internal-track upload | +| `android-release` | tag `v*.*.*` | versionCode guard, lint + tests, then a signed AAB + APK on the GH Release, plus Play Store closed-testing upload | -`ci` builds the web app exactly twice — once per base path — and every -consumer downloads the artifact rather than rebuilding it: +`ci` builds the web app exactly twice — once per base path — and the Android +job builds independently from the same checkout: ``` test (npm test) └── build (root, base "") ── deploy-firebase push to main - │ ├─ preview-firebase PR from this repo - │ └─ android-debug unsigned APK artifact + │ └─ preview-firebase PR from this repo └── build (gh, base /loto) ── deploy-pages push to main + +android-debug (gradle lint + test + assembleDebug) ── unsigned APK artifact ``` Nothing deploys unless `test` is green. Deploy jobs are skipped on pull requests, and the Firebase preview is skipped for PRs from forks, which cannot read the service-account secret. -Shared toolchain setup lives in `.github/actions/setup-web` and -`.github/actions/setup-android` — composite actions used by both workflows, -so Node stays on one version across the web build and the APK. +Shared toolchain setup lives in `.github/actions/setup-web` (Node) and +`.github/actions/setup-android` (JDK 21 + Gradle caching) — composite actions +used by both workflows. Release/secret setup for the Play Store pipeline is documented in [`docs/play-store-publishing.md`](docs/play-store-publishing.md). @@ -70,6 +72,11 @@ for commit and interleaved in commit-time order under a single root, so `git log` reads as one timeline and `git log -- web/` or `git log -- android/` each resolve on the current paths. +`android/` has lived three lives: a native Kotlin/Compose port (retired +2026-05-10), a Capacitor WebView wrapper around the web app, and — since +2026-08-31 — a fresh native Kotlin/Compose rewrite that replaced the wrapper. +All three are preserved in history. + ## License Apache-2.0 — see [`LICENSE`](LICENSE). diff --git a/android/.gitignore b/android/.gitignore index 6a0acdd..fe371bc 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -1,33 +1,9 @@ -# Node -node_modules/ -npm-debug.log* - -# Android build outputs -android/.gradle/ -android/build/ -android/app/build/ -android/capacitor-cordova-android-plugins/ -android/local.properties -android/.idea/ -android/captures/ -android/.externalNativeBuild/ -android/.cxx/ - -# Capacitor sync artifacts (regenerated by `npx cap sync`) -android/app/src/main/assets/public/ -android/app/src/main/assets/capacitor.config.json -android/app/src/main/assets/capacitor.plugins.json - -# Signing -*.apk -*.aab +.gradle/ +build/ +local.properties +.idea/ +.kotlin/ +captures/ *.keystore *.jks *.p12 -service-account*.json -google-services.json - -# Editor / OS -*.iml -.idea/ -.DS_Store diff --git a/android/README.md b/android/README.md index 9188149..82abe1e 100644 --- a/android/README.md +++ b/android/README.md @@ -1,258 +1,88 @@ -# Lô tô — Android (Capacitor wrapper) +# Lô tô — Android -![ci](https://github.com/tiennm99/loto/actions/workflows/ci.yml/badge.svg) +Native Kotlin + Jetpack Compose app for *lô tô hội chợ*, at feature parity +with the [`web/`](../web) SvelteKit app. Fully offline: the APK declares no +`INTERNET` permission and bundles everything it needs, including the +Vietnamese voice clips. -Fully-offline Android wrapper around the [`web/`](../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 list` and earlier. This -> is now a thin wrapper over `web/`; the web app evolves and we rebuild + ship. - -## How it works - -``` -../web/ - └── npm 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 `