Dev.to Security 🔐 Cybersecurity 👁 0 📖 10 min read

Your Keep Rules Are Costing You: Auditing R8 with the Configuration Analyzer

Most release builds ship with a proguard-rules.pro that nobody has read in two years. R8's Configuration Analyzer finally lets you put a price on every line of it. The quiet tax Turning on R8 is the easy part

Your Keep Rules Are Costing You: Auditing R8 with the Configuration Analyzer

Most release builds ship with a proguard-rules.pro that nobody has read in two years. R8's Configuration Analyzer finally lets you put a price on every line of it.

The quiet tax

Turning on R8 is the easy part. The hard part is that every -keep rule is a small promise to R8: "don't touch this." One careless wildcard can exempt thousands of classes from shrinking, optimization and renaming, and nothing fails. The app launches, the crash rate stays flat, and the only symptom is an APK that is a little fatter and a cold start that is a little slower than it should be.

The Configuration Analyzer turns that invisible tax into a report. This post walks through how to generate it, how to read it, and how to act on it without breaking production, using a fictional tide-tracking app, Tidewatch, as the running example. Package names, rules and code below are invented for this article.

Prerequisite: the analyzer needs AGP 9.3.0 or newer, or a sufficiently recent R8 swapped in for older AGP versions. The official documentation lists the exact versions.

Three scores, three payoffs

The report’s headline numbers are shrinking, optimization and obfuscation scores. Each is the share of your classes, fields and methods that R8 is permitted to act on for that purpose.

three-scores

One scoping note: these scores count classes, fields and methods only. Removing unreferenced resources (res/ files) is the job of Android's separate resource shrinker (shrinkResources), so a keep rule never affects resource shrinking and the report does not score it.

Two things are worth internalizing before you open the report:

  • A score is a headroom measure, not a grade. Code that is genuinely reached through reflection must stay protected. A score of 100% would usually mean your keep rules are broken, not perfect.
  • The three scores move independently. A rule written with narrower modifiers can block renaming while still permitting shrinking. Always look at which property a rule blocks, not only how much.

Getting the report

There are three routes, and they differ mainly in cost.

report-lanes

For day-to-day tuning, use the standalone task. It stops before packaging, which is exactly what you want when you are editing a rule and re-measuring ten times in an hour:

./gradlew :app:analyzeReleaseR8Config

If you only ever want the report from full builds, it is also emitted during assembleRelease. A Gradle property exists to switch that off if you find it noisy (see the docs for its exact name).

On AGP 9.2 or older, you pull a newer R8 onto the settings classpath temporarily and pass a system property naming the output directory. Treat that as a one-off investigation tool. Do not commit a floating R8 override into the repo.

The patient: Tidewatch’s rules file

Here is the file we inherit. Every line was added by someone solving a real problem on a real Friday.

# app/proguard-rules.pro

# "Temporary" fix from the 2.3 release, after export stopped working
-keep class com.tidewatch.** { *; }

# Gson models
-keep class com.tidewatch.data.remote.dto.** { *; }

# Plugin loading
-keep class com.tidewatch.export.** { *; }
-keep class com.tidewatch.export.ExportJobFactory

# Removed analytics module long ago
-keep class com.tidewatch.legacy.TrackerShim { *; }

# Copied from a blog post, also present in :feature-map's consumer file
-keepattributes Signature
-keepattributes Signature

Even without tooling, you can feel the problem. With the analyzer, you can quantify it. Sort or scan the rules by the share of items each one blocks, and the first line will dominate the report. A package-wide wildcard on the app’s root namespace effectively opts your entire first-party codebase out of everything.

Triage: three questions per rule

Resist the urge to start deleting. For each high-impact rule, ask the same questions in the same order.

triage-flow

The second question is the one that matters, and it is where teams get lazy. “Is this reached by reflection?” is answerable. Search for Class.forName, getDeclaredMethod, JNI callbacks, serialization libraries that read fields by name, and anything that resolves a class from a string, whether remote config, a manifest, or a deep link.

Fix one: delete the rule by deleting the reflection

The best keep rule is the one you no longer need. Tidewatch loads export formats from a string supplied by server config:

// Before: a string becomes a class name. R8 cannot see this edge.
fun createFormat(id: String): ExportFormat? = runCatching {
    val name = "com.tidewatch.export.${id.replaceFirstChar(Char::uppercase)}Format"
    Class.forName(name).getDeclaredConstructor().newInstance() as ExportFormat
}.getOrNull()

Notice the runCatching { ... }.getOrNull(). In a release build with an over-aggressive shrink, ClassNotFoundException is swallowed and the export option simply vanishes. That is how the "temporary" wildcard got added in the first place: nobody saw a crash, only a missing feature.

Replace the string-to-class leap with something the compiler and R8 can both follow:

interface ExportFormat {
    fun write(report: TideReport, out: OutputStream)
}

object ExportFormats {
    private val factories: Map<String, () -> ExportFormat> = mapOf(
        "csv" to ::CsvFormat,
        "gpx" to ::GpxFormat,
        "json" to ::JsonFormat,
    )

    val supportedIds: Set<String> get() = factories.keys

    fun create(id: String): ExportFormat? = factories[id]?.invoke()
}

Constructor references are ordinary code references. R8 sees every edge, keeps exactly what is reachable, and can inline and merge freely. Two rules disappear from the file, and the feature fails loudly at compile time if someone renames a class.

Fix two: narrow what genuinely needs protecting

Some reflection is not worth removing. Tidewatch parses API responses with Gson, which reads and writes fields by name:

data class TideDto(
    @SerializedName("station") val station: String,
    @SerializedName("heightCm") val heightCm: Int,
    @SerializedName("flags") val flags: List<String> = emptyList(),
)

The old rule kept every member of every DTO under their original names. The reflective surface is much smaller than that: Gson needs the annotated fields to be retained, and the annotation tells it their JSON names, so the Kotlin names can still be obfuscated.

-keepattributes Signature, *Annotation*

-keepclassmembers,allowobfuscation class com.tidewatch.data.remote.dto.** {
    @com.google.gson.annotations.SerializedName <fields>;
}

Signature matters because List<String> is a generic type, and Gson recovers element types from signature metadata that R8 would otherwise strip.

If the codebase is Kotlin-first and you control the serialization layer, consider avoiding the question entirely:

@Serializable
data class TideDto(
    val station: String,
    val heightCm: Int,
    val flags: List<String> = emptyList(),
)

With kotlinx.serialization, generated serializers replace field-name reflection for ordinary classes, and flags genuinely defaults when absent. (Under Gson, a class without a no-arg constructor can be instantiated without running Kotlin default initializers, so the same missing key can leave you with a null in a non-null List. That is a Gson-and-Kotlin trap unrelated to R8, but it tends to surface during the same migrations.) Either way, re-run the analyzer afterward and confirm the numbers moved.

Subsumed, unused and identical rules

Three categories of waste do not require any reflection analysis at all, only housekeeping.

Subsumed rules happen when a broad rule already covers the target of a narrower one. In Tidewatch, -keep class com.tidewatch.export.** swallows the ExportJobFactory rule below it.

subsumed-rules

The decision depends on which rule matches what you actually found when you audited reflection. If the narrow rule is the precise one, delete the broad one and a whole package comes back into play. If the broad rule was aimed correctly, tighten it and drop the redundant narrow rule.

Unused rules match nothing in the current build. TrackerShim is gone from the codebase, and the rule has been dead weight since. Identical rules repeat the same target, either within one file or across files; the doubled -keepattributes Signature line is the harmless version, but duplicates of expensive rules make audits miserable.

These are the lowest-risk deletions in the whole exercise. Do them first and bank the clarity.

Libraries: rules you did not write

Third-party libraries ship consumer rules, and their authors cannot know how you use them. Some err broad, and that breadth lands on your app’s score. The analyzer attributes rules to their source, so you can see which dependency is responsible.

You have two levers. File an issue with the library, with the report data as evidence, after searching for an existing one. Or, to size the opportunity before committing to anything, filter a specific library’s rules out locally and re-run the analyzer to measure the delta. The documentation’s guidance on choosing libraries wisely covers the filtering mechanism.

Also remember that the report includes the default rules AGP itself supplies. Leave those alone; they are written to preserve as much optimization as possible.

Make it a loop, not a project

tightening-loop

A single audit decays. New dependencies arrive with new rules, and new reflection sneaks in. Put the report where people will see it:

// app/build.gradle.kts
tasks.register<Copy>("publishR8Report") {
    group = "verification"
    description = "Runs the R8 configuration analyzer and copies the HTML report for CI."
    dependsOn("analyzeReleaseR8Config")
    from(layout.buildDirectory.file("reports/r8/r8-config-analyzer-release.html"))
    into(rootProject.layout.buildDirectory.dir("ci-artifacts"))
}

Archive that file from your pipeline and review it when proguard-rules.pro, a consumer rules file, or a dependency version changes. Trend lines beat snapshots.

Real-world failure modes

These are the ways this exercise goes wrong in practice.

1. Deleting a rule the debug build never exercises. R8 is typically off or lenient in debug, so everything passes locally. The crash lives on a path that only a push notification, a rare deep link, or an uncommon locale touches. The fix is process: run your instrumented tests against a minified build, not just unit tests on the JVM.

// build.gradle.kts (app)
android {
    testBuildType = "release"   // instrumented tests run against the minified variant
}
@RunWith(AndroidJUnit4::class)
class ExportRegistrySmokeTest {
    @Test
    fun everyAdvertisedFormatStillResolves() {
        ExportFormats.supportedIds.forEach { id ->
            assertNotNull("Export format '$id' is missing in the minified build", ExportFormats.create(id))
        }
    }
}

2. Silent failure through runCatching. Reflection wrapped in a catch-all converts a build-time defect into a quiet feature outage. When you tighten rules, grep for swallowed ClassNotFoundException and NoSuchMethodException first, because those are the places that will lie to you.

3. Picking the wrong survivor in a subsumption. Two overlapping rules are not always equivalent. The narrow one may carry different modifiers or member selectors than the broad one. Before deleting either, compare what each retains and what each still allows, not just their package prefixes.

4. Score chasing. The score counts items, not importance. Five percent of your code blocked might include your entire startup path, while the next fifteen percent might be dead screens nobody visits. Rank by what the blocked code does for users, not by the number alone. This is my own judgment rather than something the report computes, so bring your profiler data to the decision.

5. Harder crash reports. More obfuscation means stack traces that are less readable, because names you used to keep are now shortened. Make sure the mapping file for every release is uploaded to your crash reporter and archived, before you widen what R8 can rename.

6. One variant audited, five shipped. Flavors and build types can contribute different rules. Verify the exact task name for each variant with ./gradlew tasks and audit the ones that reach users, not only the default release.

7. Leaving a temporary R8 override in place. On older AGP, the classpath trick is meant for analysis. Committing it silently changes your production compiler. Keep it in a local patch or a throwaway branch.

A short checklist

  • Generate the report with the standalone task and read the top blockers first.
  • Delete unused and identical rules; they carry no reflection risk.
  • For each broad rule, find the actual reflective call site, or prove there is none.
  • Prefer removing reflection (registries, constructor references, generated serializers) over keeping around it.
  • Where reflection stays, keep the smallest member set with the least restrictive modifier that still works.
  • Test the minified build, upload mappings, and re-run the report after every change.

Keep rules are easy to add and almost never removed, which is exactly why the file ends up as a ledger of old emergencies. The analyzer gives you the audit trail to close those accounts one rule at a time.

Reference: Use R8 Configuration Analyzer, Android Developers documentation. Check it for current version requirements, since the tooling is evolving.

📱 Level Up Your Android System Design Game

If you’re prepping for a senior Android role and system design rounds are the part that makes you sweat, I highly recommend “Mastering Android System Design” — a 37-chapter playbook that treats Android like the constrained runtime it actually is, not a small server. It covers Binder IPC, ART’s GC, process death, and background execution limits, then walks through the CRDDS interview framework and 13 full worked case studies (chat, ride-hailing, video streaming, payments, and more) carried end-to-end through API design, offline sync, and the Android-specific edge cases that trip candidates up mid-interview.

Originally published on Medium.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.