Using AI to Find Dead Code and Unused Resources
R8 and lint already compute reachability properly — use their output as evidence, then use a model for the part they can't do: classifying why a candidate is dead. The categories it finds that static analysis misses, and the four it confidently gets wrong.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — Don't ask a model to find dead code; it can't compute reachability and won't admit it.
Ask R8 (-printusage) and lint (UnusedResources, shrinkResources) — they do real analysis and they
produce evidence files. Then use a model for the part tools genuinely can't do: classifying and
explaining candidates, especially permanently-on feature flags, unreachable navigation destinations,
resources referenced by string name, and transitively dead APIs. It will confidently declare Hilt
providers, Room DAOs and @Serializable classes unused — those deletions fail at runtime, not at
compile time. Model proposes, R8 and a canary rollout prove.
First: the tools already know
R8 performs a full reachability analysis on every release build. Ask it to write down what it removed:
# proguard-rules.pro
-printusage build/outputs/usage.txt
-printseeds build/outputs/seeds.txt
-printconfiguration build/outputs/full-config.pro
./gradlew assembleRelease
wc -l app/build/outputs/usage.txt
usage.txt is every class, field and method R8 stripped. It is not a heuristic. Anything in there is
provably unreachable from your entry points in that build, and it's the best starting list you will
get anywhere.
The complements:
./gradlew lintRelease # UnusedResources
# android.buildFeatures / build.gradle.kts:
# isShrinkResources = true → build/outputs/mapping/release/resources.txt
And when you need to argue with R8 about something it kept:
-whyareyoukeeping class com.app.legacy.OldSyncService { *; }
That prints the reference chain holding it alive. It is the single most useful and least used R8 flag.
So what's left for a model?
R8 solves the shipping problem — dead code doesn't reach users. It doesn't solve the maintenance problem: that code is still in the repo, still in code review, still getting migrated to Compose by someone who assumes it matters, still failing CI when its test breaks.
Turning "R8 removed 400 methods" into "delete these 12 files" is judgement work across many files, and that's the part worth delegating. Four categories where it beats static analysis outright:
1. Permanently-on feature flags. Static analysis sees a live branch, because it is one. A model
given the remote-config export and the code sees that newCheckoutEnabled has been at 100% since
March 2024 and the else branch is 300 lines of unreachable-in-practice code.
"Here's the flag config with rollout percentages and dates, and every call site of
flags.in this module. Which branches have been unreachable for over a year? For each, list the files that only exist to serve the dead branch."
2. Unreachable navigation destinations. A composable, a route constant, and nothing that navigates to it. Reachability across a nav graph, deep-link definitions and the manifest is cross-file reasoning tools do badly and models do well.
3. Resources referenced by name. getIdentifier("ic_theme_$name", "drawable", pkg) defeats lint in
both directions — it can't see those uses, and it can't see that the other 40 icons in the family are
dead. A model reading ThemeLoader and the drawable list will tell you which naming pattern is live.
4. Transitive death. A public function in :core:util with exactly one caller, where that caller
is itself on your dead list. R8 catches this within a build; it's much harder to see across modules
while reading source.
Feed it evidence, not the repository
The failure mode is asking a model to survey a codebase it can't hold. Give it artefacts instead:
Input:
1. usage.txt (what R8 removed from the release build)
2. git log --format='%ad %s' -- <each candidate file>
3. rg -n 'SymbolName' across the repo — full results
4. The DI graph: every @Provides/@Binds in the module
5. proguard-rules.pro, so you can see what's -keep'd
For each candidate, output:
- verdict: dead | reachable | reflection-only | flag-gated
- the reachability argument, in one sentence
- what would break at RUNTIME if it's wrong
The last column is what makes the output reviewable. A verdict is an assertion; a reachability argument is something you can check in ten seconds. And when the argument is "I don't see any references", that is not a reachability argument — it's an absence of evidence, and it's exactly where the reflection cases hide.
The git log line matters more than it looks. "Last modified 2019, author left in 2020, no test file"
is real evidence about maintenance cost, and it's the kind of context a model integrates well.
What it gets wrong — all four are runtime failures
Reflection and code generation. Hilt @Provides, Room @Dao, @Serializable classes, WorkManager
workers instantiated by name, ViewBinding, anything a -keep rule protects. There are no source
references, so it looks dead. Deleting a @Provides compiles fine and crashes on first injection.
Entry points from the manifest. Activities, receivers, providers, exported services. A broadcast receiver invoked only by a system intent has zero call sites in your code.
Debug-only and variant-only code. usage.txt is per-variant. Code alive in debug or in a
region-specific flavour appears in the release build's removal list. Deleting it breaks a build you
don't run locally.
Test-only code. Fakes, test doubles, and utilities that exist purely for androidTest. R8's release
analysis has never heard of them.
The common thread: a model's confidence is uniform across the cases it's right about and the cases it's guessing on. So don't rely on it flagging its own uncertainty — build the check in.
The workflow that's safe
- Generate evidence.
-printusage,lintRelease,resources.txt. Commit these under adocs/deadcode/folder so successive runs are diffable. - Classify with a model, one module at a time, with the reachability argument required.
- Filter mechanically before you read anything: drop every candidate annotated with a DI, Room,
serialization or WorkManager annotation, everything named in the manifest, and everything matching a
-keeprule. This is a five-line script and it removes most of the dangerous suggestions. - Delete one category per PR. All the dead-flag code in one; unused drawables in another. A mixed deletion PR is unrevertable in practice, because reverting it takes back the safe deletions too.
- Prove it. Full build, all variants, unit and instrumented tests. Then diff the new
usage.txtagainst the old — if a deletion was really dead, R8's removal list should shrink by roughly what you removed. A surprise there means something you deleted was reachable. - Canary. Ship deletions to a small rollout percentage first, with crash-free-rate halt criteria. Reflection failures show up in hours.
Rough calibration from doing this: of R8's removal list, about two thirds is genuinely deletable
source, a quarter is variant- or test-only, and the rest is reflection-reached code that R8 removed
because a -keep rule was missing — which is a separate bug, and finding it is a nice side effect.
The rule
The model proposes a hypothesis with an explanation; R8, the compiler, and a canary supply the proof. Never delete on confidence alone, and never delete a category whose reachability argument you can't state out loud. Dead code removal is one of the few refactors with no user-visible upside — so the only acceptable outcome is that nobody ever notices it happened.