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.1means "access info from the app's own container, for the app's own use". If you syncUserDefaultsvalues 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_IDpermission 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.xcprivacyin 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
-
targetSdkVersionmeets the current Play policy; tested on the matching emulator - All
.sofiles 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.