Skip to content

Android + iOS build & test — the release front door

One page, run every time. This is the operational checklist for the standing rule: every feature is built and tested on Android native and iOS native (Web PWA too, where applicable) before it is called done. If you only ever open one page for a build, it's this one — the deeper "why" and one-time machine setup live in the linked guides, not repeated here.

Why this exists — the standing rule

CLAUDE.md's Definition of Done requires "cross-platform parity verified (Android native · iOS native · Web PWA) for every applicable feature, or a pre-approved documented exception." Since the 2026-07-26 ADR-0011 amendment, iOS is native in R1, not a PWA stand-in — so "generate the APK" now always implies "and the matching iOS build, from the same commit, tested in parallel." That is what this page operationalizes.

The two-machine topology

There is no single machine that builds both — Android and iOS builds happen on two different computers, from the same commit, at the same time.

Windows dev boxMac mini (M4)
BuildsAndroid (APK, sideloaded via adb)iOS (built directly onto the attached iPhone)
Why that machineExisting dev environment, Android SDK/emulator already set upOnly Apple hardware can build/sign for iOS — no way around this
One-time setupWindows Build EnvironmentmacOS · iOS Build Environment
Deep build walkthrough(this page + the mobile README)iOS Build & Device Testing

A real asymmetry to know about, not a bug: Android produces a portable artifact (an .apk file) you can install on any authorized device via adb install. iOS, on the free Personal Team signing path this project uses (no $99/yr Apple Developer Program membership — see why), has no portable artifact: expo run:ios --device builds and installs in one step, and only onto whatever iPhone is plugged into the Mac mini at that moment. There is no "build once, install on any iPhone later" for iOS until the paid membership is added for distribution (TestFlight/ad-hoc). Practically: the iPhone must be connected to the Mac mini by cable during the iOS build step.

Prerequisites (one-time per machine — verify, don't skip)

Do these once; they should already be done if you followed the setup guides.

Windows dev box:

powershell
node -v            # 22.x — .nvmrc
adb devices         # your Android phone listed as "device" (not "unauthorized")

If the phone shows "unauthorized," check its screen for the USB-debugging trust prompt. Full environment (Android SDK, ANDROID_HOME, repo-relocation, Metro, Maestro): Windows Build Environment.

Mac mini:

bash
node -v                  # 22.x — .nvmrc
xcode-select -p          # /Applications/Xcode.app/Contents/Developer, NOT .../CommandLineTools
xcodebuild -version      # 16.1+ (RN 0.86's floor)
pod --version
gh auth status           # logged in — the repo is private

Full environment (Xcode platform selection, nvm/Homebrew bootstrap, GitHub auth, free-signing constraints): macOS · iOS Build Environment.

xcode-select -p drifts after any Command Line Tools install

Installing Homebrew (or anything that pulls the CLT) can silently repoint xcode-select away from Xcode.app, breaking pod install/expo prebuild with errors that look unrelated. Re-check it before every session if you've installed anything Xcode-adjacent recently: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer.

The repeatable loop — every feature, every release

Run this top-to-bottom. Steps A–C run on the Windows box; D–F on the Mac mini; they can genuinely run in parallel since the two machines don't depend on each other mid-build.

A · Pull the latest code (both machines)

bash
git fetch origin
git checkout <branch>       # the branch under test — e.g. feat/mobile-nativewind-b2
git pull
npm install                 # only if package-lock.json changed

Run this identically on both machines — same branch, same commit — before building either side. If one machine is behind, you are not testing parity, you're testing two different versions.

B · Generate the Android build (Windows box)

bash
cd apps/mobile
npm run build:android          # guarded release build — arm64 APK
# npm run build:android:clean  # add --clean if a previous build left stale native artifacts

This is scripts/build-android.mjs — the only sanctioned local build path (it forces build scratch off the system drive, caps parallelism, and verifies the output APK is actually fresh; see Windows Build Environment § Guarded build script for why a raw gradlew invocation is not used directly). Output:

apps/mobile/android/app/build/outputs/apk/release/app-release.apk

C · Install + launch on the Android device

bash
adb devices                       # confirm the phone is listed
adb install -r app-release.apk    # -r = reinstall over an existing copy, keeps app data

Run from apps/mobile/android/app/build/outputs/apk/release/, or give adb install the full path. This is a standalone release build — no Metro, no dev server, no adb reverse needed; it launches like any installed app.

D · Generate the iOS build (Mac mini, from the same commit)

bash
cd apps/mobile
npx expo prebuild -p ios       # regenerates ios/ from app.json — safe to re-run, ios/ is gitignored

Sanity-check the deployment target it generated before the first compile — this is the thing standing between you and an iPhone that's too old:

bash
grep "platform :ios" ios/Podfile
grep -m1 IPHONEOS_DEPLOYMENT_TARGET ios/*.xcodeproj/project.pbxproj

Both must read 16.4 (RN 0.86 + expo-router's actual floor, not the 15.1 React Native alone would suggest — see iOS Build & Device Testing for why). If either is lower, set ios.deploymentTarget in app.json and re-run prebuild.

E · Install + launch on the iPhone (Mac mini, iPhone connected by cable)

bash
npx expo run:ios --device --configuration Release   # parity testing — use THIS
# npx expo run:ios --device                         # debug: for iterating only, see below

Match the build type or you are not testing parity

Step B builds a release APK for Android. So the iOS side must be --configuration Release too. A default expo run:ios is a debug build: its JS is not embedded (it is fetched from Metro on every cold start, so the app needs the Mac running and the same Wi-Fi, and re-bundles each launch), dev warnings are on, and JS + reanimated run unoptimized. Comparing a release APK against a debug iOS build and attributing the difference to "the platform" is the easiest way to log a parity bug that does not exist — or to miss a real one.

Release also behaves like a normally-installed app: unplug the cable, no Mac, no Wi-Fi dependency. The cable is only needed during the install itself.

First launch after a fresh install shows "Untrusted Developer" — trust it once: Settings → General → VPN & Device Management → Apple Development: <your Apple ID> → Trust. The phone needs internet for this (iOS verifies the certificate with Apple). Then launch from the home screen.

The 7-day free-signing expiry

Free Personal Team provisioning profiles expire after 7 days; the app then refuses to launch. This is Apple's limit, not a bug. Fix: re-run the install.

For iterating on JS/TS changes, use the debug build: npx expo start --dev-client (press i) hot-reloads onto whatever was last installed. Re-run expo run:ios --device only when native code, a dependency, or app.json changes — and re-install Release before signing off on parity.

Aside · Managing Metro (the dev server)

Only debug builds need Metro; a Release install does not (the JS is embedded — stop Metro, close the tab, unplug the cable, and the app still launches).

  • Stop it with Ctrl + C. Press twice if the first is swallowed by Expo's interactive prompt.
  • Never Ctrl + Z. That suspends it: you get your prompt back while Metro stays alive holding port 8081, so the next expo start silently picks a different port while the app keeps looking on 8081 and finds nothing. Recover with fg then Ctrl+C, or kill %1.
  • q does nothing — Expo's menu is single letters (r reload, s switch build type, i iOS, a Android).
  • Prefer a second terminal tab (Cmd+T / a new PowerShell tab) and leave Metro running, rather than stopping and restarting it around every git command.

F · Run the parity check, both devices side by side

With the same feature live on both phones, work through Parity Verification — theme (light + dark), the platform-specific press feedback (Android ripple vs iOS haptic — this is the one approved difference, not a bug), safe areas on the iPhone (notch + home indicator), and any divergence seam the feature touches (camera, push, storage, deep links).

Aside · Before testing anything TIME-SENSITIVE on Android (QRS-237)

Android will make a correctly scheduled alert arrive late, or not at all, for reasons that have nothing to do with our code. Check these before filing a "notifications don't work" report, because all three are invisible from inside the app and each one alone is enough to produce total silence.

CheckWhereWhy it matters
Notification permission grantedSettings › Apps › QR setu › NotificationsOn API 33+ POST_NOTIFICATIONS is runtime-gated and defaults to NOT granted. The app shows a banner for this state, but only on the Reminders screen
Alarms & reminders allowedSettings › Apps › QR setu › Alarms & remindersAndroid 14+ only. SCHEDULE_EXACT_ALARM is pre-granted on Android 12/13 but not on 14+, and without it expo-notifications takes the deferrable setAndAllowWhileIdle branch, so Doze can delay a 15:00 reminder by minutes to hours
Battery optimisation disabled for the appSettings › Apps › QR setu › Battery › UnrestrictedXiaomi, Oppo, Vivo, Realme and Samsung suspend background alarms aggressively, independently of the two above. This is why "it works on a Pixel" proves very little

Record the device's Android version in the result. It decides which of the rows above even apply: none of the exact-alarm behaviour exists below Android 12, and the settings trip only exists on 14+. A notification report without an OS version cannot be diagnosed, only guessed at.

We do not prompt for battery optimisation in-app on purpose. REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is itself a Play-policy-restricted permission, so this stays a device-setup step rather than becoming a store-review risk.

G · Record the result

Per the Definition of Done, parity status is not optional documentation: note in the feature's README/implementation note which surfaces were verified, and log any gap as a QRS-### with a prior approval — never as an after-the-fact discovery. See Parity Verification § The checklist, item 6.

Quick command cheat-sheet

Copy-paste block for a fresh pull-build-install-test pass, split by machine.

Windows dev box:

bash
git fetch origin && git checkout <branch> && git pull && npm install
cd apps/mobile
npm run build:android
adb install -r android/app/build/outputs/apk/release/app-release.apk

Mac mini:

bash
git fetch origin && git checkout <branch> && git pull && npm install
cd apps/mobile
npx expo prebuild -p ios
npx expo run:ios --device --configuration Release   # Release, to match the Android release APK

Troubleshooting

Full troubleshooting tables live in each platform's deep guide — this is only the fastest fixes for the two most common stalls:

SymptomFix
Android build OOMs / hangsYou bypassed the guarded script. Use npm run build:android, never raw gradlew. See Windows § Memory/commit limit.
iOS: "Untrusted Developer"Expected on first install — trust it in Settings (Step E above).
iOS red box: "No script URL provided" (unsanitizedScriptURLString = (null))A debug build has no embedded JS bundle — it needs Metro. Run npx expo start from apps/mobile and leave it running, then reload in the Simulator (Cmd+D → Reload). Check lsof -i :8081 for a port squatter: if Metro silently picked another port, the app still looks on 8081 and finds nothing. If Metro's banner says "Using Expo Go", press s to switch to development build — otherwise i/the QR target Expo Go, not your installed app.
iOS red box: "Expo Head: Add the handoff origin to the Expo Config"A web-only expo-router/head import leaked onto a native path. expo-router/head is not a web shim — on iOS it is ExpoHead.ios.js (Handoff/Spotlight indexing) and throws unless an origin URL is set in the expo-router plugin. Do not add the origin to silence it (Spotlight indexing is an undecided product concern) — use @/ui's DocumentTitle, which is platform-split so native never imports the module.
iOS app stopped launching after ~a weekThe free 7-day signing expiry. Re-run expo run:ios --device.
iOS pod install/prebuild errors that make no senseCheck xcode-select -p — a CLT install likely repointed it away from Xcode.app.
iOS Release build fails with sentry-cli - error: An organization ID or slug is required (xcodebuild exit 65)The Sentry source-map upload phase runs even with no Sentry org provisioned, and Release (unlike debug) treats its failure as fatal. Already fixed in app.json via disableAutoUpload: true — if you hit it, your ios/ predates that change: re-run npx expo prebuild -p ios. One-off escape hatch: prefix the command with SENTRY_DISABLE_AUTO_UPLOAD=true. See Sentry § source-map upload.
adb devices shows nothing / "unauthorized"USB debugging not enabled, or the trust prompt wasn't accepted on the phone screen.
Two builds don't actually matchSteps A weren't run identically on both machines — re-confirm git log -1 matches on both before re-testing.

Deeper references: Windows Build Environment · macOS · iOS Build Environment · iOS Build & Device Testing · Parity Verification.