MMashDiv

Build for iOS and TestFlight

Install the pods, set your signing team and bundle identifier from app_config.json, check the new Apply app_config.json build phase, add the push capability the project does not ship, then archive and upload to TestFlight.

10 min readUpdated 26 September 2026mobile, ios, xcode, cocoapods, signing, bundle-id, testflight, app-store

An iOS build needs a Mac, Xcode and CocoaPods, and it needs three things only you can supply: your Apple team, your bundle identifier, and, if you use push, the Push Notifications capability. The name under the icon takes care of itself: a build phase writes it from app_config.json on every build.

This page assumes the app already runs against your server. If it does not, start with Install and run the app. Every command below runs from the project folder unless it says otherwise.

Before you start

  • A Mac with a current Xcode, and CocoaPods
  • Membership of the Apple Developer Program as an organisation (see Developer accounts)
  • Your Team ID, from the Membership details page of your Apple Developer account
  • Your bundle identifier chosen (see Branding), and an App ID registered for it
  • flutter pub get has run in the project folder

Install the pods

The package ships without ios/Pods/ and without ios/Podfile.lock, so the pods are resolved on your machine the first time.

flutter pub get
cd ios
pod install
cd ..

Order matters. The Podfile reads ios/Flutter/Generated.xcconfig, which flutter pub get writes. Run pod install first and it stops with:

.../Flutter/Generated.xcconfig must exist. If you're running pod install manually, make sure flutter pub get is executed first

From now on, open ios/Runner.xcworkspace in Xcode, not Runner.xcodeproj. Only the workspace includes the pods.

"The sandbox is not in sync with the Podfile.lock"

Xcode checks, at the start of every build, that the installed pods match Podfile.lock. When they do not, the [CP] Check Pods Manifest.lock phase stops the build with:

error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.

It happens when the lock and the installed pods have drifted apart: after you take a new release of the app, after a plugin was added or updated, or when a stale Podfile.lock was carried over from an older copy of the project. The package ships no Podfile.lock, so a lock in your tree is one your machine wrote, or one you copied in. The fix is always the same: flutter pub get, then pod install in ios/, then build again.

flutter run, flutter build ios and flutter build ipa run pod install themselves when the plugins have changed. Building or archiving from Xcode does not, which is why this message usually appears there.

The project's deployment target is iOS 13.0. The Podfile raises any pod still set below iOS 12.0 up to 12.0, because Xcode 15 and later no longer support older targets, and turns off "treat warnings as errors" for the pods.

Set your signing team

Xcode signs the app with a team, and it cannot sign or archive with a team you are not a member of.

The project in the package carries no team, so you set one before your first archive. Put your Apple Team ID in app_config.json as iosTeamId (exactly 10 characters, upper-case letters and digits), then write it into the Xcode project:

{
  "iosTeamId": "AB12CD34EF"
}
dart run tool/apply_app_config.dart

The tool writes it into every Runner build configuration (Debug, Release and Profile) as DEVELOPMENT_TEAM, and says so:

  iOS signing team       AB12CD34EF  (iosTeamId; written into ios/Runner.xcodeproj/project.pbxproj: 3 Runner configurations, was (none))

A value that is not a Team ID is refused, and nothing is written. If you would rather not keep the team in the config, leave iosTeamId out or empty (the example ships it as ""): nothing is written, and you choose your team in Xcode. Select the Runner target, open Signing & Capabilities, and pick it under Team.

Until one of the two is done, the release preflight reports [ios-team] the Xcode project names no Apple Development Team for the Runner Release configuration. If you set iosTeamId and later pick a different team in Xcode, dart run tool/apply_app_config.dart --check exits with an error and the preflight reports [ios-team-mismatch].

Copies of the project from releases up to 5.3.9 carry the vendor's own team, 52K7QMD7KR, in the project file. If Xcode reports a team you do not recognise, that is it: replace it with yours by either route above. The preflight reports it as [ios-team] until you do.

Set your bundle identifier

Apple knows your app by its bundle identifier. It is a build setting that signing and provisioning read before any script runs, so the build cannot change it on the fly. It is written into the Xcode project, once, by a tool.

  1. Set iosBundleId in assets/config/app_config.json. Letters, digits and hyphens, in two or more dot-separated parts. No underscores.

    {
      "iosBundleId": "com.myexchange.app"
    }
  2. Write it into the Xcode project. Run this again every time you change the ID:

    dart run tool/apply_app_config.dart
  3. Register an App ID with exactly the same ID in your Apple Developer account, under Certificates, Identifiers & Profiles.

  4. Check Xcode agrees. Open ios/Runner.xcworkspace, select the Runner target, open Signing & Capabilities, and confirm the Bundle Identifier shown there is yours.

A few rules the tool enforces, so that a mistake stops here rather than in App Store Connect:

  • The example file ships "iosBundleId": "". Empty or absent means "keep whatever the Xcode project has, and do not check it", so a new release never moves an iOS app you already publish. If you prefer to set the ID in Xcode, leave it empty.
  • The template's own ID is com.bicryptto.mobile (spelt with two t's). The tool will not write it over an ID of yours that the project already has; --allow-template-id overrides that, if you truly want it back.
  • If your project uses different IDs per build configuration (a .dev suffix on Debug, say), the tool refuses to merge them into one. Leave iosBundleId empty and manage those IDs in Xcode.
  • It does not change an app extension's ID, such as a Notification Service extension you added. It tells you when one still starts with the old ID; change it in Xcode.

To check without writing anything, run dart run tool/apply_app_config.dart --check. It exits with an error when the Xcode project disagrees with iosBundleId or iosTeamId.

Changing the bundle identifier after release makes a different App Store app. It must match the App ID you registered and the provisioning profile you sign with, and a Firebase GoogleService-Info.plist must be issued for the same ID.

The "Apply app_config.json" build phase

The Runner target has a build phase called Apply app_config.json, listed after Thin Binary. On every iOS build (flutter run, flutter build ios, flutter build ipa or an Archive in Xcode) it runs:

"$FLUTTER_ROOT/bin/dart" "$SRCROOT/../tool/ios_app_identity.dart"

It does two things:

  • Writes appName into the built app as CFBundleDisplayName (the name under the icon) and CFBundleName. It edits the copy of Info.plist inside the built app, before Xcode signs it. The source ios/Runner/Info.plist still says BiCrypto, and should: that is the default, and the phase overwrites it in the built copy only. Leave the source file alone.
  • Checks the bundle identifier. If iosBundleId is set and the build is signing a different one, the build stops rather than producing an app under an ID you did not configure.

Because the phase runs the file in tool/, every iOS build needs that folder in the project. Keep it when you copy or update the project.

What to look for in the build log

The phase is new: releases up to 5.3.9 do not have it, and they put BiCrypto under the icon whatever appName said. Until you have seen it work on your own Mac, check its output after your first build. In Xcode, open the Report navigator, select the build, and find the step for Apply app_config.json. From the command line, add --verbose to flutter build ios or flutter build ipa to see the same output. A good run prints one line:

app_config.json: iOS home-screen name "My Exchange" applied; bundle id com.myexchange.app matches

With iosBundleId left empty, the second half reads bundle id not checked (iosBundleId not set). Then check the name under the icon on a device or simulator.

If there is no such step in the log and the icon still says BiCrypto, the phase is missing from your Xcode project, for example because the project came from an older release. dart run tool/apply_app_config.dart --check says so in a warning (the Runner target ... has no "Apply app_config.json" build phase). Take ios/Runner.xcodeproj from the current package, then set your team and bundle identifier again as above, and add the Push Notifications capability again if you use push.

A bundle identifier that disagrees stops the build with a message that offers both ways out, because the phase cannot know which side is right:

error: iosBundleId in assets/config/app_config.json is "com.myexchange.app", but this build (Release) signs Runner as "com.bicryptto.mobile" (PRODUCT_BUNDLE_IDENTIFIER). ...

If the config is right, run dart run tool/apply_app_config.dart. If the project is right (you changed the ID in Xcode), put that ID in iosBundleId, or empty it.

If app_config.json is missing or empty, the phase does not stop. It prints a warning, which Xcode lists with the build's issues, and the build carries the template's values:

warning: assets/config/app_config.json is missing or empty, so this build uses the template assets/config/app_config.example.json. ...

You can preview what the phase will do on any machine, Windows and Linux included, without Xcode and without changing anything:

dart tool/ios_app_identity.dart --dry-run

Push notifications need a capability the project does not ship

The project has no entitlements file, so it has no push entitlement. Without one, the app still asks for notification permission, the customer still agrees, the build still archives, and no notification is ever delivered. The app does not report it.

If you use push on iOS:

  1. Add the capability. In Xcode, select the Runner target, open Signing & Capabilities, press + Capability and add Push Notifications. Xcode creates Runner.entitlements and points the build at it.

  2. Add your Firebase file. Download GoogleService-Info.plist from a Firebase iOS app registered with your bundle identifier, put it in ios/Runner/, and add it to the Runner target in Xcode.

  3. Upload an APNs key to your Firebase project, in its Cloud Messaging settings. iOS delivers every push through Apple's service, whatever Firebase is doing.

  4. Configure the server half. See Push notifications.

Once GoogleService-Info.plist exists, the preflight refuses a project without the entitlement: iOS push cannot work: Firebase is configured but the app has no aps-environment entitlement. The capability lives in the Xcode project, so taking a new release's ios/Runner.xcodeproj removes it. Add it again after every update.

Google sign-in on iOS also needs entries in Info.plist that the project does not ship. See Sign-in methods.

What the project already declares

Declaration Value What it means for you
ITSAppUsesNonExemptEncryption in Info.plist false App Store Connect does not ask the export-compliance question on every upload. The value declares that the app uses no non-exempt encryption; its network traffic uses standard HTTPS and TLS. If you add encryption of your own, revisit the answer.
NSCameraUsageDescription Take a photo of your ID document to complete identity verification, or capture evidence for a trade dispute. The reason iOS shows when the app asks for the camera.
NSPhotoLibraryUsageDescription Attach an existing photo of your ID document for identity verification, or evidence for a trade dispute. The reason iOS shows when the app asks for the photo library.
ios/Runner/PrivacyInfo.xcprivacy The privacy manifest Must stay in the Runner target's resources. See Data safety and privacy.
Device family iPhone and iPad App Store Connect treats it as an iPad app too, and asks for iPad screenshots. Only the futures trading screen has a wide layout; every other screen is the phone layout at tablet width.

Version and build number

iOS takes both numbers from the same version: line in pubspec.yaml as Android. 5.3.9+10 becomes version 5.3.9 (CFBundleShortVersionString) and build 10 (CFBundleVersion). You can override them with --build-name and --build-number on flutter build ipa.

App Store Connect refuses a build number it has already received for that version. Raise the build number for every upload. A new source release from us arrives with our version: line, so check it against your last upload before you build; see Updating your app.

Archive and upload

Run the preflight first and fix what it prints:

dart run tool/release_preflight.dart --explain

Then archive by either route.

flutter build ipa --release
# Archive: build/ios/archive/Runner.xcarchive
# Upload-ready file: build/ios/ipa/*.ipa
1. flutter build ios --release     (compiles the Dart code and applies the pubspec version)
2. Open ios/Runner.xcworkspace
3. Choose the Runner scheme and "Any iOS Device (arm64)" as the destination
4. Product > Archive
5. In the Organizer, select the archive, press Distribute App,
   and choose the App Store Connect upload

flutter build ipa ends with a summary read from the archived app. Its Display Name should be your appName and its Bundle Identifier your bundle identifier. Upload its .ipa with Apple's Transporter app, or open the .xcarchive in Xcode's Organizer and distribute from there.

The project ships no ExportOptions.plist, no fastlane setup and no CI configuration. The two routes above are the whole process.

The Merchant, AI Investment and Affiliate modules are left out of a normal build. To include them, add --dart-define=STORE_RESTRICTED_MODULES=true to flutter build ipa, and read Restricted modules first: it is a store decision as much as a technical one.

TestFlight

After an upload, App Store Connect processes the build before it can be tested; it then appears under TestFlight for that app. Internal testers (people on your App Store Connect team) can install it from there. External testers need the build to pass Beta App Review first.

Test on a real iPhone before you submit:

  • The name under the icon is your appName
  • Sign-in works with a real account on your server
  • If you use push: after the first sign-in the app's Stay informed sheet appears, Continue leads to the iPhone's permission prompt, and a test notification arrives
  • Profile (the gear icon at the top right of Home) → Open-source Licenses shows the version and build you uploaded

The reviewer will need a working login, because every screen is behind sign-in. See App review access.

More build and run-time failures are on Troubleshooting.