If you've been building Flutter apps for a while, you've probably touched your AndroidManifest.xml once during setup, added a permission or two when a plugin asked for it, and moved on. Flutter does a great job keeping Android complexity out of your way. But here's something worth knowing: the manifest you write is never actually the one that ships. By the time your app is packaged into an APK or AAB, Android's build system has quietly merged your manifest together with the manifest of every plugin in your pubspec.yaml. The result can look very different from what you started with.
That merged output is sitting in your build/ folder is what the Android OS reads, what users grant permissions against, and what Google Play's policy engine inspects on every upload. A plugin you added for image picking can silently pull in video and audio permissions you never intended to declare, and Play Console will flag them without mercy. This article walks you through how the merge works, how to read the merged output, and how to stay in control of what your app actually ships.
Here's everything we'll cover:
- What Is Android Manifest?
- The Merge Process Under the Hood
- Where to Find the Merged Manifest
- READ_MEDIA_*
- Merge Tools And Markers
- How Flutter Uses the Merged Manifest at Build Time
- Why This Matters for Play Store Deployment
What Is Android Manifest?
Every Android application ships with a file called AndroidManifest.xml. It is the contract between your app and the Android OS. It declares who you are, what you need, and what you can do. Without it, your app simply cannot run.
In a Flutter project, yours lives at:
android/app/src/main/AndroidManifest.xml
At the top level, a manifest answers four fundamental questions the OS always asks:
Identity —
<manifest>
Your app's package name (today mapped to applicationId in Gradle), version code, and version name live here. This is what uniquely identifies your app on a device and on the Play Store.Permissions —
<uses-permission>
Anything your app wants to access that belongs to the user or system, such as camera, location, storage, or internet, must be declared. Without a declaration, the runtime will deny the call even if the user tries to grant it manually.
<!-- Internet access — no runtime prompt needed -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Camera — triggers a runtime prompt on Android 6+ -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Fine location for GPS -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
Hardware Features —
<uses-feature>
Declares hardware the app requires, such as a camera or GPS chip. Google Play uses these to filter which devices can install your app. Mark features asrequired="false"if they're optional enhancements.Components —
<application>
Registers every Android component your app uses:Activity,Service,BroadcastReceiver,ContentProvider. Flutter registers its ownFlutterActivityhere, and every plugin that uses a Service or a FileProvider adds its own entries too.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:name="${applicationName}"
android:label="App Name"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
The Merge Process Under the Hood
Here's the critical thing most developers miss: the manifest you write is never the manifest that ships. Android's build system (AGP — Android Gradle Plugin) merges manifests from every source in your project before packaging them into your APK or AAB.
Who contributes a manifest?
[ Your App ] --------+
app/src/main |
|
[ Build Variant ] ---+
src/debug/release | (Manifest Merge) [ Merged Manifest ]
+----------------------------→ What ships in
[ Flutter Plugins ] | processManifest your APK
Every pub.dev pkg |
|
[ AAR Libraries ] ---+
Google/Firebase
The tool responsible is called the Manifest Merger, part of AGP. It runs during the processDebugManifest / processReleaseManifest Gradle task.
Merge priority order
Manifests are merged in a strict priority chain. Higher-priority entries win on conflicts:
| Priority | Source | Description |
|---|---|---|
| Priority 1 | App manifest | Your android/app/src/main/AndroidManifest.xml. Highest priority — always wins. |
| Priority 2 | Build variant manifest | e.g. src/staging/AndroidManifest.xml — used for flavor-specific overrides. |
| Priority 3 | Library / plugin manifests | Every package in your pubspec that has native Android code ships its own manifest. These are lowest priority and can be overridden. |
Merge strategies
The merger applies a default strategy per element type. For most nodes, higher-priority entries win. For <uses-permission>, the union is taken — if any manifest declares a permission, it ends up in the final output.
1. merge (default)
Combines all attributes, preferring higher-priority source on conflicts. Most elements use this.
2. replace
Higher-priority element fully replaces the lower one. No attribute blending.
3. remove
Explicitly removes an element that a lower-priority manifest added. Your only way to un-declare a library's permission.
4. strict
Both manifests must declare the exact same value. Build fails otherwise. Good for enforcing consistency.
Permissions accumulate. Because permissions use a union strategy, adding a plugin is enough to add its permissions to your final APK — even if your Dart code never calls the relevant API. This is a common source of Play Store policy violations.
Where to Find the Merged Manifest
After any build, AGP writes the fully merged manifest to your build folder. This is the ground truth of what actually ships.
File location
android/app/build/intermediates/merged_manifests/<variant>/AndroidManifest.xml
For a typical Flutter project with debug/release:
android/app/build/intermediates/merged_manifests/debug/AndroidManifest.xml
android/app/build/intermediates/merged_manifests/release/AndroidManifest.xml
If you use flavors (e.g., staging, prod), each combination gets its own folder:
/merged_manifests/stagingRelease/AndroidManifest.xml
.../merged_manifests/prodRelease/AndroidManifest.xml
Merge report
AGP also writes a human-readable merge report that tells you exactly which library contributed each entry and why:
android/app/build/outputs/logs/manifest-merger-<variant>-report.txt
Android Studio shortcut: Open your source AndroidManifest.xml in Android Studio and click the Merged Manifest tab at the bottom of the editor. It renders the full merged file and shows the source of each element inline — indispensable for debugging.
After a Flutter build
Running flutter build apk or flutter build appbundle triggers the full Gradle build, which includes manifest merging. You can inspect the result immediately after:
# Build release AAB
flutter build appbundle --flavor prod --dart-define-from-file=env/prod.json
# Then inspect the merged manifest
cat android/app/build/intermediates/merged_manifests/prodRelease/AndroidManifest.xml
# Or search specifically for permissions
grep "uses-permission" android/app/build/intermediates/merged_manifests/prodRelease/AndroidManifest.xml
READ_MEDIA_*
This is one of the most common permission headaches in modern Flutter apps. Android 13 (API 33) split the old READ_EXTERNAL_STORAGE into granular media permissions. If you handle this wrong, your app either crashes on old devices, gets rejected on the Play Store, or worst silently ships permissions you never intended.
The old world (≤ Android 12)
One permission covered everything in external storage:
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
The new world (Android 13+, API 33+)
Android 13 introduced three scoped permissions. You only request what your app actually needs:
| API Level | Permission | Description |
|---|---|---|
| API 33+ | READ_MEDIA_IMAGES | Read images and photos from shared storage. Replaces READ_EXTERNAL_STORAGE for photos. |
| API 33+ | READ_MEDIA_VIDEO | Read video files. Required only if your app accesses video — a chat app or media player, for instance. |
| API 33+ | READ_MEDIA_AUDIO | Read audio files. Required only for music or voice note apps. |
The trap: image_picker and other plugins
If you use image_picker, file_picker, or photo_manager, check their shipped manifests. Some older versions blanket-declare all three READ_MEDIA_* permissions even if your app only needs images. You'll find them added to your merged manifest without writing a single line yourself.
Here's what the merged manifest might look like for a basic profile photo only feature:
<!-- ✅ You declared this -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- ❌ image_picker added these — you never use video or audio! -->
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- Still needed for Android 12 and below -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
Play Store policy alert. Declaring READ_MEDIA_VIDEO or READ_MEDIA_AUDIO on an app that has no video/audio feature triggers a Sensitive Permissions policy warning in Google Play Console. Your app may be rejected or removed. This is exactly the kind of issue you only catch by inspecting the merged manifest.
The fix: remove what you don't need
In your app’s manifest, use the tools:node="remove" attribute to explicitly remove unwanted permissions that may be coming from libraries or merged manifests. To use this feature, you must first declare the tools namespace in the manifest root.
This allows you to cleanly remove specific permissions without modifying the library that added them.
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Only images needed for profile photo -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- Constrain READ_EXTERNAL_STORAGE to Android 12 and below -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Remove VIDEO permission added by image_picker -->
<uses-permission
android:name="android.permission.READ_MEDIA_VIDEO"
tools:node="remove" />
<!-- Remove AUDIO permission added by image_picker -->
<uses-permission
android:name="android.permission.READ_MEDIA_AUDIO"
tools:node="remove" />
...
</manifest>
Merge Tools and Markers
The xmlns:tools namespace acts as your control panel for the manifest merge process. It allows you to add special instructions to manifest elements that tell the Android manifest merger exactly how to handle them. These instructions override the default merge behavior and give you direct control over the final merged manifest.
tools:node — control the element itself
<!-- Remove an element a library added -->
<uses-permission
android:name="android.permission.RECORD_AUDIO"
tools:node="remove" />
<!-- Replace a library's activity declaration entirely -->
<activity
android:name="com.some.lib.SomeActivity"
tools:node="replace">
<!-- your overridden content -->
</activity>
<!-- Merge children but not attributes -->
<application
tools:node="mergeOnlyAttributes">
...
</application>
tools:attr — control individual attributes
Sometimes you need finer control — just override a single attribute from a library without replacing the whole element:
<!-- Force our label, ignore what the library set -->
<activity
android:name="com.some.lib.SomeActivity"
android:label="@string/app_name"
tools:replace="android:label" />
<!-- Remove just the theme attribute, keep everything else -->
<activity
android:name="com.some.lib.OtherActivity"
tools:remove="android:theme" />
android:maxSdkVersion — scoping permissions by OS version
This is not a tools: attribute. It is a native Android attribute that tells the OS to automatically revoke the permission on devices running a newer API level. It's the correct way to handle the READ_EXTERNAL_STORAGE to READ_MEDIA_* migration:
<!-- Grant READ_EXTERNAL_STORAGE only on Android 12 (API 32) and below.
On Android 13+, the granular READ_MEDIA_* permissions take over. -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Similarly, WRITE_EXTERNAL_STORAGE has been a no-op since API 29.
Constrain it to avoid unnecessary permission requests. -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
How maxSdkVersion actually works. It's enforced at install time and at system update time. When a user updates their OS from Android 12 to 13, the OS automatically revokes the permission for apps that declared
maxSdkVersion="32". Your app never needs to hold the old permission on a new OS version.
tools:ignore — silence false-positive merge warnings
If you intentionally declare something that triggers a merger warning, suppress it cleanly rather than leaving the warning in CI output:
<application
android:allowBackup="false"
tools:ignore="GoogleAppIndexingWarning,MissingApplicationIcon">
...
</application>
How Flutter Uses the Merged Manifest at Build Time
Understanding the build pipeline helps you know exactly when and where the manifest merge happens relative to your Dart code and assets.
flutter build Dart compile Gradle assemble processManifest package / sign
CLI entry point → AOT kernel → AGP kicks in → Manifest merge → APK / AAB output
Flutter's Gradle plugin (flutter.gradle) executes the full Android build chain. The Dart compilation happens first, producing a snapshot that is bundled as native assets. Then Gradle takes over and performs the standard Android build — including manifest merging as part of process<Variant>Manifest.
Flutter plugin registration contributes to the manifest
When you run flutter pub get, Flutter's tooling auto-generates the GeneratedPluginRegistrant class and also ensures each plugin's android/src/main/AndroidManifest.xml is registered as a library manifest source with Gradle. This is why you don't need to manually add plugin permissions — they merge in automatically. But it also means they merge in whether you want them to or not.
Flavors and the manifest per build variant
In a repo with dev, staging, and prod flavors, each flavor can have its own source set manifest:
android/app/src/dev/AndroidManifest.xml — dev-only debug overrides
android/app/src/staging/AndroidManifest.xml — staging environment flags
android/app/src/prod/AndroidManifest.xml — production, minimal, clean
A common pattern is to put android:usesCleartextTraffic="true" only in the dev manifest (so it never ships to prod), and to declare android:debuggable="false" explicitly in the prod manifest as a safeguard.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config_dev"/>
</manifest>
Why This Matters for Play Store Deployment
Google Play Console runs its own manifest analysis on every AAB you upload. It extracts the final merged manifest and cross-checks it against multiple policy layers. Unintended permissions in your merged manifest are one of the top reasons for pre-launch warnings, policy violations, and outright rejections.
Common Play Store issues caused by the merged manifest
| Issue in Play Console | Root Cause in Manifest | Fix |
|---|---|---|
| Sensitive permissions warning | Plugin added READ_MEDIA_VIDEO or READ_MEDIA_AUDIO you don't need |
tools:node="remove" |
| Broad storage access policy |
READ_EXTERNAL_STORAGE without maxSdkVersion — triggers review for API 33+ |
Add maxSdkVersion="32"
|
| MANAGE_EXTERNAL_STORAGE requires declaration | A plugin declares this without your knowledge — it requires a special Play Store form |
tools:node="remove" unless needed |
| Background location without foreground | Library adds ACCESS_BACKGROUND_LOCATION — needs explicit policy approval |
Remove if unused; otherwise file declaration form |
| QUERY_ALL_PACKAGES policy | Some plugins query all installed packages — requires justification |
tools:node="remove" if avoidable |
| Uses-feature incompatibility | Plugin declares a required hardware feature, filtering out valid device categories | Override with required="false"
|
| Cleartext traffic in production |
usesCleartextTraffic="true" leaking from a dev-flavored manifest into a prod build |
Scope to dev flavor manifest only |
Pre-submission checklist
Before uploading any AAB to Play Console, run through these steps:
1. Build and inspect.
Run flutter build appbundle --flavor prod and open the merged manifest. Use grep "uses-permission" to get a clean list of all declared permissions.
2. Audit against features.
For every permission in the merged manifest, ask: Does my app actually use this? If not, remove it with tools:node="remove".
3. Scope storage permissions.
Ensure READ_EXTERNAL_STORAGE has maxSdkVersion="32" and WRITE_EXTERNAL_STORAGE has maxSdkVersion="28".
4. Read the merge report.
Check build/outputs/logs/manifest-merger-prodRelease-report.txt for any merge conflicts flagged as errors or warnings — fix them before upload.
5. Cross-check with Play Console's App Content section.
If you declare any sensitive permissions, ensure your privacy policy and data safety form are updated to reflect them. Undeclared data collection is a separate but related violation.
Add manifest inspection to your CI pipeline. In your GitHub Actions release workflow, after flutter build appbundle, add a step that greps the merged manifest for a blocklist of sensitive permissions. Fail the job if any appear unexpectedly before the AAB ever reaches Play Console.
.github/workflows/release.yml
- name: Audit merged manifest permissions
run: |
MANIFEST="android/app/build/intermediates/merged_manifests/prodRelease/AndroidManifest.xml"
BLOCKLIST=(
"READ_MEDIA_VIDEO"
"READ_MEDIA_AUDIO"
"MANAGE_EXTERNAL_STORAGE"
"ACCESS_BACKGROUND_LOCATION"
"QUERY_ALL_PACKAGES"
)
for perm in "${BLOCKLIST[@]}"; do
if grep -q "$perm" "$MANIFEST"; then
echo "❌ BLOCKED: $perm found in merged manifest!"
exit 1
fi
done
echo "✅ No blocked permissions found"
Conclusion
Your source AndroidManifest.xml is the beginning of the story, not the end. Every plugin you add such as image_picker, geolocator, firebase_messaging, and camera ships its own manifest, and those entries silently merge into yours at build time.
The merged manifest in your build/ folder is the only version that matters. It is what the OS installs, what Play Console reviews, and what users grant permissions against. Making it a regular part of your release workflow by building it, reading it, and asserting on it in CI is one of the highest leverage habits you can build as a Flutter developer shipping to Android. 🚀
Top comments (0)