# Waters Checklist — native iOS & Android apps The checklist app now ships to **three** targets, all built from the same front-end files in `../server/public/`: | Target | Where | Updates when | |---|---|---| | Website | `check.watersmedia.org` | you deploy — immediately | | iOS app | App Store (unlisted) | a new build is uploaded **and released** | | Android app | Play Store (unlisted track) | a new AAB is uploaded **and released** | > **The one thing that matters:** the store apps bundle a *copy* of the front-end inside the > app binary. They have no mechanism to notice a web deploy — not on relaunch, not on > sign-out, not ever. Deploy the website alone and phones keep the UI they were last built > with, silently, while the website moves on. So after any front-end change, run: > > ```bash > ./release.sh > ``` > > from the project root (not from `mobile/`). That's the whole workflow. --- ## Everyday release ```bash cd /Users/Studio/Documents/recurring-checklist-app ./release.sh # all three targets, bumps both build numbers ./release.sh --version 1.1.0 # …and set the version users see ./release.sh --web # website only — for a server/cron/email change ``` Once both API keys are configured (below), that one command deploys the website, uploads the iOS build to App Store Connect and releases the Android build to the internal track — no consoles, no dragging files. Builds also land in `dist-release/` either way. **What "fully automated" can and cannot mean.** The website is live the second it deploys. The stores are not, and no automation changes that: Play takes a few minutes to process an internal-track release, and Apple takes ~15 minutes to process a build before TestFlight testers can install it. Anything beyond TestFlight — a public App Store release — waits on human review. The upload is automated; the stores' clocks are not. --- ## Architecture, in one screen The apps are **not** a browser pointed at the website — Apple rejects that under Guideline 4.2. They bundle the front-end and treat the server as a JSON API. That creates exactly one problem: on the web, front-end and API share an origin, so `/api/state` and `/equip-images/x.jpg` just work. Inside an app the page is served from `capacitor://localhost` (iOS) or `https://localhost` (Android), where those paths resolve against the app bundle and 404. Two files solve it: - **`../server/public/native-bridge.js`** — shared by *all three* targets, loaded before `app.js`. In a browser it is a deliberate no-op. In an app it patches `window.fetch` to re-point server-absolute paths, exposes `resolveUrl()` for the few ``/`` sites, routes `navigator.vibrate` → native haptics, and opens external links in the system browser instead of blanking the WebView. - **`src/native-shell.js`** — native only, esbuild-bundled and injected into app bundles only. Publishes `window.CapPlugins` synchronously, then handles splash handover, status bar, Android's hardware Back button, foreground resume, and online/offline. `server.js` carries a matching **`NATIVE_CORS_ORIGINS`** allowlist (an allowlist, not `*`; auth is a Bearer token with no cookies, so `Allow-Credentials` is deliberately omitted). `www/` is generated. It is wiped and rebuilt from `../server/public` on every sync — editing it is always a mistake. --- ## Toolchain (already installed, no Homebrew on this Mac) | | | |---|---| | JDK 21 | `~/.jdks/temurin-21` | | Android SDK | `~/Library/Android/sdk` (API 36 + 35, build-tools, platform-tools) | | iOS deps | **Swift Package Manager, not CocoaPods** — system Ruby is 2.6, too old for CocoaPods | None of it is on the default `PATH`, so **every Android command must source the env first**: ```bash . mobile/scripts/env.sh ``` Skip that and Gradle fails with the misleading *"Unable to locate a Java Runtime."* `release.sh` does it for you. To rebuild the toolchain on a new Mac, `scripts/env.sh` carries the exact reinstall commands in its error messages. --- ## iOS one-time setup **Signing is DONE** (2026-09-14). Team **LIVING WATERS CHURCH**, Team ID `M87US675B3`, in `mobile/ios-signing.config`. `release.sh --ios` produces a signed App Store `.ipa` unattended — `-allowProvisioningUpdates` created the distribution certificate, the provisioning profile and the App ID registration on its own. If you ever set this up again (new Mac, new team): sign into Xcode ▸ Settings ▸ Accounts, then put the Team ID in `ios-signing.config` and run `./release.sh --ios`. Xcode does the rest. Finding the Team ID is the fiddly part — Xcode 26 shows your teams but never prints their IDs, and caches nothing readable. Decode any profile you already have instead: ```bash security cms -D -i ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/*.mobileprovision \ | grep -A1 -E "TeamIdentifier|TeamName" ``` Still to do in the portals: 1. **App Store Connect** → Apps → **+** → New App, bundle ID `org.waterschurch.checklist`. Needed before a build can be uploaded. 2. For unlisted distribution, request it at [Apple's unlisted app request form](https://developer.apple.com/contact/request/unlisted-app-distribution/). Apple approves it manually — allow a few days. Until then use **TestFlight**, which works immediately and is fine for volunteers. ### Automated uploads (App Store Connect API key) With a key configured, `release.sh` validates the build against Apple's acceptance checks and uploads it — no Organizer, no Transporter. Without one it just hands you the `.ipa`. Creating the key needs your Apple ID and 2FA, so it's yours to do: 1. [appstoreconnect.apple.com](https://appstoreconnect.apple.com) → **Users and Access** → **Integrations** → **App Store Connect API** → **Team Keys**. 2. **+** → name it (e.g. `Checklist release`) → Access: **App Manager** → Generate. *(App Manager is the minimum that can upload builds. Developer is not enough.)* 3. **Download the .p8 — you get exactly one chance.** Apple never shows it again; a lost key can only be revoked and replaced. 4. Move it where `altool` looks, keeping Apple's filename exactly: ```bash mv ~/Downloads/AuthKey_*.p8 ~/.appstoreconnect/private_keys/ ``` 5. Copy two values off that page into `ios-signing.config`: - **KEY ID** — 10 chars, in the key's own row - **Issuer ID** — a UUID shown *above the table*, shared by every key on the team (a classic mix-up: the Issuer ID is not the key's ID) ``` ASC_KEY_ID=XXXXXXXXXX ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx ``` 6. Confirm it works — authenticates only, uploads nothing: ```bash ./mobile/scripts/verify-asc-key.sh ``` The `.p8` is a credential that can ship builds as your team. It lives outside the repo in `~/.appstoreconnect/private_keys/` (chmod 600), and `ios-signing.config` is gitignored. Back the `.p8` up next to the Android keystore — losing it is recoverable (revoke and regenerate), but it costs you the round trip. ## Android one-time setup Already done — the upload key exists and `release.sh` produces a signed AAB. ⚠️ **Back up `mobile/keystore/upload-keystore.jks` and `mobile/keystore.properties` off this Mac** (password manager / encrypted drive). Play identifies every update by that key. Both files are gitignored and excluded from the deploy tarball. Remaining: **Play Console** → Create app → bundle ID `org.waterschurch.checklist` → set up an internal testing track → upload the AAB → add testers by email. ### Android uploads (Play Developer API) With a service-account key present, `release.sh` uploads the `.aab` and releases it to the internal track by itself. Without one it just leaves the file in `dist-release/`. **Play requires the very first bundle of a new app to go through the Console UI by hand.** The API can only take over from the second release onward — so do one manual upload first, then this works forever after. 1. **Play Console** → *Users and permissions* → note you'll come back here in step 5. 2. [console.cloud.google.com](https://console.cloud.google.com) → create (or pick) a project → **APIs & Services** → enable **Google Play Android Developer API**. 3. **IAM & Admin → Service Accounts → Create**. Name it `play-release`. No Cloud roles are needed — permission comes from Play, not from Cloud IAM. 4. On that service account → **Keys → Add key → Create new key → JSON**. Save it as: ``` mobile/play-service-account.json ``` 5. Back in **Play Console → Users and permissions → Invite user**, paste the service account's `client_email` (ends `.iam.gserviceaccount.com`). Grant it, for this app: *Release to testing tracks* and *View app information*. Nothing more. 6. Confirm it works — opens an edit and abandons it, changing nothing: ```bash cd mobile && node scripts/upload-play.js --dry-run ``` Pick a different track with `PLAY_TRACK=alpha ./release.sh`. Default is `internal`. The JSON key can ship releases as you — it's gitignored, and belongs in your password manager alongside the upload keystore. --- ## Useful commands ```bash . mobile/scripts/env.sh # required before any Android command cd mobile npm run sync:web # rebuild www/ from ../server/public npm run sync # …and push it into both native projects npm run open:ios # open in Xcode npm run open:android # open in Android Studio (if installed) npm run assets # regenerate icons/splash from assets/*.png cd mobile/android && ./gradlew :app:assembleDebug # debug APK, for a device or emulator ``` **If you regenerate assets**, re-apply the fix in `android/app/src/main/res/mipmap-anydpi-v26/ic_launcher*.xml` — `@capacitor/assets` insets both adaptive-icon layers by 16.7%, which stops the gradient background reaching the launcher's mask. The files carry a comment explaining it.