Most Flutter code problems are cheap to fix. Store rejections are not. They land the week you planned to launch, they come from a checker you have never run locally, and the fix is usually in a plugin's native build files rather than in your Dart. Over the last year the two big stores moved several requirements at once, and a Flutter app that shipped fine in 2024 can now be rejected or degraded on modern devices without a single line of your own code changing.
This tutorial is the release-readiness pass we run on client apps before a submission: Android 16 KB page sizes, the current targetSdk and edge-to-edge behaviour, iOS privacy manifests and required-reason APIs, tracking permission, plus the automation that makes all of it a CI failure instead of a store email.
The short version
| Requirement | Where it bites | How you detect it |
|---|---|---|
| 16 KB memory page support (Android) | Native .so files from plugins | check_elf_alignment.sh on your AAB, or a 16 KB emulator |
Current targetSdk for Play uploads | android/app/build.gradle.kts | Play Console upload rejection |
| Edge-to-edge / no deprecated status bar APIs | Your UI, on Android 15+ | Run on Android 15+ and look for clipped content |
| Privacy manifest + required-reason APIs (iOS) | Your app and every plugin | Xcode upload validation, App Store Connect email |
| Signature for third-party SDKs (iOS) | Plugin-bundled xcframeworks | Same as above |
| App Tracking Transparency | Ads / attribution SDKs | Review rejection |
Everything below is about turning each of those rows into something you can check on a laptop.
1. Android 16 KB page sizes: the one that hides in plugins
Newer 64-bit Android devices run with 16 KB memory pages instead of 4 KB. Native libraries compiled with the old 4 KB ELF alignment either fail to load or run in a compatibility mode you do not want. Your Dart code is unaffected — the Flutter engine has been aligned for a while — but any plugin shipping a prebuilt .so may not be: video players, ML runtimes, database engines, crash SDKs, map renderers, secure-storage forks.
Build a release bundle and inspect it:
flutter build appbundle --release
# Google's helper script from the NDK
curl -O https://raw.githubusercontent.com/android/ndk-samples/main/hello-jni/check_elf_alignment.sh
chmod +x check_elf_alignment.sh
./check_elf_alignment.sh build/app/outputs/bundle/release/app-release.aab
Anything reported as unaligned is your work list. If you prefer to look yourself:
cd $(mktemp -d) && unzip -q /path/to/app-release.aab
for so in $(find . -name '*.so'); do
printf '%-60s ' "$so"
# LOAD segment alignment must be >= 0x4000 (16384)
llvm-readelf -l "$so" | awk '/LOAD/ {print $NF; exit}'
done
Make sure your own NDK builds are aligned. In android/app/build.gradle.kts:
android {
ndkVersion = "27.0.12077973" // r27+ aligns to 16 KB by default
packaging {
jniLibs {
useLegacyPackaging = false // required for uncompressed, page-aligned libs
}
}
defaultConfig {
externalNativeBuild {
cmake {
arguments += listOf("-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON")
}
}
}
}
For a misaligned dependency the options, in order of preference: upgrade the plugin (most popular packages have shipped an aligned release), swap the plugin, or vendor a rebuild of the native library. If none of those is possible in time, verify on a 16 KB device image before you assume the compatibility path is acceptable:
# Android Studio -> Device Manager -> new device -> system image with "16 KB page size"
adb shell getconf PAGE_SIZE # prints 16384 on a 16 KB image
Run your whole smoke suite there. Failures usually show up as an immediate UnsatisfiedLinkError in logcat, not as a subtle bug.
2. targetSdk, compileSdk, and edge-to-edge
Play requires new uploads to target a recent API level, and the window moves every August. Pin it explicitly rather than relying on flutter.targetSdkVersion, so an SDK upgrade never silently changes what you ship:
android {
compileSdk = 36
defaultConfig {
minSdk = flutter.minSdkVersion
targetSdk = 36
}
}
Targeting Android 15+ also means edge-to-edge is enforced: the system no longer lets you opt out, and colouring the status bar via the old APIs is deprecated. Two things to fix in Flutter apps:
void main() {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent, // not a coloured bar
statusBarIconBrightness: Brightness.dark,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarContrastEnforced: false,
),
);
runApp(const App());
}
Then audit every screen that draws its own background or sits outside a Scaffold body for missing insets. The usual offenders are custom bottom bars and full-bleed image headers:
// Bottom action bar that used to sit above a 48dp system nav bar.
Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewPaddingOf(context).bottom + 12,
),
child: const CheckoutButton(),
)
SafeArea handles most cases; reach for MediaQuery.viewPaddingOf when you need the inset but still want to paint into it. Test with the three-button nav bar and gesture navigation — they produce different insets.
While you are in the Android build, confirm the release build still uses R8 with your Flutter-safe rules, and that flutter build appbundle --release (not apk) is what CI uploads.
3. iOS privacy manifests and required-reason APIs
Apple requires a PrivacyInfo.xcprivacy file describing the data your app collects, the tracking domains it contacts, and the reason it uses certain APIs — file timestamps, disk space, system boot time, active keyboards, user defaults. Flutter apps trip this constantly because shared_preferences, path_provider, and analytics SDKs touch those APIs on your behalf.
Create the manifest in Xcode (File → New → File → App Privacy) so it lands in the target's resources, then edit it as XML:
<!-- ios/Runner/PrivacyInfo.xcprivacy -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeCrashData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>CA92.1</string></array> <!-- app's own defaults -->
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>C617.1</string></array> <!-- files inside the container -->
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>E174.1</string></array>
</dict>
</array>
</dict>
</plist>
Two rules that save a rejection cycle:
- Only declare what you actually do. An over-broad manifest is not "safe"; it contradicts your App Store Connect answers and invites review questions.
- Plugins ship their own manifests. Yours does not need to cover a dependency that declares its own. What you must not do is declare an API category you use without a reason code.
Check what your dependencies bring in before you write yours:
find ios/Pods . -name '*.xcprivacy' -not -path './build/*' 2>/dev/null
# and confirm the tracking-domain lists are empty if NSPrivacyTracking is false
Any third-party SDK on Apple's commonly-used list must also be signed by its vendor. Verify the xcframeworks a plugin bundles:
codesign -dv --verbose=2 ios/Pods/SomeSDK/SomeSDK.xcframework 2>&1 | grep -i authority
Unsigned means upgrade the plugin or drop the SDK. There is no workaround on your side.
If anything in your app does cross-app tracking, ATT is mandatory before you read the IDFA — and the prompt needs a purpose string, or the dialog never appears:
<key>NSUserTrackingUsageDescription</key>
<string>We use this to measure which campaigns bring people to the app.</string>
final status = await AppTrackingTransparency.requestTrackingAuthorization();
final canAttribute = status == TrackingStatus.authorized;
Request it at a moment the user understands, after a short in-app explanation. Requesting on first frame is both a rejection risk and a conversion disaster.
4. Make CI fail instead of the store
None of this survives as a wiki page. Encode it.
# .github/workflows/release-readiness.yml
name: release-readiness
on: [pull_request]
jobs:
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable }
- run: flutter build appbundle --release
- name: 16 KB alignment gate
run: |
chmod +x tool/check_elf_alignment.sh
./tool/check_elf_alignment.sh \
build/app/outputs/bundle/release/app-release.aab | tee align.log
if grep -qi 'unaligned' align.log; then
echo "::error::native library not 16 KB aligned"; exit 1
fi
- name: targetSdk gate
run: grep -q 'targetSdk = 36' android/app/build.gradle.kts
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable }
- run: test -f ios/Runner/PrivacyInfo.xcprivacy
- run: flutter build ipa --release --export-method app-store
The alignment gate is the one that pays for itself: it catches a bad plugin bump on the pull request that introduced it, when the fix is a version constraint rather than a rollback during a launch window.
5. The pre-submission checklist we actually run
-
flutter build appbundle --releaseandflutter build ipa --releaseboth clean on the pinned SDK version recorded in.fvmrc/ CI - Every
.soin the AAB is 16 KB aligned; smoke tested on a 16 KB emulator image -
targetSdkmatches Play's current requirement; release notes reference the API level - Edge-to-edge audited on Android 15+ with both gesture and three-button navigation
-
PrivacyInfo.xcprivacypresent, matched against App Store Connect data answers, every accessed-API category has a reason code - All bundled third-party xcframeworks signed by their vendor
- ATT prompt (if any) gated behind an explanation screen, with a purpose string in
Info.plist - Permission strings in
Info.plistdescribe a user benefit, not an implementation detail - Crash reporting and symbol upload verified from a release build, not debug
- Deep links / associated domains verified on a signed build from TestFlight and internal testing
Why this belongs in the schedule, not the launch week
Store requirements are dated: page-size support, target API levels, and manifest rules all have deadlines that arrive whether or not your roadmap is ready. The teams that never get surprised do two things — pin the SDK and plugin versions so the native surface only changes deliberately, and run the alignment and manifest gates on every pull request so the blast radius of a bad dependency bump is one review comment.
If you would rather hand this pass to someone who has done it across a dozen apps, get in touch. Our Flutter consultants run release-readiness audits as a fixed-scope engagement: we inventory your native dependencies, fix or replace what fails, wire the gates into your pipeline, and hand back a checklist your own team can run next quarter.