⚠️ This project is in an experimental stage. APIs and behavior may change without notice.
"There can be only one."
A Gradle plugin that finds duplicate resources, assets, classes, and native libraries hiding across your Android dependencies — before they cause silent UI bugs, Dex merge failures, or runtime crashes.
When you add libraries to an Android project, duplicates can sneak in silently:
| Problem | What Happens | When You Find Out |
|---|---|---|
Duplicate resources (drawable/ic_close) |
AGP silently picks one by priority | Runtime — wrong icon/color appears |
Duplicate assets (config.json) |
Higher-priority module's file wins | Runtime — library reads wrong config |
Duplicate classes (a.a.class) |
Dex merge fails or wrong class loads | Build time or runtime crash |
Duplicate native libs (libc++_shared.so) |
Build fails, devs add pickFirst |
Runtime — UnsatisfiedLinkError |
Highlander catches all of these before they become problems, using a baseline-based approach that integrates into your CI pipeline.
// build.gradle.kts (app module)
plugins {
id("com.android.application")
id("io.github.fornewid.highlander") version "<latest-version>"
}
highlander {
configuration("release")
}./gradlew :app:highlanderBaselineThis creates baseline files in highlander/ that record the current state of duplicates. Commit these files to your repository.
./gradlew :app:highlanderIf new duplicates appear (e.g., after adding a dependency), the build fails with a clear diff:
Highlander: Duplicates changed in :app (release)
=== resources ===
+ drawable/ic_close:
+ - :app (.xml)
+ - com.example:sdk:1.0 (.png)
If this is expected, re-baseline with:
./gradlew :app:highlanderBaselineRelease
highlander {
baselineDir.set("highlander") // default
configuration("release") {
resources = true // Scan res/ file-based resources
assets = true // Scan assets/
nativeLibs = false // Scan .so native libraries
valuesResources = false // Scan values/ XML entries (strings, colors, etc.)
classes = false // Scan Java/Kotlin classes in JARs/AARs
excludeAndroidXValues = true // Drop androidx.* sources from the values scan
skipContentIdenticalDuplicates = true // Drop byte-identical duplicates from the baseline
}
}| Option | Default | Description |
|---|---|---|
resources |
true |
Detect duplicate file-based resources (drawable, layout, mipmap, etc.) |
assets |
true |
Detect duplicate asset files |
nativeLibs |
false |
Detect duplicate .so native libraries per ABI |
valuesResources |
false |
Detect duplicate values entries (string, color, dimen, etc.) |
classes |
false |
Detect duplicate Java/Kotlin classes across dependency JARs/AARs |
excludeAndroidXValues |
true |
Filter out androidx.* sources from the values scan only |
skipContentIdenticalDuplicates |
true |
Omit byte-identical duplicates (classified duplicate-safe) from the baseline |
baselineDir |
"highlander" |
Directory for baseline files |
Note on excludeAndroidXValues: AndroidX components (Compose, Core, etc.) routinely share benign values declarations by design. Filtering them out keeps the values baseline signal-to-noise high. Set to false to include AndroidX entries. No effect unless valuesResources = true. Run with --info to see how many AndroidX sources were excluded and how many unknown-origin sources remain (unknown-origin sources such as files() or some composite-build setups are not matched by the filter).
Note on values id-slot skip: the values scan automatically skips empty-body <item type="id" name="..."/> (and the shorthand <id name="..."/>) declarations. AAPT2 treats these as weak Id values that merge across libraries without runtime conflict, so reporting them would be false-positive noise.
Note on skipContentIdenticalDuplicates: enabled by default to keep the baseline compact — only entries that need review (# override, # conflict) are persisted. Divergent-content conflicts that used to be byte-identical still surface the moment they diverge (the new # conflict entry appears in the diff). Set to false to retain # duplicate-safe lines in the baseline if you want a historical record of benign duplicates. Only affects scans that classify as duplicate-safe today (resources, assets).
Each scan type produces a separate baseline file:
highlander/
├── releaseResources.txt # res/ duplicates
├── releaseAssets.txt # assets duplicates
├── releaseNativeLibs.txt # .so duplicates (if nativeLibs = true)
├── releaseValues.txt # values entry duplicates (if valuesResources = true)
└── releaseClasses.txt # class duplicates (if classes = true)
Each entry is tagged with one of three labels indicating how AGP will resolve the duplicate:
| Tag | Meaning | Action |
|---|---|---|
# override |
The app module is one of the sources — AAPT's "last wins" rule makes the app's copy win | Usually intentional |
# conflict |
External dependencies only, file bytes differ — AGP picks one by priority, behavior can change | Review the diff |
# duplicate-safe |
All sources have byte-identical content — AAPT merges deterministically, no runtime difference | Informational |
Classification matrix by scan type:
| Scan | override |
conflict |
duplicate-safe |
|---|---|---|---|
resources |
✓ | ✓ | ✓ |
assets |
✓ | ✓ | ✓ |
nativeLibs |
✓ | ✓ | — |
classes |
✓ | ✓ | — |
valuesResources |
✓ | ✓ | — |
duplicate-safe requires byte-level comparison, which is only performed for resources and assets today.
# override
drawable/ic_close:
- :app (.xml)
- com.example:lib:1.0 (.png)
# conflict
config.json:
- com.sdk.a:core:1.0
- com.sdk.b:analytics:2.0
# duplicate-safe
drawable-anydpi-v21/ic_shared:
- androidx.media3:media3-ui:1.4.1 (.xml)
- com.google.android.exoplayer:exoplayer-ui:2.18.7 (.xml)
If a duplicate's classification changes (e.g. a dependency upgrade makes bytes match, turning conflict into duplicate-safe), the guard reports a single ~ line for that key:
~ drawable/ic_shared (conflict -> duplicate-safe):
- androidx.media3:media3-ui:1.4.1 (.xml)
- com.google.android.exoplayer:exoplayer-ui:2.18.7 (.xml)
Re-run highlanderBaseline to accept the transition.
Once a baseline exists, the investigation guide walks
through diagnosing each # conflict / # override entry — locating the AARs
in the Gradle cache, diffing the actual files, tracing the dependency graph,
mapping to runtime usage, and classifying intentional layering vs. real risk.
- Android Gradle Plugin 8.0.0 or higher
- Gradle 8.0 or higher
- Applied to
com.android.applicationmodules only. Library modules are not supported.
If you use an AI coding assistant (Claude Code, Copilot, Gemini, Cursor, etc.), reference the setup guide for accurate installation instructions and common pitfalls.
Inspired by dependency-guard and manifest-shield.
