PG Prince Gupta

Flutter/ / 8 min read/ 1510 words

One Flutter codebase, two stores, zero surprises

Shipping the same app to the App Store and Google Play sounds like a solved problem until release day. Signing, permissions, privacy manifests and the small platform differences that cost the most time.

The short version

  • Flutter gives you one codebase, not one release process. The divergence is almost entirely in signing, permissions and store metadata.
  • Android ships an .aab app bundle; iOS ships an .ipa. Both need signing set up long before you need it.
  • Permissions are declarative on both platforms but reviewed very differently — iOS wants a human-readable reason string, Android wants a justification form.
  • Since 2024 iOS builds need a PrivacyInfo.xcprivacy manifest covering required-reason APIs and third-party SDKs.
  • Do a full dress rehearsal release to internal testing and TestFlight before the real one.

The pitch for Flutter is one codebase, two platforms. That pitch is true, and it is also slightly misleading. You do write the app once. You do not release it once.

Everything above the platform line — widgets, state, navigation, business logic, networking — is genuinely shared, and it stays shared. Everything below it — signing, permissions, store metadata, privacy disclosure, review — stays resolutely separate. On my first app I estimated the build well and the release not at all. This is the checklist I wish I had then.

What actually diverges

It helps to be precise about where the two platforms part company, because it is a much shorter list than people expect.

ConcerniOSAndroid
Artefact.ipa via Xcode archive.aab app bundle
SigningCertificate + provisioning profileUpload keystore + Play App Signing
Permission reasonUsage string in Info.plist, shown to the userManifest entry + Play Console declaration form
Privacy disclosureNutrition labels + PrivacyInfo.xcprivacyData safety form
Testing trackTestFlightInternal / closed / open testing
RolloutPhased release (7 days)Staged rollout (% of users)

Six rows. That is the whole divergence, and every one of them is a configuration task rather than a coding task — which is exactly why they get forgotten.

Signing: set it up on day one

Signing is the single most common reason a release slips, and it is entirely avoidable. Do it in week one, when nothing is urgent.

On Android, generate an upload keystore and never lose it. Put the credentials in android/key.properties, keep that file out of version control, and back up the keystore somewhere you will still have access to in three years.

keytool -genkey -v -keystore ~/upload-keystore.jks \
  -keyalg RSA -keysize 2048 -validity 10000 -alias upload

Then wire it into android/app/build.gradle so release builds are signed automatically. Enrol in Play App Signing so Google holds the actual app signing key — if your upload key is ever lost you can request a reset, which is not true of the legacy scheme.

On iOS, the shortest path is to let Xcode manage signing automatically with your team selected, then confirm that the bundle identifier in Xcode, in App Store Connect and in your Firebase configuration all match exactly. A mismatched bundle ID produces errors that look like everything except what they are.

Versioning and build numbers

Flutter drives both platforms from one line in pubspec.yaml:

version: 1.4.0+27
#        ^^^^^ ^^
#        name  build number

The name is what users see. The build number is what the stores use to tell uploads apart, and both stores reject a build number they have seen before — including one from a build you uploaded and then abandoned. Two rules keep this painless: never reuse a build number, and increment it on every upload, even a failed one. Automate it in CI if you can; a forgotten bump is a very annoying way to lose ten minutes.

Permissions read very differently

Both platforms want you to declare permissions. They judge you on completely different things.

iOS judges your sentence. Every sensitive API needs a usage string in Info.plist, and the user reads it in the permission dialog. “This app requires camera access” is a bad string: it explains nothing and reviewers push back on it. Say what the feature is.

<key>NSCameraUsageDescription</key>
<string>Take a photo of an item so buyers can see what
you are offering to trade.</string>

<key>NSPhotoLibraryUsageDescription</key>
<string>Choose existing photos to add to your listing.</string>

Android judges your justification. The manifest entry is mechanical, but sensitive permissions trigger a declaration form in the Play Console where you explain the core feature that needs them, sometimes with a demo video. Modern Android also splits things finely — Bluetooth alone needs BLUETOOTH_SCAN and BLUETOOTH_CONNECT on Android 12 and up, and foreground services must declare a type.

The practical rule: request permissions at the moment the feature needs them, never on launch. A permission dialog before the user understands the app gets denied, and a denied permission is much harder to recover than one you asked for at the right time.

The iOS privacy manifest

Since 2024, Apple expects a PrivacyInfo.xcprivacy file in iOS apps that use what it calls required-reason APIs — things as ordinary as file timestamps, disk space checks, system boot time and UserDefaults. It also covers data collection categories and tracking domains.

The part that catches Flutter developers is that this is not only about your code. Third-party SDKs ship their own manifests, and Apple aggregates them. So when you add a package, the honest question is no longer just “does it work” but “what does it declare”. Keep the dependency list short and you keep this easy; let it sprawl and you will spend a day auditing packages you barely use.

Producing the two artefacts

Once signing exists, the builds themselves are two commands.

# Android — app bundle for Play
flutter build appbundle --release

# iOS — archive for App Store Connect
flutter build ipa --release

Two things worth doing before either. Run flutter build apk --release --analyze-size to see what is actually in your binary; it regularly surfaces a forgotten asset folder or an oversized font. And test the release build on a real device, not just debug — release mode enables code shrinking and tree shaking, and the occasional reflection-dependent library only breaks there.

If you use R8 shrinking on Android and something disappears in release but not debug, that is your answer, and the fix is a keep rule.

Do a dress rehearsal

The single highest-value habit I have picked up: run the whole release once, for real, before it matters.

Push a build to Play internal testing and to TestFlight. Fill in every store field. Write the description, crop the screenshots, complete the Data safety form and the privacy labels, set the content rating, add the demo account. Then install from the store link on a device that has never seen the app.

You will find things. A screenshot at the wrong resolution. A missing privacy policy URL. A push notification that works in debug because the production APNs key was never uploaded. An app icon with an alpha channel, which iOS rejects outright. Every one of those is a five-minute fix on a quiet Tuesday and a crisis on launch day.

Then, when you do go live, use the gradual paths: staged rollout on Play, phased release on iOS. Ship to a small percentage, watch your crash reporting for a day, and expand. It costs nothing and it means a bad build reaches a handful of people instead of everyone.

One codebase is real. Zero surprises is achievable. It just happens in the configuration files and the console forms, not in the Dart — and if you have scoped for that, release day is quiet. For the rest of the pre-submission list, see what App Store rejections teach you about scope.

Quick answers

Can one Flutter codebase really ship to both app stores?

Yes. The Dart code, UI, state management and business logic are genuinely shared. What does not transfer is the release process: signing, store metadata, permission declarations, privacy disclosures and review. Budget for two release pipelines around one codebase.

What file format does Google Play require?

An Android App Bundle, the .aab file produced by flutter build appbundle. Google Play has required app bundles for new apps since August 2021. APKs are still useful for direct distribution and testing, but Play will not accept one for a new listing.

What is PrivacyInfo.xcprivacy and do I need it?

It is a privacy manifest Apple introduced in 2024. It declares the data your app collects, the required reasons for using certain sensitive APIs such as file timestamps or user defaults, and the tracking domains you contact. If your app or its SDKs touch those APIs, you need one.

Prince Gupta

Full-stack developer · Ludhiana, India

I build Flutter apps, web platforms, AI features and SEO growth end-to-end. Four apps live on the App Store and Play Store.

Have an idea? Let’s build it.

I reply within 24 hours with honest thoughts on scope, timeline and cost — no sales pitch.