+1 (415) 843-4662

Store compliance for Ionic apps: privacy manifests, Data Safety, target SDK 36 and 16 KB pages

Every Ionic team we work with hits the same wall a week before a release: the app builds fine, QA is happy, and then App Store Connect or the Play Console rejects the upload for something that has nothing to do with your code. In 2026 the rejections cluster around four things — Apple privacy manifests and signatures for third-party SDKs, Google's Data Safety form, Android's target SDK deadline, and the 16 KB memory page requirement for native libraries.

This tutorial turns those four into a release checklist you can run from the command line, plus a CI job that fails the build instead of the store review. Examples assume a Capacitor 8 project (ios/ and android/ committed to the repo), but everything applies to any Ionic app with native platforms.

0. Know what is actually inside your app

Compliance questions are all questions about your dependencies. Start by listing every native SDK the build pulls in — not the ones you remember adding.

# JS/Capacitor plugins with native code
npx cap ls

# iOS: everything CocoaPods or SPM resolves
cd ios/App && pod list 2>/dev/null | sed -n '1,200p'; cd -

# Android: the full runtime dependency tree
cd android && ./gradlew :app:dependencies --configuration releaseRuntimeClasspath | \
  grep -E '^[+\\]---' | sort -u | head -100; cd -

Paste the result into a spreadsheet with four columns: SDK, what data it touches, privacy manifest present (iOS), Data Safety category (Android). That sheet is your compliance artefact — auditors and clients ask for it, and it makes the two store forms a copy-paste exercise instead of a guessing game.

1. Apple: privacy manifests and required-reason APIs

Apple requires a PrivacyInfo.xcprivacy file for your app and for every listed third-party SDK, and it requires you to declare a reason code for "required reason" APIs such as UserDefaults, file timestamps, disk space and systemUptime. Capacitor uses several of these internally, so a plain Ionic app is not exempt.

Create ios/App/App/PrivacyInfo.xcprivacy and add it to the App target in Xcode (File → Add Files, target checkbox ticked — a file on disk that is not in the target does nothing):

<?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>NSPrivacyCollectedDataTypeEmailAddress</string>
      <key>NSPrivacyCollectedDataTypeLinked</key><true/>
      <key>NSPrivacyCollectedDataTypeTracking</key><false/>
      <key>NSPrivacyCollectedDataTypePurposes</key>
      <array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
    </dict>
    <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>
    </dict>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array><string>C617.1</string></array>
    </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 round-trip:

  • Declare only what is true. CA92.1 means "access info from the app's own container, for the app's own use". If you sync UserDefaults values to a server, that is a different reason code, and the wrong one is worse than a missing one.
  • Do not write manifests for other people's SDKs. If a dependency ships no manifest, update it or replace it. Apple's rule is that the SDK author declares its own usage; a stale analytics or ad SDK is the usual culprit.

Check the built app rather than the source. After an archive, Xcode's Organizer can generate a privacy report (Archive → Generate Privacy Report), and you can confirm the manifests actually made it into the bundle:

# after `npx cap sync ios` and a build
find ios/App/build -name 'PrivacyInfo.xcprivacy' | sed 's|.*/Frameworks/||' | sort

Signed SDKs matter too: commonly used binary SDKs must ship a signature alongside the manifest. Upload a build to TestFlight early in the release cycle — the validation email arrives in minutes and names the offending SDK.

2. Google Play: Data Safety that matches your code

Play's Data Safety form is a declaration, and Google cross-checks it against observed network behaviour. The two mismatches we see most in Ionic apps:

  • The app declares "no data collected" while a crash reporter uploads device identifiers.
  • Advertising ID is used by a dependency you did not know you had. If your app targets Android 13+ and touches the ad ID, you need the com.google.android.gms.permission.AD_ID permission declared and the form answered accordingly.

Find out empirically:

# what permissions ended up in the merged manifest?
cd android && ./gradlew :app:processReleaseManifest && \
  grep -E 'uses-permission|uses-feature' app/build/intermediates/merged_manifest/release/AndroidManifest.xml

If a permission appears that no feature needs, remove it at the merge level in android/app/src/main/AndroidManifest.xml rather than shipping a declaration you cannot justify:

<uses-permission android:name="com.google.android.gms.permission.AD_ID"
    tools:node="remove" />

(Add xmlns:tools="http://schemas.android.com/tools" to the <manifest> tag if it is not already there.)

Then keep the answers in the repo. We store compliance/data-safety.md next to the code, with one row per data type and the SDK that justifies it, and we require it to be updated in the same PR that adds a dependency.

3. Target SDK and the Android release treadmill

Google raises the minimum targetSdkVersion for new and updated apps every year, with the cutoff at the end of August. Capacitor's Android project reads the values from android/variables.gradle:

ext {
    minSdkVersion = 24
    compileSdkVersion = 36
    targetSdkVersion = 36
    androidxActivityVersion = '1.9.2'
    // ...
}

Bumping the number is trivial; the behaviour changes are not. For SDK 36 the ones that bite Ionic apps are enforced edge-to-edge layout (your toolbars and tab bars need safe-area handling), stricter foreground-service types, and predictive back. Test on an Android 16 emulator with the developer option "Enforce app compatibility changes" enabled before you bump in main.

A cheap guard so the treadmill never surprises you:

#!/usr/bin/env bash
# tools/check-target-sdk.sh — fail CI if targetSdk drifts below policy
REQUIRED=36
ACTUAL=$(grep -oE 'targetSdkVersion *= *[0-9]+' android/variables.gradle | grep -oE '[0-9]+')
if [ "$ACTUAL" -lt "$REQUIRED" ]; then
  echo "targetSdkVersion is $ACTUAL, Play policy requires $REQUIRED"; exit 1
fi
echo "targetSdkVersion $ACTUAL OK"

4. The 16 KB page size requirement

Newer Android devices run with 16 KB memory pages. Play now requires apps targeting recent API levels to have native libraries that are 16 KB-aligned; a single old .so inside a plugin will block the upload. Pure-JS Ionic apps usually pass, but anything with SQLite, a video SDK, a barcode scanner or an older crash reporter needs checking.

Build the bundle, then inspect the shared objects:

cd android && ./gradlew bundleRelease
cd - && mkdir -p /tmp/aabcheck && \
  unzip -o android/app/build/outputs/bundle/release/app-release.aab -d /tmp/aabcheck > /dev/null

for so in $(find /tmp/aabcheck -name '*.so'); do
  ALIGN=$(objdump -p "$so" | awk '/LOAD/ {print $NF; exit}')
  echo "$ALIGN  $so"
done

Anything reporting 2**12 (4 KB) instead of 2**14 (16 KB) is a problem. Fix it in this order: update the plugin, then update the NDK/AGP used by the plugin's own build, then — last resort — replace the dependency. Also make sure your own build uses AGP 8.5+ and NDK r27+, which align by default:

android {
    packaging {
        jniLibs { useLegacyPackaging = false }
    }
}

5. Wire it into CI so the store is never the first check

Put the cheap checks in a single job that runs on every pull request. It costs seconds and it catches the drift that otherwise surfaces the day you submit.

# .github/workflows/compliance.yml
name: store-compliance
on: [pull_request]
jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: iOS privacy manifest present
        run: test -f ios/App/App/PrivacyInfo.xcprivacy || { echo "missing PrivacyInfo.xcprivacy"; exit 1; }
      - name: Privacy manifest is valid plist
        run: plutil -lint ios/App/App/PrivacyInfo.xcprivacy || python3 -c "import plistlib,sys; plistlib.load(open(sys.argv[1],'rb'))" ios/App/App/PrivacyInfo.xcprivacy
      - name: Target SDK policy
        run: bash tools/check-target-sdk.sh
      - name: Data safety doc updated with dependencies
        run: |
          if git diff --name-only origin/${{ github.base_ref }}... | grep -qE '(package.json|android/app/build.gradle|ios/App/Podfile)'; then
            git diff --name-only origin/${{ github.base_ref }}... | grep -q '^compliance/data-safety.md$' \
              || { echo "dependencies changed but compliance/data-safety.md was not updated"; exit 1; }
          fi

The 16 KB scan needs a full Android build, so run it in the nightly or release workflow rather than on every PR.

Release-day checklist

  • Dependency inventory regenerated and reviewed
  • PrivacyInfo.xcprivacy in the App target, reasons match real usage
  • Every third-party SDK ships its own manifest (and signature where required)
  • Xcode privacy report generated and archived with the release notes
  • Play Data Safety form matches compliance/data-safety.md
  • Merged Android manifest contains no unjustified permissions
  • targetSdkVersion meets the current Play policy; tested on the matching emulator
  • All .so files 16 KB-aligned in the release bundle
  • Age rating, account deletion route and data-deletion URL still accurate
  • TestFlight and Play internal-track uploads validated before the public build

Where this usually goes wrong

The pattern is always the same: compliance is treated as paperwork at the end instead of a property of the dependency list. Teams that keep the inventory current spend twenty minutes per release on this. Teams that do not lose a week to rejection cycles, usually in the middle of a launch window.

If you want a second pair of eyes on a release that is about to go out — or a one-off audit of an Ionic app that has accumulated a decade of plugins — our Ionic consultants do exactly this kind of review. Get in touch and tell us what you are shipping.