diff --git a/.github/workflows/build-phone-app.yml b/.github/workflows/build-phone-app.yml index 04c4d390..f778949d 100644 --- a/.github/workflows/build-phone-app.yml +++ b/.github/workflows/build-phone-app.yml @@ -5,8 +5,9 @@ # repository can call it with this one as a submodule (`path`). Setup: docs/self-hosting.md#the-phone. # variant=staging builds "OpenClapper Staging" (its own package id and clay icon, so it sits next to the # production app; signed with the same key) for a staging instance's APP_URL. variant=dev builds the -# development client "OpenClapper Dev" instead (debug-signed, its own package id, loads the JavaScript -# from Metro: make mobile), on the same runner setup. +# development client instead (its own package id, loads the JavaScript from Metro: make mobile), on the +# same runner setup: with the environment's key "OpenClapper Dev", a release build for Google Play's +# internal track (deploy#22); without a key "OpenClapper Dev Local", debug-signed like make dev-build. # `environment` and `ref` work as in deploy-server.yml: the GitHub environment whose variables (APP_URL) and # secrets (the signing key) to use, and another version of this repository to build than the pinned one. # The Android SDK, build-tools and NDK are the runner's; local builds (scripts/apk-local.sh) pin theirs in @@ -22,7 +23,7 @@ on: options: [release, staging, dev] default: release track: - description: Upload the bundle to this Google Play track (release and staging; needs PLAY_SERVICE_ACCOUNT_JSON) + description: Upload the bundle to this Google Play track (needs PLAY_SERVICE_ACCOUNT_JSON, and for dev the key) type: choice options: [none, internal] default: none @@ -41,7 +42,7 @@ on: type: string default: release track: - description: Upload the bundle to this Google Play track (none, internal); release and staging only + description: Upload the bundle to this Google Play track (none, internal); dev only with the key type: string default: none path: @@ -57,7 +58,7 @@ on: type: string default: "" secrets: - # Only the release and staging variants use them. + # The release and staging variants need them; the dev variant is signed with them when they are set. ANDROID_KEYSTORE_BASE64: required: false ANDROID_KEYSTORE_PASSWORD: @@ -103,11 +104,18 @@ jobs: run: | case "$VARIANT" in release|staging|dev) ;; *) echo "::error::variant must be release, staging or dev"; exit 1 ;; esac case "$TRACK" in none) ;; internal) - [ "$VARIANT" != dev ] || { echo "::error::The dev client does not go to Google Play"; exit 1; } [ -n "$PLAY_SERVICE_ACCOUNT_JSON" ] || { echo "::error::Set the PLAY_SERVICE_ACCOUNT_JSON secret to upload to Google Play (docs/self-hosting.md)"; exit 1; } ;; *) echo "::error::track must be none or internal"; exit 1 ;; esac - [ "$VARIANT" != dev ] || exit 0 # the development client needs neither a server nor the key - [ -n "$EXPO_PUBLIC_API_URL" ] || { echo "::error::Set the APP_URL variable to your server, e.g. https://openclapper-api..workers.dev"; exit 1; } + if [ "$VARIANT" = dev ]; then + # The development client needs no server (Metro gives it one); without the key it is the + # debug-signed OpenClapper Dev Local, which Google Play does not take. + if [ -z "$ANDROID_KEYSTORE_BASE64" ]; then + [ "$TRACK" = none ] || { echo "::error::The dev client goes to Google Play signed: set the ANDROID_KEYSTORE_BASE64 secret and the other three (scripts/keystore.sh)"; exit 1; } + exit 0 + fi + else + [ -n "$EXPO_PUBLIC_API_URL" ] || { echo "::error::Set the APP_URL variable to your server, e.g. https://openclapper-api..workers.dev"; exit 1; } + fi for v in ANDROID_KEYSTORE_BASE64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD; do [ -n "${!v}" ] || { echo "::error::Set the $v secret (scripts/keystore.sh prints all four)"; exit 1; } done @@ -178,23 +186,29 @@ jobs: run: | export GIT_COMMIT=$(git rev-parse --short HEAD) version=$(node -p 'require("../../package.json").version') - if [ "$VARIANT" = dev ]; then - # The development client: its own name, package id and icon (app.config.ts), debug-signed with - # the template's fixed debug key (so one dev build updates the last), no bundle inside. - export APP_VARIANT=development - task=assembleDebug out=debug name="openclapper-dev-$version-$GIT_COMMIT.apk" + if [ "$VARIANT" = dev ] && [ -z "$ANDROID_KEYSTORE_BASE64" ]; then + # The development client without a key, OpenClapper Dev Local: its own name, package id and icon + # (app.config.ts), debug-signed with the template's fixed debug key (so it installs over a + # make dev-build and the other way round), no bundle inside. + export APP_VARIANT=development-local + task=assembleDebug out=debug name="openclapper-dev-local-$version-$GIT_COMMIT.apk" else echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks" export ANDROID_KEYSTORE="$RUNNER_TEMP/release.jks" task=assembleRelease out=release name="openclapper-$version-$GIT_COMMIT.apk" - # The staging app: its own name, package id and icon (app.config.ts). Its version code is the - # build time, minutes since 2020-01-01 UTC: every build is higher than the one before, whatever - # ref it comes from, so Play installs an older tag as an ordinary update (a rollback keeps the - # phone's data). Builds in the same minute share a code; Play then keeps the first. - if [ "$VARIANT" = staging ]; then - export APP_VARIANT=staging name="openclapper-staging-$version-$GIT_COMMIT.apk" - export STAGING_VERSION_CODE=$(( ($(date -u +%s) - $(date -u -d 2020-01-01 +%s)) / 60 )) - echo "Version code: $STAGING_VERSION_CODE (minutes since 2020-01-01 UTC)" + # The staging app and the dev client for Play: their own name, package id and icon + # (app.config.ts). Their version code is the build time, minutes since 2020-01-01 UTC: every + # build is higher than the one before, whatever ref it comes from, so Play installs an older + # one as an ordinary update (a rollback keeps the phone's data). Builds in the same minute + # share a code; Play then keeps the first. The dev client keeps its launcher and menu in this + # release build (app.config.ts, withDevClientInRelease). + case "$VARIANT" in + staging) export APP_VARIANT=staging name="openclapper-staging-$version-$GIT_COMMIT.apk" ;; + dev) export APP_VARIANT=development name="openclapper-dev-$version-$GIT_COMMIT.apk" ;; + esac + if [ "$VARIANT" != release ]; then + export BUILD_TIME_VERSION_CODE=$(( ($(date -u +%s) - $(date -u -d 2020-01-01 +%s)) / 60 )) + echo "Version code: $BUILD_TIME_VERSION_CODE (minutes since 2020-01-01 UTC)" fi # The App Bundle for Play next to the APK for sideloading, from the same build and key. task="$task bundleRelease" @@ -202,7 +216,7 @@ jobs: pnpm exec expo prebuild --platform android --no-install (cd android && ./gradlew $task --no-daemon -PreactNativeArchitectures="$ANDROID_ABIS") mv "android/app/build/outputs/apk/$out/app-$out.apk" "$RUNNER_TEMP/$name" - if [ "$VARIANT" != dev ]; then + if [ "$out" = release ]; then aab="${name%.apk}.aab" mv android/app/build/outputs/bundle/release/app-release.aab "$RUNNER_TEMP/$aab" # Never hand out a release signed with the debug key (it would not update a real install). @@ -267,7 +281,7 @@ jobs: retention-days: 3 compression-level: 0 - # The App Bundle (release and staging): what goes to Google Play (scripts/play-upload.mjs, deploy#11). + # The App Bundle (every signed build): what goes to Google Play (scripts/play-upload.mjs, deploy#11). - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: env.AAB_NAME != '' with: diff --git a/Makefile b/Makefile index 5021e4a1..72300ee9 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ admin: ## Admin board -> http://localhost:5174 with its Worker on :8788 (local D mobile: ## Local Worker behind a quick tunnel + Metro for the dev build (OpenClapper Dev); LOCAL data pnpm dev:mobile -dev-build: ## Development build (OpenClapper Dev) in Docker, to the Windows Downloads folder; rebuild after native changes +dev-build: ## Development build (OpenClapper Dev Local) in Docker, to the Windows Downloads folder; rebuild after native changes VARIANT=dev bash scripts/apk-local.sh apk-local: ## OpenClapper Local in Docker, a complete app for one server, no secrets: API_URL=https://… make apk-local diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 9b255b96..5849aaf8 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,5 +1,5 @@ import type { ConfigContext, ExpoConfig } from "expo/config"; -import { withAndroidManifest, withAppBuildGradle } from "expo/config-plugins"; +import { withAndroidManifest, withAppBuildGradle, withGradleProperties } from "expo/config-plugins"; import { version } from "../../package.json"; /** @@ -12,13 +12,13 @@ function versionCode(v: string): number { } /** - * The staging app is built from any ref between releases, and Android only installs a higher version - * code, so it uses the build time instead (STAGING_VERSION_CODE, minutes since 2020, set by the Build - * phone app workflow): a build of an older tag still installs as an update. Without it (a local config - * run) it falls back to the release scheme. + * The staging app and the dev client on Google Play are built from any ref between releases, and Android + * only installs a higher version code, so they use the build time instead (BUILD_TIME_VERSION_CODE, + * minutes since 2020, set by the Build phone app workflow): a build of an older ref still installs as an + * update. Without it (a local config run) it falls back to the release scheme. */ -function stagingVersionCode(): number { - const code = Number.parseInt(process.env.STAGING_VERSION_CODE ?? "", 10); +function buildTimeVersionCode(): number { + const code = Number.parseInt(process.env.BUILD_TIME_VERSION_CODE ?? "", 10); return code > 0 ? code : versionCode(version); } @@ -75,6 +75,20 @@ const withUncappedLocation = (config: ExpoConfig): ExpoConfig => return c; }) as ExpoConfig; +/** + * expo-dev-launcher and expo-dev-menu compile themselves out of release builds unless these Gradle + * properties say otherwise (their android/build.gradle). The dev client on Google Play must be a release + * build (Play rejects debuggable bundles), so it keeps them in. + */ +const withDevClientInRelease = (config: ExpoConfig): ExpoConfig => + withGradleProperties(config, (c) => { + for (const key of ["expo.devlauncher.configureInRelease", "expo.devmenu.configureInRelease"]) { + c.modResults = c.modResults.filter((p) => !(p.type === "property" && p.key === key)); + c.modResults.push({ type: "property", key, value: "true" }); + } + return c; + }) as ExpoConfig; + /** Every build's config plugins of our own. */ const withOwnPlugins = (config: ExpoConfig): ExpoConfig => withUncappedLocation(withReleaseSigning(config)); @@ -82,7 +96,11 @@ const withOwnPlugins = (config: ExpoConfig): ExpoConfig => withUncappedLocation( * The builds besides production (app.json), by APP_VARIANT. Each has its own package id, so it sits next * to the production app with its own storage instead of replacing it, and its own icon colour tells them * apart in the drawer (assets/icons, #249): - * - development: the dev client "OpenClapper Dev" (make dev-build, make mobile), loads the JavaScript from Metro. + * - development: the dev client "OpenClapper Dev", loads the JavaScript from Metro (make mobile). A release + * build signed with the deployment's key, from Google Play's internal track (Build phone app, variant dev). + * - development-local: the same dev client built without a key (make dev-build, or Build phone app with + * variant dev and no key), debug-signed. Android installs no update signed with another key, so it has + * its own package id and sits next to the Play one; both share the scheme, so Metro opens either. * - staging: "OpenClapper Staging" for a staging instance, built by the Build phone app workflow and signed * with the deployment's key like production. * - local: "OpenClapper Local" (make apk-local), the JavaScript inside and signed with the template's @@ -90,6 +108,7 @@ const withOwnPlugins = (config: ExpoConfig): ExpoConfig => withUncappedLocation( */ const VARIANTS: Record = { development: { name: "OpenClapper Dev", slug: "dev", package: "org.openclapper.dev", icons: "dev", background: "#3B7A1A" }, + "development-local": { name: "OpenClapper Dev Local", slug: "dev", package: "org.openclapper.dev.local", icons: "dev", background: "#3B7A1A" }, staging: { name: "OpenClapper Staging", slug: "staging", package: "org.openclapper.staging", icons: "staging", background: "#B8471F" }, local: { name: "OpenClapper Local", slug: "local", package: "org.openclapper.local", icons: "dev", background: "#3B7A1A" }, }; @@ -112,14 +131,14 @@ export default ({ config }: ConfigContext): ExpoConfig => { // A typo must not silently build the production app. if (!v) throw new Error(`APP_VARIANT=${name}: expected one of ${Object.keys(VARIANTS).join(", ")}, or unset for production`); const icons = `./assets/icons/${v.icons}`; - return withOwnPlugins({ + const variant = withOwnPlugins({ ...app, name: v.name, scheme: `openclapper-${v.slug}`, icon: `${icons}/app-icon-1024.png`, android: { ...app.android, - ...(name === "staging" ? { versionCode: stagingVersionCode() } : {}), + ...(name === "staging" || name === "development" ? { versionCode: buildTimeVersionCode() } : {}), package: v.package, adaptiveIcon: { backgroundColor: v.background, @@ -128,4 +147,5 @@ export default ({ config }: ConfigContext): ExpoConfig => { }, }, }); + return name === "development" ? withDevClientInRelease(variant) : variant; }; diff --git a/docs/development.md b/docs/development.md index 23bfcb4f..b16df445 100644 --- a/docs/development.md +++ b/docs/development.md @@ -85,18 +85,20 @@ Agents can click through the Studio with the Playwright MCP: headless Chrome aga ## Phone dev loop (development build) ```sh -make dev-build # once, and after every native change: "OpenClapper Dev" APK into the Windows Downloads folder -make mobile # local Worker behind a quick tunnel + Metro for OpenClapper Dev; the phone works on LOCAL data +make dev-build # once, and after every native change: "OpenClapper Dev Local" APK into the Windows Downloads folder +make mobile # local Worker behind a quick tunnel + Metro for the dev client; the phone works on LOCAL data make all # all of it at once: Worker, Studio, admin board and mobile in one tmux session ("openclapper"), one pane each make all-down # stop all of it and close the session · make all-restart: restart the servers in place make studio # beside it: the Studio on :5173 against the same local Worker ``` -- **The development build** ("OpenClapper Dev", `org.openclapper.dev`) is this app with its native modules plus Expo's dev launcher and menu (`expo-dev-client`). It carries no JavaScript: it loads the bundle from Metro, so Fast Refresh works as in Expo Go. Rebuild only when the native side changes: a new native module, a plugin or permission in `app.json`, an SDK upgrade. A bundle that calls a native module the build lacks fails at runtime. -- `APP_VARIANT` switches `app.config.ts` to another name, package id, scheme and icon set: `development` (`make dev-build` and `make mobile`, the green dev icons), `local` (`make apk-local`, also green), `staging` (Build phone app, clay); unset is production, any other value fails. Each has its own package id, so it installs next to the production app with its own storage. The Studio and admin dev servers show the green icons too (tab, installed app); builds the real ones ([design](design.md)). +- **The development build** (the dev client) is this app with its native modules plus Expo's dev launcher and menu (`expo-dev-client`). It loads the JavaScript from Metro, so Fast Refresh works as in Expo Go. Rebuild only when the native side changes: a new native module, a plugin or permission in `app.json`, an SDK upgrade. A bundle that calls a native module the build lacks fails at runtime. It comes as two installs, side by side, because Android installs no update signed with another key: + - **OpenClapper Dev** (`org.openclapper.dev`) from Google Play's internal track: a release build signed with a deployment's key that keeps the launcher and menu (`app.config.ts`, `withDevClientInRelease`), built by Build phone app with `variant: dev` ([Play Store](self-hosting.md#play-store)). After a native change, build it once with `track: internal` and Play updates the phone. + - **OpenClapper Dev Local** (`org.openclapper.dev.local`): the keyless debug build, `make dev-build`, for quick native iterations without waiting for Play. +- `APP_VARIANT` switches `app.config.ts` to another name, package id, scheme and icon set: `development` (the dev client for Play, and `make mobile`), `development-local` (`make dev-build`; same scheme and icons, so Metro's QR code opens either dev client), `local` (`make apk-local`, also green), `staging` (Build phone app, clay); unset is production, any other value fails. Each has its own package id, so it installs next to the production app with its own storage. The Studio and admin dev servers show the green icons too (tab, installed app); builds the real ones ([design](design.md)). - `make dev-build` = `VARIANT=dev scripts/apk-local.sh`: `expo prebuild` and `./gradlew assembleDebug` in the Docker toolchain, no account. Signed with the debug key of Expo's prebuild template, the same on every machine. Install the APK from Downloads once (allow installs from the file manager); later builds install over it. -- **On GitHub instead**: Actions → **Build phone app** → Run workflow with `variant: dev` (in a deployment repository its Build phone app workflow passes the same input). Same runner setup as the release build; the APK `openclapper-dev--.apk` is the run's artifact for 3 days. Built from the same template debug key, so it installs over a local `make dev-build` and vice versa. -- In OpenClapper Dev, open the `exp.direct` URL Metro prints (or pick the server in the launcher). Shake or the notification opens the dev menu. +- **On GitHub instead**: Actions → **Build phone app** → Run workflow with `variant: dev` (in a deployment repository its Build phone app workflow passes the same input). Same runner setup as the release build. With the four key secrets in the environment it builds OpenClapper Dev (APK and App Bundle; `track: internal` uploads the bundle); without them OpenClapper Dev Local, `openclapper-dev-local--.apk`, from the same template debug key, so it installs over a local `make dev-build` and vice versa. Either is the run's artifact for 3 days. +- In the dev client, open the `exp.direct` URL Metro prints (or pick the server in the launcher). Shake or the notification opens the dev menu. - `scripts/dev-all.sh` (`make all`) opens a tmux session `openclapper`: Worker, Studio (:5173) and admin board (:5174, its Worker on :8788) stacked on the left, `make mobile` on the right (it and the admin pane wait for the Worker: mobile only adds a tunnel, and two wranglers starting together collide on their inspector port), and a **control strip** across the bottom showing which servers answer (● up, ○ down). Click it, then `r` restarts the three servers in their panes, `q` stops everything and closes the session, `d` detaches. Mouse mode is on, panes stay open after a crash so the error is readable, and running `make all` again attaches. Ctrl-C in a server pane stops just that server and leaves a shell to run it again. From another terminal: `make all-down` (Ctrl-C to every server, then it stops anything started from this repo that still listens on :8787, :5173 or Metro's :8081, plus a stray `cloudflared`; other folders' processes are left alone, then the session closes) and `make all-restart` (the same as `r`). - `scripts/mobile.sh` starts `wrangler dev --tunnel` on :8787, or, when `make dev`/`make worker` already runs there, only a quick tunnel to it (`wrangler tunnel quick-start`), waits for the `https://….trycloudflare.com` URL, then starts Metro (`expo start --dev-client --tunnel --clear`) with `EXPO_PUBLIC_API_URL` set to it and `EXPO_PUBLIC_STORAGE_SCOPE=local`. It refuses to start while an `apps/mobile/.env*` file sets `EXPO_PUBLIC_*`: in development Expo serves those files ahead of the environment (`expo/virtual/env`), so the app would talk to that server instead of the tunnel. Ctrl-C stops both. Both tunnels are needed because WSL2 in NAT mode is not reachable from the phone over LAN. diff --git a/docs/features/phone-app.md b/docs/features/phone-app.md index 21ad2a4a..428e5230 100644 --- a/docs/features/phone-app.md +++ b/docs/features/phone-app.md @@ -4,7 +4,7 @@ Designed in Claude Design (2026-09-28). The IA is organised around what happens ## Builds -One app, four installs side by side, each with its own package id, storage and icon colour (`APP_VARIANT`, `app.config.ts`): **OpenClapper** (production, blue), **OpenClapper Staging** (`org.openclapper.staging`, clay, for a staging instance; built by Build phone app, same signing key), **OpenClapper Dev** (the dev client, moss, JavaScript from Metro) and **OpenClapper Local** (`org.openclapper.local`, moss, debug-signed, `make apk-local`). Each talks to the server built into it; the Setup footer names it ([development](../development.md#apk-builds), [self-hosting](../self-hosting.md#the-phone)). +One app, five installs side by side, each with its own package id, storage and icon colour (`APP_VARIANT`, `app.config.ts`): **OpenClapper** (production, blue), **OpenClapper Staging** (`org.openclapper.staging`, clay, for a staging instance; built by Build phone app, same signing key), **OpenClapper Dev** (`org.openclapper.dev`, the dev client from Google Play, moss, JavaScript from Metro), **OpenClapper Dev Local** (`org.openclapper.dev.local`, the same dev client debug-signed, `make dev-build`) and **OpenClapper Local** (`org.openclapper.local`, moss, debug-signed, `make apk-local`). Each talks to the server built into it; the Setup footer names it ([development](../development.md#apk-builds), [self-hosting](../self-hosting.md#the-phone)). ## Shell (`apps/mobile/App.tsx`, `src/appState.tsx`) diff --git a/docs/known-issues.md b/docs/known-issues.md index 09240713..48bbb061 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -89,3 +89,7 @@ The picture works on the owner's phone (docs/features/live-picture.md). Not trie - Wi-Fi drops mid-stream. The dev client loads its JavaScript from Metro, which the transmitter's Wi-Fi cannot reach: open the app on another network first, then switch. + +## The dev client on Google Play is a release build (deploy#22) + +Play rejects debuggable bundles, so "OpenClapper Dev" from the internal track is a release build that keeps expo-dev-client's launcher and menu (`expo.devlauncher.configureInRelease`, `expo.devmenu.configureInRelease`, set by `app.config.ts`). Expo supports that switch, but this app has not run that way yet: whether the launcher opens the tunnel URL, Fast Refresh and the dev menu work, and debug-only behaviour (`__DEV__`, LogBox) matches the debug build is still to be checked on the phone. `make dev-build` (OpenClapper Dev Local, a debug build) stays the fallback. diff --git a/docs/releasing.md b/docs/releasing.md index 3b0bb4b0..5f822891 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -16,7 +16,7 @@ OpenClapper has **one version for the whole product**: the Worker and the Studio - Admin board: its header. - Phone: the Setup footer and the debug log. - **Android**: `versionName` is the version and `versionCode` is derived from it (`major·1 000 000 + minor·1 000 + patch`: 0.1.0 → 1000), so it grows with every release (`apps/mobile/app.config.ts`). Builds between releases carry the same code as the last release; Android installs a same-code update over it. Google Play takes each code once, so production goes to Play once per release. -- **The staging app** (`APP_VARIANT=staging`) uses the build time instead: minutes since 2020-01-01 UTC, set by Build phone app. Every build is higher than the one before, whatever ref it comes from, so a build of an older tag installs on the phone as an ordinary update (data kept): staging can be rolled back, server and app. The version name and the commit in the Setup footer say what a build contains. +- **The staging app** (`APP_VARIANT=staging`) and the dev client for Google Play (`APP_VARIANT=development`) use the build time instead: minutes since 2020-01-01 UTC (`BUILD_TIME_VERSION_CODE`), set by Build phone app. Every build is higher than the one before, whatever ref it comes from, so a build of an older tag installs on the phone as an ordinary update (data kept): staging can be rolled back, server and app. The version name and the commit in the Setup footer say what a build contains. - **Testing a release on staging**: after `scripts/release.sh`, the deployment's staging Update with `ref: vX.Y.Z` puts exactly that tag on staging (server and app) without moving the deployment's pin; its production Release then ships the same tag. ## Cutting a release diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 372ff7af..a1c4720b 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -124,7 +124,7 @@ Once: 2. **Back up both files** (a password manager or an encrypted drive). Lose them and nobody can update the app without uninstalling it, which deletes whatever is still queued on the phones. The GitHub secrets and the backup are the key's home: nothing reads it from a checkout. 3. Add the repository variable `APP_URL`: your app's address, `https://` or `https://..workers.dev`. -Then Actions → **Build phone app** → Run workflow (`variant: release`, the default; `staging` builds "OpenClapper Staging", `org.openclapper.staging` with the clay icon and the same key, which installs next to the production app, for a [second instance](#a-second-instance-staging); `dev` builds the development client for the phone dev loop instead, [development](development.md)). The build runs on GitHub's runner. It compiles only for `arm64-v8a`, which every current Android phone runs; for older 32-bit phones set the variable `ANDROID_ABIS=arm64-v8a,armeabi-v7a` (each architecture adds build time, disk and APK size). The job first deletes preinstalled tools it does not use, since a standard runner has only about 14 GB free. The APK is the run's artifact and is **deleted after 3 days** (`retention-days`: GitHub removes it, nothing to clean up). Download it, open it on the phone and allow installs from that source. Release and staging runs also make the App Bundle for [Google Play](#play-store). A private repository on GitHub's Free plan has 500 MB of artifact storage; one APK is about 80 MB, a bundle about as much. +Then Actions → **Build phone app** → Run workflow (`variant: release`, the default; `staging` builds "OpenClapper Staging", `org.openclapper.staging` with the clay icon and the same key, which installs next to the production app, for a [second instance](#a-second-instance-staging); `dev` builds the development client for the phone dev loop instead: with the key "OpenClapper Dev" (`org.openclapper.dev`) for Google Play, without one the debug-signed "OpenClapper Dev Local", [development](development.md)). The build runs on GitHub's runner. It compiles only for `arm64-v8a`, which every current Android phone runs; for older 32-bit phones set the variable `ANDROID_ABIS=arm64-v8a,armeabi-v7a` (each architecture adds build time, disk and APK size). The job first deletes preinstalled tools it does not use, since a standard runner has only about 14 GB free. The APK is the run's artifact and is **deleted after 3 days** (`retention-days`: GitHub removes it, nothing to clean up). Download it, open it on the phone and allow installs from that source. Every signed run (release, staging, and dev with the key) also makes the App Bundle for [Google Play](#play-store). A private repository on GitHub's Free plan has 500 MB of artifact storage; one APK is about 80 MB, a bundle about as much. Without GitHub, a release build is your own setup: export the key's four values (with `ANDROID_KEYSTORE` as the path to the `.jks`) and `EXPO_PUBLIC_API_URL`, then run the workflow's build step. `make apk-local` is no release: it builds "OpenClapper Local", debug-signed, for trying the app ([development](development.md#apk-builds)). @@ -132,13 +132,14 @@ Without GitHub, a release build is your own setup: export the key's four values Google Play delivers the app instead of sideloading, starting with the **internal testing** track: up to 100 testers by email, no review, no store listing, updates on the phones within minutes. No Expo account and no third-party service is involved: Build phone app makes the bundle, and Google's own API takes it (the upload, deploy#11). -- **The bundle.** Release and staging runs of Build phone app produce an Android App Bundle (`.aab`) next to the APK, from the same build and key, as a second artifact. Play takes only bundles; the APK stays for sideloading. `ANDROID_ABIS` limits which devices Play can serve. +- **The bundle.** Signed runs of Build phone app (release, staging, dev with the key) produce an Android App Bundle (`.aab`) next to the APK, from the same build and key, as a second artifact. Play takes only bundles; the APK stays for sideloading. `ANDROID_ABIS` limits which devices Play can serve. +- **The dev client on Play.** `variant: dev` with the key builds "OpenClapper Dev" (`org.openclapper.dev`) as a release build that keeps expo-dev-client's launcher and menu (Play rejects debuggable bundles; `app.config.ts`), so after a native change the phones get the new client from the internal track instead of a copied APK. It is its own app in the Play Console, set up like the others. The keyless dev build (`make dev-build`, "OpenClapper Dev Local", `org.openclapper.dev.local`) installs next to it. - **Requirements**, checked by the workflow's "Play requirements" step: - Target API level 36 or higher: Play has rejected new apps and updates below it since 31 August 2026. The step fails below. - 16 KB page size: native libraries' load segments aligned to 16 KB. The step reports any library that is not, as a warning for now. -- **Version codes.** Play takes each version code once per app. Production's grows with each release (`0.10.0` → `10000`, [releasing](releasing.md)), so production uploads once per release. The staging app uses the build time instead (minutes since 2020), so every build is a new, higher version, whatever it is built from: an older tag installs as an ordinary update, which makes rolling staging back on a phone possible. +- **Version codes.** Play takes each version code once per app. Production's grows with each release (`0.10.0` → `10000`, [releasing](releasing.md)), so production uploads once per release. The staging app and the dev client use the build time instead (minutes since 2020), so every build is a new, higher version, whatever it is built from: an older tag installs as an ordinary update, which makes rolling staging back on a phone possible. - **Signing.** Play signs what it delivers (Play App Signing). Let Google generate each app's signing key (the Console's default on the first release); your deployment's key from `scripts/keystore.sh` becomes the app's **upload key**: Build phone app signs every bundle with it, and Play accepts only bundles signed with it. A lost upload key can be reset through Google, so no app is ever stuck. The price: an APK from Build phone app (signed with your key) and the app from Play (signed with Google's) cannot update each other, so a phone has one or the other; uninstall to switch. (Uploading your own key as the signing key with Google's PEPK tool keeps them interchangeable, but a lost key then means an app that can never be updated.) -- **The first bundle of each app** is uploaded by hand in the Play Console: the API can neither create an app nor choose its signing key. Play Console → Create app → the package (`org.openclapper`, `org.openclapper.staging`) → Testing → Internal testing → Create release → keep "Google-generated key" → the `.aab` from Build phone app, which registers its key as the upload key. Then add the testers' emails and open the opt-in link on each phone once. +- **The first bundle of each app** is uploaded by hand in the Play Console: the API can neither create an app nor choose its signing key. Play Console → Create app → the package (`org.openclapper`, `org.openclapper.staging`, `org.openclapper.dev`) → Testing → Internal testing → Create release → keep "Google-generated key" → the `.aab` from Build phone app, which registers its key as the upload key. Then add the testers' emails and open the opt-in link on each phone once. - **Uploads from the workflow.** Google Cloud Console → a project → IAM → Service accounts → create one and a JSON key for it. Play Console → Users and permissions → invite the service account's email with "Release to testing tracks" on the app (on each app). Store the JSON as the secret `PLAY_SERVICE_ACCOUNT_JSON` (in each GitHub environment that uploads). Then Build phone app with `track: internal` uploads the bundle after the build (`scripts/play-upload.mjs`: Google's androidpublisher API with the service account, no dependency). A version code Play already has is a notice, not a failure. An app that was never published takes draft releases only: the run says so, and you roll the release out in the Console once. A newly invited service account may be refused for up to a day. ## The free plan diff --git a/scripts/apk-local.sh b/scripts/apk-local.sh index a417ab18..5566d8f9 100755 --- a/scripts/apk-local.sh +++ b/scripts/apk-local.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Builds the Android app locally in a Docker container (expo prebuild + Gradle; no Expo account), then # copies the APK to the Windows Downloads folder. WSL + Docker Desktop (WSL integration on). -# VARIANT=dev (make dev-build) the development build "OpenClapper Dev": debug-signed, no bundle +# VARIANT=dev (make dev-build) the development build "OpenClapper Dev Local" +# (org.openclapper.dev.local, next to OpenClapper Dev from Google Play): debug-signed, no bundle # inside; it loads the JavaScript, and with it the server's address, from Metro # (make mobile). # VARIANT=local (make apk-local) "OpenClapper Local": a complete app with the JavaScript inside, @@ -34,7 +35,7 @@ case "$variant" in dev) task=assembleDebug; out_dir=debug; name="openclapper-dev-$commit.apk" # The development build takes its server from Metro, so the inlined one is never used. - run+=(-e APP_VARIANT=development -e EXPO_PUBLIC_API_URL="${API_URL:-http://localhost:8787}") + run+=(-e APP_VARIANT=development-local -e EXPO_PUBLIC_API_URL="${API_URL:-http://localhost:8787}") ;; local) # A release build without ANDROID_KEYSTORE signs with the debug key (app.config.ts withReleaseSigning). diff --git a/scripts/mobile.sh b/scripts/mobile.sh index ca4781e7..20ace941 100755 --- a/scripts/mobile.sh +++ b/scripts/mobile.sh @@ -47,5 +47,6 @@ echo "Local Worker: $url (Studio: make studio → http://localhost:5173)" # --clear: EXPO_PUBLIC_* values are inlined, and the URL is new on every run. cd "$root/apps/mobile" -# For the development build "OpenClapper Dev" (make dev-build); APP_VARIANT picks its name and package id. +# For the dev client, "OpenClapper Dev" from Google Play or "OpenClapper Dev Local" (make dev-build): both +# have the scheme APP_VARIANT=development gives, so the QR code opens either. EXPO_PUBLIC_API_URL="$url" EXPO_PUBLIC_STORAGE_SCOPE=local APP_VARIANT=development pnpm exec expo start --dev-client --tunnel --clear