DEV Community

Cover image for The Manifest You Never Wrote — A Flutter Developer's Guide to Android Manifest Merging
FARINU TAIWO
FARINU TAIWO

Posted on

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

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:

  1. What Is Android Manifest?
  2. The Merge Process Under the Hood
  3. Where to Find the Merged Manifest
  4. READ_MEDIA_*
  5. Merge Tools And Markers
  6. How Flutter Uses the Merged Manifest at Build Time
  7. 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:

  1. 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.

  2. 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" />

Enter fullscreen mode Exit fullscreen mode
  1. 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 as required="false" if they're optional enhancements.

  2. Components — <application>
    Registers every Android component your app uses: 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.

<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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)