Boolean Build

The Android manifest mistakes that get Play builds rejected

Almost every Android release problem that isn’t a crash is a line in AndroidManifest.xml. The manifest is the first thing Play’s own tooling reads, the thing your permissions are derived from, and — because React Native merges yours together with every library’s — the file nobody actually looks at until something breaks.

These are the six attributes that show up again and again in rejections, install failures and pre-launch crashes. All six are checkable before you upload.

1. android:exported missing on a component with an intent filter

Since Android 12 (API 31), any activity, service or receiver with an <intent-filter> must declare android:exported explicitly. Omit it and the build fails to install on API 31+, which surfaces as an instant crash in pre-launch reports rather than a clean error.

<activity android:name=".MainActivity" android:exported="true">
  <intent-filter>
    <action android:name="android.intent.action.MAIN" />
    <category android:name="android.intent.category.LAUNCHER" />
  </intent-filter>
</activity>

Deep-link filters deserve the same care: if a filter is only for your own app:// scheme, exported="false" is usually the correct answer, and it’s the safer default.

2. android:debuggable="true" in a release

Shipping a debug variant is the classic “Play says my app is debuggable” rejection. It usually comes from building assembleDebug by hand instead of a release variant, or from a stale debug applicationId surviving in a merged manifest. The merged manifest is the only place to verify it — it’s the file that actually gets packaged.

3. usesCleartextTraffic="true"

A blanket cleartext allowance means the app may talk HTTP. Play flags it, and it contradicts the “data encrypted in transit” box you ticked in the Data safety form. If a single dev host genuinely needs HTTP, use a network security config scoped to that domain instead of a global flag:

<application android:networkSecurityConfig="@xml/network_security_config">
<!-- res/xml/network_security_config.xml -->
<network-security-config>
  <domain-config cleartextTrafficPermitted="true">
    <domain includeSubdomains="false">10.0.2.2</domain>
  </domain-config>
</network-security-config>

4. allowBackup="true"

Not a rejection on its own, but it lets anyone with adb extract app data from a signed build, which is awkward to explain in a privacy review. For apps holding credentials or health data, android:allowBackup="false" (plus a backup rules file if you want partial backup) is the defensible choice.

5. targetSdkVersion falling behind

Play requires new apps and updates to target an API level no more than one year older than the latest major Android release, with the deadline landing each August. Miss it and every upload is rejected until you bump. It’s boring and it lands annually without warning, which is exactly why it should be a CI check rather than a calendar reminder.

In Expo, the manifest value comes from your build properties, so the fix is one plugin entry:

{
  "expo": {
    "plugins": [
      ["expo-build-properties", {
        "android": { "compileSdkVersion": 35, "targetSdkVersion": 36 }
      }]
    ]
  }
}

Note the asymmetry: targetSdkVersion may be newer than compileSdkVersion (AGP allows it), but not the reverse. If expo-modules-core fails to compile after a bump, the compile SDK is the number to walk back, not the target.

6. Permissions that don’t match your Data safety form

The manifest is the source of truth for what you request, so a mismatch with the Data safety form is trivially detectable — by Play and by anyone reading your AAB. Two failure modes:

{
  "expo": {
    "android": {
      "blockedPermissions": ["com.google.android.gms.permission.AD_ID"]
    }
  }
}

Do not use blockedPermissions to paper over a permission you actually use, though. Play’s automated check reads the same manifest you do.

Read the merged manifest, not the one you wrote

Your source manifest is a wish; the merged manifest is what ships. Find it after a release build:

# path varies a little by AGP version
android/app/build/intermediates/merged_manifests/release/*/AndroidManifest.xml

To inspect the AAB itself, bundletool dump manifest --bundle app-release.aab works (it needs Java), and aapt2 dump xmltree does the same for a binary APK manifest. If you’d rather not install the Android SDK on every machine and CI runner, that’s what manifest-check is for — it parses the AAB’s protobuf manifest (or a text one) with no Java, no SDK, and exits non-zero on the two blocking findings:

npx @njweber/android-manifest-check ./app-release.aab
manifest-check — Android manifest audit
artifact: ./app-release.aab

✓ manifest  <manifest> parsed — com.example.app
✓ debuggable  not debuggable
✓ cleartext  no blanket cleartext traffic
! allowBackup  allowBackup="true" — app data can be extracted via adb backup (consider false)
! activity:.LegacyActivity:exported  is exported with no <intent-filter> — make sure that is intended
✓ targetSdkVersion  targetSdkVersion 36
✓ permissions  15 permission(s) declared, 7 sensitive — each needs a Play data-safety declaration

That’s the whole argument for checking the manifest in CI: these failures are all mechanical, and every one of them is cheaper to catch on the pull request than in a Play review. Pair it with the 16 KB page-size check on the same artifact and the Android side of a release stops being a guessing game. The rest of the recurring reasons live in the rejection checklist.

The release checks above, plus the build gates and local iOS/Android release scripts, are packaged in the Expo Production Starter.