Skip to content

Commit 90fbadb

Browse files
InfantLabclaude
andcommitted
feat(android): Plan C toolchain — Live Reload + CI release builds, no devcontainer SDK
Toolchain decision locked: devcontainer stays lean (no Java, no Android SDK). Windows host gets JDK 17 + Android command-line tools only (~1GB, no IDE). Release AABs build in GitHub Actions. No emulator — real Pixel phone over USB + Capacitor Live Reload. What's in this commit: - `capacitor.config.ts` honours `CAP_SERVER_URL` env var so the WebView loads from a dev server (Live Reload) instead of the bundled assets. Empty in release builds. - `scripts/android-dev.js` detects the WSL LAN IP and prints the exact Powershell commands to `cap sync` + `gradlew installDebug` with the dev URL baked in. Plus the optional netsh portproxy + firewall rule for stubborn Wi-Fi setups. - `npm run android:dev` script wires the above. - `.github/workflows/android-release.yml` — debug APK on every `v*-android` tag (always; uploaded as a workflow artifact), signed release AAB only when the four ANDROID_* repo secrets are set. The release path is wired but dormant until the user generates a keystore. - `app/android/app/build.gradle` — signingConfigs.release pulls from env vars (ANDROID_KEY_ALIAS / _PASSWORD / _STORE_PASSWORD) plus a base64-decoded keystore at `keystore/release.keystore`. Falls back to the debug key when secrets are absent. - `app/android/.gitignore` — `*.keystore`, `*.jks`, `keystore/` to make sure a decoded keystore can never be committed. - `docs/dev/android-build-handover.md` rewritten for Plan C: one-time Windows host setup, day-to-day Live Reload loop, release-build walkthrough, Play / F-Droid status. Plan C reasoning: emulators on WSL2 are broken (no GPU passthrough), real meditation-timer testing has to happen on a real phone anyway (OEM battery quirks are the actual risk we're hardening against), so the emulator + Android Studio install isn't worth the bloat. Live Reload keeps the inner loop fast; CI handles the slow-but-rare release path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 79725e2 commit 90fbadb

7 files changed

Lines changed: 460 additions & 51 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
name: Android Release
2+
3+
on:
4+
push:
5+
tags:
6+
# Build a release AAB on tags like v0.7.0-android, v0.7.1-android, etc.
7+
- "v*-android"
8+
workflow_dispatch:
9+
inputs:
10+
apiBaseUrl:
11+
description: "API base URL to bake into the APK (default: https://tada.living)"
12+
required: false
13+
default: "https://tada.living"
14+
15+
permissions:
16+
contents: write
17+
18+
concurrency:
19+
group: android-release-${{ github.ref }}
20+
cancel-in-progress: false
21+
22+
jobs:
23+
build:
24+
name: Build Android APK + AAB
25+
runs-on: ubuntu-latest
26+
27+
env:
28+
NUXT_TYPESCRIPT_TYPECHECK: "false"
29+
NUXT_PUBLIC_API_BASE_URL: ${{ github.event.inputs.apiBaseUrl || 'https://tada.living' }}
30+
31+
steps:
32+
- name: Checkout
33+
uses: actions/checkout@v4
34+
35+
- name: Setup Bun
36+
uses: oven-sh/setup-bun@v2
37+
with:
38+
bun-version: latest
39+
40+
- name: Setup Java 17
41+
uses: actions/setup-java@v4
42+
with:
43+
distribution: temurin
44+
java-version: "17"
45+
46+
- name: Setup Android SDK
47+
uses: android-actions/setup-android@v3
48+
with:
49+
packages: "platform-tools platforms;android-34 build-tools;34.0.0"
50+
51+
- name: Install dependencies
52+
working-directory: ./app
53+
run: bun install --frozen-lockfile
54+
55+
- name: Build static web bundle (build:capacitor)
56+
working-directory: ./app
57+
run: bun run build:capacitor
58+
59+
- name: Sync Capacitor → Android project
60+
working-directory: ./app
61+
run: bunx cap sync android
62+
63+
- name: Resolve gradle wrapper permissions
64+
working-directory: ./app/android
65+
run: chmod +x gradlew
66+
67+
# ── Debug APK ─────────────────────────────────────────────
68+
# Always builds; uploads as a CI artifact so we can sideload
69+
# it onto a test device without setting up signing secrets.
70+
- name: Build debug APK
71+
working-directory: ./app/android
72+
run: ./gradlew assembleDebug
73+
74+
- name: Upload debug APK artifact
75+
uses: actions/upload-artifact@v4
76+
with:
77+
name: tada-debug-apk
78+
path: app/android/app/build/outputs/apk/debug/app-debug.apk
79+
retention-days: 14
80+
81+
# ── Release AAB (only when signing secrets exist) ─────────
82+
- name: Decode release keystore
83+
if: env.HAS_SIGNING_KEY == 'true'
84+
env:
85+
HAS_SIGNING_KEY: ${{ secrets.ANDROID_SIGNING_KEY != '' }}
86+
ANDROID_SIGNING_KEY: ${{ secrets.ANDROID_SIGNING_KEY }}
87+
run: |
88+
mkdir -p app/android/app/keystore
89+
echo "$ANDROID_SIGNING_KEY" | base64 -d > app/android/app/keystore/release.keystore
90+
91+
- name: Build release AAB
92+
if: env.HAS_SIGNING_KEY == 'true'
93+
working-directory: ./app/android
94+
env:
95+
HAS_SIGNING_KEY: ${{ secrets.ANDROID_SIGNING_KEY != '' }}
96+
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
97+
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
98+
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
99+
run: ./gradlew bundleRelease
100+
101+
- name: Upload release AAB artifact
102+
if: env.HAS_SIGNING_KEY == 'true'
103+
env:
104+
HAS_SIGNING_KEY: ${{ secrets.ANDROID_SIGNING_KEY != '' }}
105+
uses: actions/upload-artifact@v4
106+
with:
107+
name: tada-release-aab
108+
path: app/android/app/build/outputs/bundle/release/app-release.aab
109+
retention-days: 90
110+
111+
- name: Attach artifacts to GitHub release
112+
if: startsWith(github.ref, 'refs/tags/v') && env.HAS_SIGNING_KEY == 'true'
113+
env:
114+
HAS_SIGNING_KEY: ${{ secrets.ANDROID_SIGNING_KEY != '' }}
115+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
116+
run: |
117+
gh release upload "${{ github.ref_name }}" \
118+
app/android/app/build/outputs/bundle/release/app-release.aab \
119+
app/android/app/build/outputs/apk/debug/app-debug.apk \
120+
--clobber
121+
122+
- name: Note when release AAB skipped
123+
if: env.HAS_SIGNING_KEY != 'true'
124+
env:
125+
HAS_SIGNING_KEY: ${{ secrets.ANDROID_SIGNING_KEY != '' }}
126+
run: |
127+
echo "::notice title=Release AAB skipped::Signing secrets (ANDROID_SIGNING_KEY/_ALIAS/_PASSWORD/_STORE_PASSWORD) are not set. Debug APK uploaded as an artifact only."

app/android/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
*.ap_
77
*.aab
88

9+
# Signing keystores — never commit
10+
*.keystore
11+
*.jks
12+
keystore/
13+
914
# Files for the ART/Dalvik VM
1015
*.dex
1116

app/android/app/build.gradle

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,33 @@ android {
1616
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
1717
}
1818
}
19+
// Release signing pulled from env vars so the keystore never lives in
20+
// source. CI sets ANDROID_SIGNING_KEY (base64-decoded into
21+
// keystore/release.keystore) plus ANDROID_KEY_ALIAS/_PASSWORD/_STORE_PASSWORD.
22+
// Locally, leave them unset and assembleRelease will use the debug key.
23+
signingConfigs {
24+
release {
25+
def keystoreFile = file("keystore/release.keystore")
26+
if (keystoreFile.exists()
27+
&& System.getenv("ANDROID_KEY_ALIAS")
28+
&& System.getenv("ANDROID_KEY_PASSWORD")
29+
&& System.getenv("ANDROID_STORE_PASSWORD")) {
30+
storeFile = keystoreFile
31+
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
32+
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
33+
storePassword = System.getenv("ANDROID_STORE_PASSWORD")
34+
}
35+
}
36+
}
1937
buildTypes {
2038
release {
2139
minifyEnabled false
2240
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
41+
// Only attach the release signing config when the keystore is present.
42+
if (file("keystore/release.keystore").exists()
43+
&& System.getenv("ANDROID_KEY_ALIAS")) {
44+
signingConfig = signingConfigs.release
45+
}
2346
}
2447
}
2548
}

app/capacitor.config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ const config: CapacitorConfig = {
2626
// self-hosters add their own at first-run via the API server picker
2727
// (Phase 2.4); we whitelist tada.living up-front for the default flow.
2828
allowNavigation: ["tada.living", "*.tada.living"],
29+
// Live Reload during development: set CAP_SERVER_URL to point the
30+
// WebView at a Nuxt dev server (e.g. on the WSL devcontainer LAN
31+
// address). When set, Capacitor loads from that URL instead of the
32+
// bundled `webDir`, so HMR pushes JS/CSS changes straight to the
33+
// phone. Empty in release builds — use only via `npm run android:dev`.
34+
...(process.env["CAP_SERVER_URL"]
35+
? {
36+
url: process.env["CAP_SERVER_URL"],
37+
cleartext: true,
38+
}
39+
: {}),
2940
},
3041

3142
android: {

app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"build:docker": "node scripts/capture-git-info.js && NUXT_TYPESCRIPT_TYPECHECK=false nuxt build",
1111
"build:capacitor": "node scripts/capture-git-info.js && NUXT_TYPESCRIPT_TYPECHECK=false NUXT_PUBLIC_API_BASE_URL=${NUXT_PUBLIC_API_BASE_URL:-https://tada.living} nuxt generate",
1212
"android:sync": "npm run build:capacitor && npx cap sync android",
13+
"android:dev": "node scripts/android-dev.js",
1314
"android:open": "npx cap open android",
1415
"android:run": "npx cap run android",
1516
"android:assets": "npx @capacitor/assets generate --android --assetPath assets/capacitor --iconBackgroundColor #10b981 --iconBackgroundColorDark #10b981 --splashBackgroundColor #10b981 --splashBackgroundColorDark #0c8e6f",

app/scripts/android-dev.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Prints the LAN address the Capacitor WebView should point at and the
4+
* Windows-side commands to wire it up.
5+
*
6+
* Plan C workflow (see memory: v0.7.0 Android toolchain decision):
7+
*
8+
* 1. WSL devcontainer runs `bun run dev` on port 3000.
9+
* 2. Phone (on the same Wi-Fi) needs to load the WSL IP.
10+
* 3. The Windows host runs `cap sync android` + `gradlew installDebug`
11+
* with CAP_SERVER_URL pointing at this address, so the installed APK
12+
* points to the dev server.
13+
* 4. Save a file in the devcontainer → HMR pushes to the phone.
14+
*
15+
* This script just figures out the right URL and prints copy-pasteable
16+
* commands. The actual installDebug runs on Windows.
17+
*/
18+
19+
import { execSync } from "node:child_process";
20+
import { networkInterfaces } from "node:os";
21+
22+
const port = process.env.PORT ?? "3000";
23+
24+
function detectLanIp() {
25+
// Try the WSL-detected ethernet first.
26+
const ifaces = networkInterfaces();
27+
for (const name of Object.keys(ifaces)) {
28+
if (name === "lo" || name.startsWith("docker") || name.startsWith("br-")) continue;
29+
for (const addr of ifaces[name] ?? []) {
30+
if (addr.family === "IPv4" && !addr.internal) return addr.address;
31+
}
32+
}
33+
34+
// Fallback: ask ip route.
35+
try {
36+
const out = execSync("ip route get 1.1.1.1", { encoding: "utf8" });
37+
const match = out.match(/src\s+(\d+\.\d+\.\d+\.\d+)/);
38+
if (match) return match[1];
39+
} catch {
40+
// ignore
41+
}
42+
43+
return null;
44+
}
45+
46+
const ip = detectLanIp();
47+
if (!ip) {
48+
console.error("Could not detect a LAN IP. Run `ip addr` and set CAP_SERVER_URL manually.");
49+
process.exit(1);
50+
}
51+
52+
const url = `http://${ip}:${port}`;
53+
54+
console.log("");
55+
console.log("─ Capacitor Live Reload — Plan C workflow ─────────────────────────");
56+
console.log("");
57+
console.log(`Dev-server URL the phone WebView should load: ${url}`);
58+
console.log("");
59+
console.log("Step 1. In this devcontainer, in another terminal:");
60+
console.log("");
61+
console.log(" bun run dev");
62+
console.log("");
63+
console.log("Step 2. On Windows (Powershell, in the app/ directory):");
64+
console.log("");
65+
console.log(` $env:CAP_SERVER_URL = "${url}"`);
66+
console.log(" bunx cap sync android");
67+
console.log(" cd android");
68+
console.log(" ./gradlew installDebug");
69+
console.log("");
70+
console.log("Step 3. Open Ta-Da! on your phone. It will load from the WSL");
71+
console.log(" dev server, with HMR. Save a file here → phone updates.");
72+
console.log("");
73+
console.log("─ Notes ────────────────────────────────────────────────────────────");
74+
console.log("");
75+
console.log("• Phone must be on the same Wi-Fi as the laptop.");
76+
console.log("• WSL2 needs to expose port 3000 to the LAN. If your laptop's");
77+
console.log(" firewall blocks it, run this in elevated Powershell once:");
78+
console.log("");
79+
console.log(` netsh interface portproxy add v4tov4 listenport=${port} listenaddress=0.0.0.0 connectport=${port} connectaddress=${ip}`);
80+
console.log(` New-NetFirewallRule -DisplayName "WSL Nuxt dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${port}`);
81+
console.log("");
82+
console.log("• To go back to a self-contained debug APK (no Live Reload),");
83+
console.log(" unset CAP_SERVER_URL before running cap sync.");
84+
console.log("");

0 commit comments

Comments
 (0)