Files
recurring-checklist/mobile/README.md
T
Waters ChurchandClaude Opus 5 469db5bc60 Bring the checklist app under version control
The project has lived only as a folder on the Mac Studio and, briefly, as a
transfer tarball. This is the first commit: the Express server and its web
front end, the Capacitor iOS and Android wrappers, and the release and backup
scripts.

The repo is the source, not a backup of the machine. Left out on purpose, by
the .gitignore files that were already in the tree: node_modules, build output,
mobile/www (regenerated from server/public on every build), and server/data,
which holds the live checklists and belongs on the NAS rather than in git.

Every credential is excluded too — server/.env, the Play upload keystore and
its properties file, the App Store Connect key and ios-signing.config. The one
secret that was not in an ignored file was the NAS SSH password, hard-coded in
build-deploy.sh and grepped back out of it by backup-to-drive.sh. Both now read
it from the login keychain (service checklist-nas, account Christian), falling
back to NAS_PASS in the environment, so rotating it is one keychain entry
rather than a history rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:00:57 -04:00

9.9 KiB

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:

./release.sh

from the project root (not from mobile/). That's the whole workflow.


Everyday release

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 <img>/<a> 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:

. 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:

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. 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 → 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:
    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:
    ./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 → 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:
    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

. 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.