AI News Hub Logo

AI News Hub

The Manifest You Never Wrote — A Flutter Developer's Guide to Android Manifest Merging

DEV Community
FARINU TAIWO

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. That's completely normal — 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 — 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 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 — Permissions — Hardware Features — required="false" if they're optional enhancements. Components — Activity, Service, BroadcastReceiver, ContentProvider. Flutter registers its own FlutterActivity here, and every plugin that uses a Service or a FileProvider adds its own entries too. 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. [ 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. 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. The merger applies a default strategy per element type. For most nodes, higher-priority entries win. For , the union is taken — if any manifest declares a permission, it ends up in the final output. Combines all attributes, preferring higher-priority source on conflicts. Most elements use this. Higher-priority element fully replaces the lower one. No attribute blending. Explicitly removes an element that a lower-priority manifest added. Your only way to un-declare a library's permission. 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. After any build, AGP writes the fully merged manifest to your build folder. This is the ground truth of what actually ships. android/app/build/intermediates/merged_manifests//AndroidManifest.xml 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 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--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. 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. One permission covered everything in external storage: 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. 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: 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. 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. ... 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. ... Sometimes you need finer control — just override a single attribute from a library without replacing the whole element: 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: 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. If you intentionally declare something that triggers a merger warning, suppress it cleanly rather than leaving the warning in CI output: ... 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 processManifest. 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 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. 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 Before uploading any AAB to Play Console, run through these steps: Run flutter build appbundle --flavor prod and open the merged manifest. Use grep "uses-permission" to get a clean list of all declared permissions. For every permission in the merged manifest, ask: Does my app actually use this? If not, remove it with tools:node="remove". Ensure READ_EXTERNAL_STORAGE has maxSdkVersion="32" and WRITE_EXTERNAL_STORAGE has maxSdkVersion="28". Check build/outputs/logs/manifest-merger-prodRelease-report.txt for any merge conflicts flagged as errors or warnings — fix them before upload. 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" 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. 🚀