> ## Documentation Index
> Fetch the complete documentation index at: https://docs.soundchecklive.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Mobile Deployment

> Building and deploying the Soundcheck mobile app

This guide covers building and deploying the Soundcheck mobile app to the App Store and Google Play Store.

## Prerequisites

### Accounts required

| Service | Purpose | Requirement |
| - | - | - |
| Apple Developer | iOS deployment | \$99/year |
| Google Play Console | Android deployment | \$25 one-time |
| Expo | Build and OTA updates | Free tier available |

### Tools

```bash theme={null}
# Install EAS CLI
npm install -g @expo/eas-cli

# Login to Expo
eas login
```

***

## EAS build configuration

The app uses **EAS Build** for creating production binaries. Configuration is in `eas.json`:

### Build profiles

| Profile | Purpose | Auto-submit |
| - | - | - |
| `development` | Development builds with debugging | No |
| `preview` | Internal testing builds | No |
| `qa` | Store-distribution builds for TestFlight / Play internal; isolated QA OTA channel | iOS automatically on QA push; Android manual |
| `production` | App Store/Play Store builds from `main` | No; explicit manual submission |

### Version management

* **Version string** (e.g., "1.1.92"): Managed by EAS remotely (`appVersionSource: "remote"` in `eas.json`); the current value also lives in `app.config.js`.
  * CI auto-updates the patch version when iOS submission would fail (Apple requires unique versions). Both platforms are updated together to stay in sync.
  * Manual updates: `eas build:version:set --platform all --version X.Y.Z` or update `app.config.js` (EAS syncs). Do this only for a major or minor release where you want a specific number.
* **Build numbers** (`buildNumber` iOS / `versionCode` Android): Auto-incremented by EAS Build
* **Runtime version**: Fingerprint policy in `app.config.js` (`runtimeVersion.policy: 'fingerprint'`) — OTA updates only apply to binaries with a matching native fingerprint. `fingerprint.config.js` leaves the `version` string, the commit hash and `package.json` scripts out of it, so a version bump or a script edit alone does not need a new binary; native code, plugins, permissions and other config do

> [!NOTE]
>
> EAS manages build numbers independently per platform. Never manually update `buildNumber` or `versionCode`.

### Native project generation

The `ios/` and `android/` directories are **gitignored** and generated from `app.config.js` via Expo prebuild. EAS Build runs this in CI; for local native work generate them manually:

```bash theme={null}
npx expo prebuild --platform ios --clean       # iOS (macOS + Xcode)
npx expo prebuild --platform android --clean    # Android
npx expo prebuild --clean                       # both
```

* `--clean` removes existing native folders before regenerating.
* All native config (entitlements, permissions, plugins) comes from `app.config.js`.
* Never commit `ios/` or `android/` — they are ephemeral build artifacts.

### iPad and large-screen native config

These keys in `app.config.js` decide how the binary behaves on iPad. `lib/__tests__/appConfigLargeScreens.test.ts` holds the generated values. Editing these settings changes the native fingerprint even when the generated `Info.plist` stays identical (spelling out a default, for example), because the config itself is hashed apart from the fields `fingerprint.config.js` strips. So leave the two unset keys unset.

| Key | Value | Why |
| - | - | - |
| `ios.supportsTablet` | `true` | One binary for iPhone and iPad (`TARGETED_DEVICE_FAMILY = "1,2"`). |
| `ios.requireFullScreen` | unset; Expo's default is `false` | Expo writes `UIRequiresFullScreen = false`, so the app takes part in Split View, Slide Over, Stage Manager and iPadOS 26 windowed apps. Apple deprecated opting out in iPadOS 26, and from the iOS 27 SDK the key no longer stops resizing ([TN3192](https://developer.apple.com/documentation/technotes/tn3192-migrating-your-app-from-the-deprecated-uirequiresfullscreen-key)). |
| `UISupportedInterfaceOrientations~ipad` | unset; Expo writes all four | Expo adds the list whenever `requireFullScreen` is false, because App Store validation rejects a multitasking iPad app that lacks any of the four (ITMS-90474). |
| `UISupportedInterfaceOrientations` | portrait and both landscapes | Phones. Upside-down portrait is ignored on iPhones without a Home button. |
| `orientation` | `'portrait'` | Android only: it writes `android:screenOrientation="portrait"` on the main activity. iOS ignores it, because `UISupportedInterfaceOrientations` is set by hand in `ios.infoPlist`. |
| `expo-screen-orientation` `initialOrientation` | `'PORTRAIT_UP'` | Writes `EXDefaultScreenOrientationMask`, the mask iOS uses from launch until JS calls `lockAsync` or `unlockAsync`. It is the same on iPhone and iPad. |
| `UIRequiredDeviceCapabilities` | `['armv7']` | The value every store build has shipped with. Apple lets an update keep or relax this list, never add to it ([UIRequiredDeviceCapabilities](https://developer.apple.com/documentation/bundleresources/information-property-list/uirequireddevicecapabilities)), so do not swap it for Expo's template default `arm64`. |
| `LSRequiresIPhoneOS` | `true` | Means "runs on iOS and iPadOS", not "iPhone only". It is the value in Expo's and Xcode's app templates. |

The launch screen is a 100 pt logo centred on a solid background (`SplashScreen.storyboard`, from the `expo-splash-screen` plugin), so it needs no per-device variant.

iOS builds use the `macos-tahoe-26.5-xcode-26.6` image, which is EAS's `sdk-57` image. EAS has a `macos-tahoe-26.6-xcode-27.0` image with no SDK alias, and its Xcode 27.1 image is the `sdk-58` one.

***

## Building the app

### Development build

For development with debugging capabilities:

```bash theme={null}
# iOS development build
eas build --platform ios --profile development

# Android development build
eas build --platform android --profile development
```

### Physical Android device on a different VLAN

If a connected Android device cannot reach the Expo dev server over LAN, start Metro through an Expo tunnel:

```bash theme={null}
EXPO_TUNNEL_SUBDOMAIN=soundcheck-mobile-8083 npx expo start --dev-client --clear --tunnel --port 8083
```

Then press `a` in the Expo terminal to open the development build on the device. Keep the tunnel subdomain to letters, numbers, and hyphens only; Android rejects underscores in TLS hostnames.

### Preview build

For internal testing and QA:

```bash theme={null}
# iOS preview build
eas build --platform ios --profile preview

# Android preview build
eas build --platform android --profile preview
```

### Production build

Promote the approved code through `qa` to `main`. Run the standard production build from `main` with submission disabled:

```bash theme={null}
gh workflow run ci-cd.yml --ref main \
	-f mode=build_submit \
	-f platform=all \
	-f submit=false \
	-f android_track=production
```

This creates separate production-channel binaries for iOS and Android. Record each finished build's exact ID and `gitCommitHash` before requesting Chris's approval to submit. A successful build does not upload either binary to a store.

### Submit an approved build

After Chris approves the exact build IDs and destination, submit each binary through `promote`. Replace the placeholders below with those approved IDs. For iOS, this uploads to App Store Connect and TestFlight. Public App Store release still requires a separate App Review and release decision.

```bash theme={null}
gh workflow run ci-cd.yml --ref main \
	-f mode=promote \
	-f platform=ios \
	-f build_id='<approved-ios-build-id>'

gh workflow run ci-cd.yml --ref main \
	-f mode=promote \
	-f platform=android \
	-f build_id='<approved-android-build-id>' \
	-f android_track=production
```

The `android_track=auto` default selects Play internal on `qa` and Play production on `main`. The example names production explicitly because the production submit profile uses `releaseStatus: completed`. Obtain Chris's approval for that public destination before submitting. For an approved internal test of a production-channel Android binary, explicitly select `android_track=internal`.

iOS uses the `qa` submit profile on `qa` and the `production` submit profile on `main`, independently of the Android track.

The submission wrapper validates the exact build ID, project, platform, finished status, store distribution, build profile, and embedded OTA channel. It also requires a full EAS `gitCommitHash` in the selected release source's history. An older approved build can pass. A build from unmerged feature code or a build without source provenance fails before submission.

QA binaries are for TestFlight and Play internal testing. Never select a QA-channel binary for public App Store release, including through App Store Connect. The workflow rejects QA Android submissions to the production track. Build a separate production-channel binary from `main` for either public store.

### EAS environment variables

`EXPO_PUBLIC_*` variables are embedded at **build time**. OTA updates cannot add a missing key after release.

For TestFlight and production builds, configure these in the [Expo dashboard](https://expo.dev) or via EAS CLI:

```bash theme={null}
npx eas env:create production \
  --name EXPO_PUBLIC_GOOGLE_MAPS_API_KEY \
  --value "AIza..." \
  --visibility sensitive \
  --type string \
  --scope project
```

Use a **mobile-dedicated** Google Cloud API key (not the web referrer-restricted key):

* Enable Places API, Places API (New), and Geocoding API
* iOS restriction: bundle ID `com.soundchecklive.app`
* Android restriction: package `com.soundchecklive.app` plus production signing SHA-1 from EAS credentials

CI runs `scripts/check-eas-maps-key.sh` before production builds to fail early when the key is missing.

***

## App store deployment

### Step 1: configure project

Update `app.json` with your details:

```json theme={null}
{
  "expo": {
    "owner": "your-expo-username",
    "extra": {
      "eas": {
        "projectId": "your-project-id"
      }
    }
  }
}
```

Update `eas.json` submit section:

```json theme={null}
{
  "submit": {
    "production": {
      "ios": {
        "appleId": "your-apple-id@email.com",
        "ascAppId": "your-app-store-connect-id",
        "appleTeamId": "your-apple-team-id"
      }
    }
  }
}
```

### Step 2: create App Store Connect app

**1. Go to App Store Connect**

Navigate to [appstoreconnect.apple.com](https://appstoreconnect.apple.com)

**2. Create New App**

Click **My Apps → + → New App**

**3. Fill in Details**

* **Platform**: iOS
* **Name**: Soundcheck
* **Primary Language**: English
* **Bundle ID**: com.soundchecklive.app
* **SKU**: soundcheck-mobile

### Step 3: build and submit

Use the [production build](#production-build) workflow with `submit=false`. After Chris approves the exact iOS build ID, use [Submit an approved build](#submit-an-approved-build). EAS Submit uploads the binary to TestFlight. It does not approve or release the app publicly.

### Step 4: complete App Store Connect

1. **App Information**
   * Add app description
   * Set category to "Music"
   * Add keywords

2. **Pricing and Availability**
   * Set to Free
   * Select countries

3. **App Privacy**
   * Answer privacy questionnaire
   * Add privacy policy URL

4. **Screenshots**
   * Upload for 5.5", 6.5", and iPad

5. **Submit for Review**
   * Click "Add for Review"
   * Wait 1-7 days for review

***

## Email links into the mobile app

ENG-1137 associates `https://app.soundchecklive.io` with the mobile app for `/engagements`, `/gigs/:id`, and `/offers`. Deploy the web association routes before distributing the new native build. An OTA update alone cannot add the iOS entitlement or Android verified intent filters.

* iOS: `/.well-known/apple-app-site-association` serves the repository's Apple team `6W5GT42YBU` and bundle `com.soundchecklive.app`.
* Android: set `MOBILE_ANDROID_SHA256_CERT_FINGERPRINTS` on the web deployment to the SHA-256 app-signing certificate from the team's Play Console **App integrity → App signing** page. This is distinct from the EAS upload key and the Google OAuth SHA-1. Comma-separated fingerprints support signing-key rotation. Missing or malformed configuration returns an uncached 503 instead of advertising an unverified certificate.
* Both endpoints must return JSON over HTTPS without authentication or redirects. Native config restricts associations to the app domain; marketing links and Clerk OAuth callbacks keep their existing behavior.
* iOS also claims `dev.app.soundchecklive.io` so links can be rehearsed against dev. Every build carries that entitlement, but only a build on the development EAS environment can open dev data: store and qa builds use the production API, so a dev link opens the app and then finds nothing.
* Paths in `lib/navigation/androidOnlyAppLinkPaths.json` are on Android filters but not in the production AASA yet. Don't move them: both lists feed `app.config.js`, so editing either one changes the native fingerprint. To release them on iOS, add them to the AASA route in `web/`.
* Signed-out destinations are stored for 30 minutes, validated again, and consumed once after sign-in. Accept/available apply only to a matching actionable engagement; negative responses require confirmation, and multi-day responses open the existing day picker. Failed responses remain retryable in Mail.

Before release, test a new signed iOS installation and a Play-signed Android build from a real email, both cold and already open. Check signed-in and signed-out gig links, accept/available, decline/unavailable cancellation, multi-day choices, already-answered links, and a link belonging to another account. Unit tests, bundle export, and Expo config introspection do not certify OS association or real email delivery. The Android signing fingerprint and signed-device checks remain release requirements; adding these routes does not publish a mobile build.

Official setup: [Expo iOS Universal Links](https://docs.expo.dev/linking/ios-universal-links/), [Expo Android App Links](https://docs.expo.dev/linking/android-app-links/), and [Expo Router native intent](https://docs.expo.dev/router/advanced/native-intent/).

## Google Play deployment

### First-time setup

Before publishing to Play Store:

**1. Create App in Play Console**

Create the app with package name `com.soundchecklive.app`

**2. Complete Store Listing**

* Name, descriptions
* App category
* Contact email/website
* Privacy policy URL

**3. Complete App Content**

* Data Safety declarations
* Target audience
* Content rating questionnaire

**4. Add Graphics**

* 512x512 icon
* 1024x500 feature graphic
* Phone screenshots

**5. Set Up Play App Signing**

EAS manages the keystore, but Play Console needs the first upload to configure signing.

### Build and submit

Use the [production build](#production-build) workflow with `submit=false`. After Chris approves the exact Android build ID and Play track, use [Submit an approved build](#submit-an-approved-build). The Play service account credential is required.

***

## CI/CD pipeline

The project includes automated CI/CD via GitHub Actions:

### Workflows

| Trigger | Action |
| - | - |
| Push to `qa` | Build and submit a fresh iOS binary to TestFlight; build Android only when needed |
| Push to `main` | Publish production OTA for both platforms when compatible; otherwise warn that a manual native build is required |
| Push to `dev` | No automatic mobile build or release |
| Manual dispatch | Custom build/submit workflows |

### Smart builds

The CI pipeline uses **fingerprint-based detection**:

* On QA pushes, always build iOS so JS-only changes reach TestFlight. Android and manual smart builds skip compatible native builds
* On main pushes, publish a production OTA only when both platforms have compatible production binaries. Otherwise, skip publication and show a warning with instructions to run `build_submit` manually
* A manual `smart_build` with no native change now builds nothing, even after a version bump. To ship a store release, run `build_submit` or set `force_build`

The Git source determines the build profile and OTA channel:

| Git source | EAS build profile / channel | EAS environment | Distribution |
| - | - | - | - |
| `qa` | `qa` | `production` | TestFlight / Play internal |
| `main` | `production` | `production` | Production binary / OTA release |

QA intentionally keeps production backend/auth variables. Its `distribution: store` profile creates real device binaries suitable for TestFlight, unlike the simulator-only `preview` iOS profile.

Mobile workflow runs share one concurrency group because QA and production use the same EAS version counters. `queue: max` retains up to 100 pending runs, so a later QA push cannot replace an already queued manual release. If the queue is full, GitHub cancels newly arriving runs; existing queued runs remain. Check the Actions queue before dispatching a release during a backlog.

QA pushes automatically submit the newly built iOS binary to TestFlight. Main pushes automatically publish production OTAs for both platforms after compatibility checks. Android submissions and production native builds/submissions remain manual (`submit=true` in build modes, or `promote` with an exact build ID). Manual `ota_update` remains available from `qa` for testers or `main` for production. The source/channel guard rejects production OTAs from `qa` and rejects QA Android binaries targeting the public track.

Native changes on `main` never trigger an automatic build. When a platform has no compatible production binary, the OTA job succeeds with a warning and a **Native build required** summary. It publishes nothing. Run the standard `build_submit` dispatch with `submit=false`, then follow the approved submission path. Fingerprint generation failures, invalid EAS responses, and network errors still fail the job.

Manual OTA dispatch accepts `platform=all`, `platform=ios`, or `platform=android`. The job validates that choice before contacting EAS and uses the validated value for both compatibility checks and publication.

A QA-channel binary must not be promoted to the public App Store: its embedded channel remains `qa`. Build a separate production-channel binary from `main`, then submit it explicitly. iOS submission uploads to App Store Connect/TestFlight; public release requires App Review separately.

### Bootstrap production OTA delivery

Confirm in EAS that the `production` channel exists and points to the `production` update branch. Keep the `qa` channel on the `qa` update branch. The build profile selects an embedded channel, but it does not verify the remote channel-to-branch mapping.

The first production binaries containing `expo-updates` must come from `main` with the `production` build profile and channel. Build them with `submit=false`, then obtain Chris's approval for the exact IDs and destinations before submission. Existing binaries without `expo-updates` cannot receive the update that adds it.

Until compatible production binaries exist for both platforms, automatic production OTA publication skips with the manual-build warning. Do not use a QA binary to satisfy this requirement.

Production bootstrap and device acceptance remain pending until the release record includes the exact build IDs and commits, Chris's submission approval, and signed-device evidence. Verify both platforms with a compatible JavaScript update, the build's embedded channel and runtime fingerprint, and a cold launch that applies the update. Also verify that a QA installation cannot receive a production-channel update.

### Auto-version updates

CI automatically updates the patch version when iOS submission would fail due to duplicate version:

```
1.1.0 → 1.1.1 (if 1.1.0 already submitted)
```

***

## OTA updates

EAS Update delivers JavaScript-only changes without a new app store submission. The client needs:

1. The `expo-updates` dependency (included in release binaries via prebuild)
2. `updates.url` pointing at the EAS project (`https://u.expo.dev/<projectId>`)
3. A matching `channel` on the EAS build profile (`qa` / `production`, plus local development/preview profiles in `eas.json`)
4. A `runtimeVersion` fingerprint that matches the installed binary

```bash theme={null}
# After approval, from main: publish to production using production EAS variables
eas update --channel production --environment production --message "Bug fixes"

# After approval, from qa: tester-only OTA, also using production backend/auth
eas update --channel qa --environment production --message "QA fixes"
```

### Staged rollout and rollback

A `main` merge publishes its OTA to every compatible production user. To stage one, publish it with an `ota_update` dispatch and a `rollout_percentage` below 100. EAS allows one open rollout per branch, platform and runtime, so another staged OTA can't start until that rollout is raised to 100% or reverted. Finish or revert it before the next `main` merge, so a full publish doesn't replace a half-checked update. Users outside a rollout keep the previous update.

Watch `ota_update_failed` and errors split by `update_id` in PostHog, and the `ota_updates` context in Sentry. Then run the `ci-cd.yml` dispatches from the same branch:

| Goal | Dispatch |
| - | - |
| Raise a rollout | `mode=ota_rollout`, `update_group=<group ID>`, `rollout_percentage=50` or `100` |
| Undo a partial rollout | `mode=ota_rollback`, `rollback_target=rollout`, `update_group=<group ID>` |
| Send users back to the store bundle after a full rollout | `mode=ota_rollback`, `rollback_target=embedded`, `platform=ios` or `android`, `runtime_version=<fingerprint>` |

iOS and Android have different fingerprints, so a publish makes one update group per platform. Run each dispatch once per platform, with group IDs from the publish log.

### When to use OTA vs new build

| Change Type | Deployment Method |
| - | - |
| Bug fixes (JS only) | OTA Update |
| New features (JS only) | OTA Update |
| Native dependency change | New Build |
| Expo SDK upgrade | New Build |
| New permissions | New Build |

> [!NOTE]
>
> Adding or configuring `expo-updates` itself is a native change. Ship a new production build before expecting devices to consume OTA updates.

***

## Build commands reference

### Development

```bash theme={null}
eas build --platform ios --profile development
eas build --platform android --profile development
```

### Preview

```bash theme={null}
eas build --platform ios --profile preview
eas build --platform android --profile preview
```

### Production

Use the [standard production build](#production-build) from `main` with `submit=false`.

### Submit

Use [Submit an approved build](#submit-an-approved-build) with each exact build ID after Chris's approval.

### Status

```bash theme={null}
eas build:list
```

***

## Troubleshooting

> [accordion] Build fails with code signing error
>
> Reset credentials:
>
> ```bash theme={null}
> eas credentials --platform ios
> ```

> [accordion] App Store submission rejected
>
> Check the rejection reason in App Store Connect and address the specific issue. Common reasons:
>
> * Missing privacy policy
> * Incomplete metadata
> * Guideline violations

> [accordion] OTA update not appearing
>
> Verify:
>
> 1. The installed binary was built after `expo-updates` and `updates.url` were added
> 2. Runtime fingerprint of the update matches the binary (`eas update:list` / build `runtimeVersion`)
> 3. Update channel matches the build profile channel (`qa` for QA TestFlight builds, `production` for production builds)
> 4. The app was fully quit and relaunched (default check is on cold start)

***

## Resources

* [EAS Build Documentation](https://docs.expo.dev/build/introduction/)
* [App Store Connect Help](https://help.apple.com/app-store-connect/)
* [Google Play Console Help](https://support.google.com/googleplay/android-developer)
* [Expo Updates Documentation](https://docs.expo.dev/eas-update/introduction/)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.