+1 (415) 480-3939

Flutter Release Readiness in 2026: Android 16 KB Pages, targetSdk 36, and iOS Privacy Manifests

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

RequirementWhere it bitesHow you detect it
16 KB memory page support (Android)Native .so files from pluginscheck_elf_alignment.sh on your AAB, or a 16 KB emulator
Current targetSdk for Play uploadsandroid/app/build.gradle.ktsPlay Console upload rejection
Edge-to-edge / no deprecated status bar APIsYour UI, on Android 15+Run on Android 15+ and look for clipped content
Privacy manifest + required-reason APIs (iOS)Your app and every pluginXcode upload validation, App Store Connect email
Signature for third-party SDKs (iOS)Plugin-bundled xcframeworksSame as above
App Tracking TransparencyAds / attribution SDKsReview 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 --release and flutter build ipa --release both clean on the pinned SDK version recorded in .fvmrc / CI
  • Every .so in the AAB is 16 KB aligned; smoke tested on a 16 KB emulator image
  • targetSdk matches 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.xcprivacy present, 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.plist describe 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.