跪拜 Guibai
← Back to the summary

Obfuscation Is for Size, Not Security — How One Keep Rule Sabotaged It for Two Years

On a Friday night, I dragged a freshly built release APK into jadx to check whether an interface signature had been changed. The package opened and I froze: UserRepositoryImpl, HealthRecordViewModel, LocationPermissionHelper — none of the business class names had been touched, the package structure was intact, even method names were original. I'd had minifyEnabled true on for nearly two years, and the obfuscation had done nothing.

I opened proguard-rules.pro, and the second line held this:

# Historical leftover, nobody dares to delete
-keep class com.example.** { *; }

The person who wrote that line had left the company long ago; the comment "nobody dares to delete" was added by someone during a later release. It kept the entire business package, classes and members, so R8 could neither delete nor rename anything. Even more embarrassing was that the evidence had been sitting in plain sight — seeds.txt was thicker than mapping.txt, tens of thousands of lines, and I'd looked at it for two years without noticing anything wrong.

That night I deleted the rule, ran the build, and three hours after a staged rollout I got a string of ClassNotFoundExceptions. Then I spent two days patching the reflection, JNI, serialization, and Compose holes one by one. When I was done, a question popped into my head: who exactly am I protecting against?

I went digging into the history of this thing, and the answer was surprising. Obfuscation was never originally meant to deter cracking; it's a byproduct of saving space. Once that understanding flipped, every trade-off changed — including how I should give requirements to Trae.

What obfuscation originally solved: "it doesn't fit"

Back in 1996, when Java was just gaining traction, someone noticed something: the language's bytecode was way too easy to decompile.

The reason lies in the JVM's design. .class files, to support lazy linking and dynamic loading, leave class names, method names, field names, and descriptors in the constant pool verbatim. The JVM is a simple stack machine with no general-purpose registers, its instruction semantics are relatively high-level, and control flow structures are clean. IBM's Daniel Ford wrote a decompiler called Jive in May 1996; a month later, Dutch developer Hanpeter van Vliet released the beta of Mocha — the first publicly available Java decompiler, capable of turning bytecode back into nearly readable source.

Interestingly, van Vliet also released another tool at the same time, a obfuscator called Crema. Forging the spear and the shield, same person, same month. Mocha stirred up considerable controversy, and he once took it down from the web; in December of that year he passed away from cancer at 34. Later, Borland bought the source code for both Mocha and Crema; the decompiler built into JBuilder was based on Mocha, and its bundled obfuscator replaced ASCII variable names with control characters. Then JAD went further — it could automatically turn mangled identifiers back into legal names.

So from day one, obfuscation was not "encryption"; it was wiping semantics from the symbol table. UserRepositoryImpl becomes a, the bytecode is unchanged, and a decompiler can still reconstruct the full logic, it's just tedious for humans to read. That hasn't changed today.

What actually made obfuscation a necessity was something else. On May 1, 2002, Belgian engineer Eric Lafortune committed the first line to the ProGuard repository and released version 1.0 in June. He later said it was just a hobby project, "but I take my hobby projects seriously." At the time, the goal was to slim JAR files for Java applications — desktop, server, and the early mobile devices like J2ME where storage and memory were tight. ProGuard 1.0 had only two features: shrinking (later called tree shaking) and name obfuscation. Bytecode optimization was added in August 2004; method inlining, constant propagation, and more than 200 peephole optimizations came with version 4.0 in 2007.

Why were short names so valuable back then? You have to look at the dex structure. Class names, method names, and field names aren't stored individually in dex; they all go into string_ids / string_data, and type_ids, field_ids, and method_ids reference them by index. The string com.example.app.data.user.HealthRecordRepositoryImpl takes over fifty bytes as UTF-8; changing it to a.b.c takes five. In a moderately sized app with tens of thousands of symbols, that adds up to real bytes, and short strings also have higher reuse rates in the constant pool. In the J2ME era, whether a package fit on a device depended on that margin.

That main thread persists today. In Android's official R8 documentation, the obfuscation section says: "To reduce the size of the DEX file, R8 shortens the names of classes, fields, and methods", with the example com.example.MyActivity becoming a.b.a. It lists obfuscation under "app optimization" alongside method inlining, class merging, and tree shaking — not under security.

I reordered the three words and kept this table in mind when writing rules:

Technique Originally solved Side benefit How to judge it
Shrinking Removes uncalled code, package too big Reduces attack surface usage.txt how much deleted, how much dex shrank
Optimization Run faster, smaller Structural changes make static analysis harder Method count, cold start, package size
Obfuscation Short names save dex space Semantics erased, reverse-engineering cost rises Rename coverage, mapping recoverability

Treating obfuscation as a security technique is a later narrative. At its core it's a size tool. Once you get that, you don't add a pile of conservative keeps to "look more secure" — that would also kill the size optimization. The -keep class com.example.** { *; } at the start is a living example.

How names actually get changed

First, look at what the thing looks like. In a Java .class file, class names, method names, field names, and descriptors all live in the constant pool as CONSTANT_Utf8 entries, like Lcom/example/UserRepository; or (Ljava/lang/String;)Lhealth/Record;. Renaming isn't just replacing one string; every type name that appears in a descriptor has to be changed too, otherwise you get a VerifyError at load time.

At the dex layer, the structure is more compact:

dex
├── header              # magic, checksum, section offsets
├── string_ids          # all strings' index, pointing to string_data
├── string_data         # actual UTF-8 bytes; class/method/field names live here
├── type_ids            # index into string_ids
├── proto_ids           # method signatures (return type + arg list)
├── field_ids           # class_idx + type_idx + name_idx
├── method_ids          # class_idx + proto_idx + name_idx
├── class_defs          # class definitions, superclass, interfaces, annotations
├── code                # bytecode
└── map_list

The name_idx in method_ids points to string_ids, class_idx points to type_ids which then points back to string_ids. Renaming means switching the index targets in these tables in a consistent way so the reference graph stays coherent. That's why obfuscation must be the last stage of the compilation pipeline — earlier inlining and class merging are still changing the structure, and renaming too early would break everything.

The compilation chain has changed once in recent years; the two diagrams side by side show it best:

flowchart LR
    subgraph Old[ProGuard era]
        A1[javac / kotlinc] --> A2[.class / jar]
        A2 --> A3[ProGuard<br/>shrink · optimize · obfuscate]
        A3 --> A4[dx<br/>class → dex]
        A4 --> A5[classes.dex]
    end
flowchart LR
    subgraph New[R8 era]
        B1[javac / kotlinc] --> B2[.class / jar]
        B2 --> B3[R8 single-pass<br/>desugar · tree shaking · inlining/class merging<br/>IR optimization · Minifier · dex]
        B3 --> B4[classes.dex + mapping.txt]
    end

R8 was introduced in AGP 3.3 and became the default in 3.4. Its biggest change wasn't just a tool name swap; it was combining desugaring, shrinking, optimization, obfuscation, and dex compilation into a single whole-program analysis. In the old days ProGuard scanned .class files in multiple passes and dx compiled dex separately, breaking the information flow. Now R8 makes decisions with a complete reference graph and can remove more dead code and merge more classes.

The component responsible for renaming in R8 is com.android.tools.r8.naming.Minifier, which runs in three sub-stages: ClassNameMinifier → MethodNameMinifier → FieldNameMinifier, each step producing a NamingLens for the next. The naming allocation strategy is surprisingly simple: it takes dictionary order a, b, c... then when those run out, aa, ab, and BaseMinificationNamingStrategy.nextString() does exactly that. So the screen full of a.b.c after obfuscation is not random; it's the greedy result of "shortest available identifier" — which circles back to the space-saving line.

Four types of obfuscation, R8 handles only the first two

I've split common techniques into four layers by what they target; this division recurred when I asked Trae for requirements:

Layer What it does R8 does it? Cost
Identifier Class/method/field renaming, package structure scrambling Yes, core ability Reflection, JNI, serialization all need keep rules
Structural Method inlining, class merging, dead-branch elimination Yes, part of optimization Stack traces get shallower, line numbers change, reflection on private members fails
Control flow Flattening, opaque predicates, junk instructions No Package grows, runs slower, easy to pattern-match
Data and strings String encryption, constant hiding, resource name mangling No (resource shrinking is separate) Runtime decryption has overhead, key stays on device

Layers three and four are the domain of commercial solutions like DexGuard, hardening shells, and OLLVM-style native tools. Setting isMinifyEnabled = true only gets you the first two layers; don't expect it to hide hardcoded interface URLs or keys. I hit this cognitive gap in a previous piece on hardening; this time I've written out the boundary explicitly.

Which names can't be touched

Rename freedom is entirely consumed by "things found at runtime via strings":

The trade-off here is painfully simple: the more keeps you add the safer you are (no crashes), but the lower the obfuscation rate and the less size benefit. In a healthy proguard-rules.pro, every keep rule should be able to answer "what scenario would crash if I removed it"; if it can't, it's debt.

mapping.txt: the "undo key" of obfuscation

After renaming, production crash stacks become at a.b.c.a(SourceFile:12). R8 also emits mapping.txt, recording the bidirectional mapping from original to new names:

com.example.app.data.UserRepositoryImpl -> a.b.c.d:
    java.lang.String baseUrl -> a
    14:32:void <init>(android.content.Context) -> <init>
    41:56:public health.Record load(java.lang.String) -> a
    # {"id":"com.android.tools.r8.mapping","version":"2.2"}
com.example.app.ui.health.HealthRecordViewModel -> a.b.e.a:
    ...

With it, retrace can map an obfuscated stack back to source line numbers. The prerequisite is -keepattributes SourceFile, LineNumberTable (AGP 8.6 keeps LineNumberTable by default, 8.2 for SourceFile), plus -renamesourcefileattribute to change real source file names to a single value.

I hold two things as iron rules: mapping must be archived one-to-one with the APK, with file names carrying versionName + commit; if the next build overwrites it, that release's crashes become permanently unreadable. And mapping must never be packaged into the APK or posted anywhere public — once it leaks, obfuscation is basically pointless; with mapping and jadx, the code you get back is only missing comments.

Here's a counterintuitive point: the official docs now call obfuscation "minification" and put more and more effort into recoverability (8.6 improved retrace for low minSdk, 9.x keeps adding). That in itself says the official priority is "optimization + being able to debug when something goes wrong", not "make it impossible for reverse engineers to understand".

A name walks through the whole pipeline

Abstract talk gets muddy. Let's take a real class from our project and walk end to end.

In source it looks like this:

package com.example.app.data.health

class HealthRecordRepositoryImpl(
    private val api: HealthApi,
) : HealthRecordRepository {
    override suspend fun load(userId: String): HealthRecord {
        val raw = api.fetch("/v3/health/records?uid=$userId")
        return HealthRecordParser.parse(raw)
    }
}

Compiled to .class, the constant pool holds these CONSTANT_Utf8 entries:

Lcom/example/app/data/health/HealthRecordRepositoryImpl;
Ljava/lang/String;
(Ljava/lang/String;)Lhealth/HealthRecord;
load
fetch
/v3/health/records?uid=

Into dex, these strings go into string_data, with type_ids / method_ids pointing by index; after R8's ClassNameMinifier, MethodNameMinifier, and FieldNameMinifier run, the indexes stay, but the targets change:

com.example.app.data.health.HealthRecordRepositoryImpl  ->  a.b.c.d
load                                                    ->  a
api                                                     ->  a
HealthRecordParser                                      ->  a.b.c.e

mapping.txt records each mapping, and when jadx opens the final APK it sees:

package a.b.c;

public final class d implements a {
    private final b a;

    public final Object a(String str, Continuation continuation) {
        Object a2 = this.a.a("/v3/health/records?uid=" + str, continuation);
        return e.a(a2);
    }
}

Put the five places side by side and it's clear what obfuscation really changed:

Location What this class looks like Has semantics
Source HealthRecordRepositoryImpl.load(userId) All
.class constant pool Lcom/example/.../HealthRecordRepositoryImpl; All; decompiler can restore source
dex string_ids pointing to a.b.c.d / a Names gone, structure remains
mapping.txt ... -> a.b.c.d All; this is the undo key
jadx restored d.a(String) + plaintext interface path Names gone, logic and strings remain

Look at the last column: what obfuscation erases is the name, not the semantics. The interface URL /v3/health/records?uid=, field names (if the serialization protocol requires them), resource names, class names in annotation values, string constants — all stay as-is. Follow a.a(...) two or three levels up and you can still draw the business structure.

So every time someone asks "doesn't obfuscation prevent my API from being scraped?", I show this table. To truly protect an interface URL, you rely on server-side authentication and not putting keys on the client, not on R8 renaming a class to d. Conversely, the legitimate benefits are real: for this class alone, the class name went from over fifty bytes to six, the method name from four to one, and across the whole project tens of thousands of symbols add up to that 3.5 MB.

Toolchain changes over the past twenty years

I rearranged a timeline by "impact on someone writing rules." Years are usually irrelevant, but when build behavior suddenly changes, knowing them saves half a day of guessing:

Date What happened Actual impact on obfuscation
1996.05 / 06 IBM's Jive, van Vliet's Mocha decompiler and Crema obfuscator Origin is "wipe symbol-table semantics", not encryption
2002.05 / 06 ProGuard first commit, 1.0 release, borrowed Mark Welsh's RetroGuard class-parsing code Only shrinking + name obfuscation, for J2ME size savings
2004.08 / 2007.09 Bytecode optimization added; 4.0 brings inlining, constant propagation, 250+ peephole Obfuscation starts binding with optimization
2010.12 ProGuard integrated into Android SDK with 2.3 Gingerbread proguard-android.txt era begins, minifyEnabled becomes standard
2012 / 2014 DexGuard born; Guardsquare founded String encryption, control-flow obfuscation go commercial; open-source line no longer covers them
2018 · AGP 3.3 R8 introduced as optional (useProguard false), ProGuard remains default Rule syntax carried over, behavior begins to diverge
2019.04 · AGP 3.4 R8 becomes default; desugar/shrink/obfusc/optimize/dex all in one step Official release notes say: may remove code ProGuard wouldn't remove, need extra rules
AGP 7.0 ProGuard removed, useProguard and android.enableR8 switches gone, Java 11 baseline No fallback; full mode appears as opt-in
2023 · AGP 8.0 Full mode becomes default Old-rule semantics change massively; android.r8.failOnMissingClasses defaults on; missing_rules.txt appears
AGP 8.2 / 8.3 -allowaccessmodification default on; SourceFile preserved by default; precise resource shrinking default Private members may become public; stack-trace restoration conditions improve
AGP 8.6 retrace improved for all minSdk (previously 26+), LineNumberTable default on Crashes on low-version devices become restorable
AGP 8.12 optimizedResourceShrinking optional (android.r8.optimizedResourceShrinking=true) Resources first enter R8's reference graph
AGP 9.0 Resource optimization shrinking default; proguard-android.txt no longer supported (it carries -dontoptimize); library global options filtered; Kotlin null-check optimization default A bunch of old build configs error out
AGP 9.1 -repackageclasses default for apps No need to hand-scramble package structure; opt out with -dontrepackage
AGP 9.2 alpha Member name patterns support negation; -keepattributes *Annotation* no longer matches RuntimeInvisible* family Rules that rely on wildcards need rewriting one by one
AGP 9.3 New optimization { enable = true } DSL, keepRules source set, KMP consumer rules Configuration entry point changes entirely
2026.04 Standalone ProGuard project reaches 7.9.1 Source still maintained, but Android line is fully on R8

The most natural illusion from that table is "the rule syntax hasn't changed, so old configs should still work." That's precisely wrong. Syntax hasn't changed; semantics have changed four times.

What full mode actually changed

After AGP 8.0, the default full mode differs from compatibility mode in four ways, each one capable of crashing a five-year-old app in a staged rollout:

Difference Compatibility mode Full mode Typical disaster
Attribute retention Signature, InnerClasses, EnclosingMethod preserved by default Only for explicitly kept classes and members Reflection over generic signatures silently loses types
Default constructors Preserved implicitly when class kept No longer implicit Class.newInstance() / getDeclaredConstructor().newInstance() throws
Visibility Not touched Changed to public to enable inlining Reflection doing getDeclaredMethods() then filtering by private fails
Reference-graph judgment Relatively conservative A class referenced only by a reflection string is deemed unreachable and deleted from dex Plugin systems, SPI, string-based instantiation vanish entirely

The fourth item was the most concentrated class of problems when upgrading to AGP 8: the code clearly has Class.forName("com.example.Foo"), R8 can't see how that string relates to Foo, so it deletes the class directly, and the compile step says nothing. Fortunately the same version gave us android.r8.failOnMissingClasses=true and app/build/outputs/mapping/release/missing_rules.txt, which writes the inferred keep rules there for you to copy. My habit is to copy only that one line and narrow it to the specific class, not to throw in the whole package.

There's an even finer point: starting from AGP 9.0 with android.r8.strictFullModeForKeepRules=true, even a bare -keep class A no longer implicitly preserves the default constructor. The more "proper-looking" your rules, the easier it is to get stabbed by this kind of semantic migration.

Kotlin and Compose made rules simpler, and also more hidden

In Kotlin projects, most keep rules shouldn't be written by humans at all:

I later set a rule: any androidx.compose prefix in proguard-rules.pro is treated as a bug first.

The resource branch goes a different path

Beyond code obfuscation there's a branch called resource obfuscation. Early third-party schemes (the WeChat AndResGuard type) shortened res/drawable/xxx paths to compress the package while also editing resources.arsc. It works well, but the cost is that it breaks on Resources.getIdentifier()-built resource names, hardcoded paths in third-party SDKs, and ROM resource lookups. I've never added such a library to my own projects, consistent with my general bias: don't touch the build pipeline if you can avoid it.

The official line has been gradually taking this over. In the old pipeline, AAPT2 would generate a batch of unconditional keep rules to prevent the case where "a class referenced by a resource gets deleted by R8"; the result was that a pile of code that should have died was kept alive, along with the resources it pulled in. AGP 8.12's optimizedResourceShrinking puts resources directly into R8's reference graph, so code and resources are judged for reachability together, no longer needing those unconditional rules; the official blog says apps that share many resources and code across form factors can see package-size improvements over 50%. AGP 9.0 makes it the default.

For the person writing rules, the practical impact is twofold: stop using tools:keep / tools:discard as the primary mechanism, and make a separate list for dynamically constructed resource names (getIdentifier, server-pushed resource keys), otherwise resource-optimization shrinking will treat them as unused and delete them.

One sentence to sum up the evolution

Obfuscation's center of gravity shifted from "rename so people can't understand" to "whole-program optimization + recoverable when things break." The official line increasingly treats it as a performance tool: inlining, class merging, resource reference graphs, retrace, Startup Profile — all investments along that line. Anyone who still treats it as a "crack-prevention switch" will trip over semantic migration at every AGP upgrade.

Once I understood that, the requirements I gave Trae were completely different from the first time. The first time I said "help me make this project's obfuscation stronger", and it came back with a professional-looking rule file that added three -dontwarn lines, two whole-package keeps, and thoughtfully commented out -dontobfuscate as a backup. Now I ask for: measure first, then delete, and only then add.

The rules I set for Trae

The mode division matches a previous piece on hardening: read code and trace call chains with IDE-mode Chat, attach #Code context; modify files, run builds, iterate on errors with Agent; for checking AGP version behavior and official docs, turn on SOLO with internet. The Builder from last year's tutorials was already merged into SOLO Agent in May 2026, so this time I didn't lose time looking in the wrong place.

.traeignore this time has a different focus. For the hardening piece, the goal was to block keys and signing files. For obfuscation, I actually need mapping.txt, seeds.txt, and usage.txt in the context for analysis, but I don't want tens of thousands of intermediate files under build/ to blow up the index:

# .traeignore
**/keystore/**
**/*.jks
**/*.keystore
local.properties
**/.env
**/.env.*
# build outputs ignored as a whole, only the three archives below get context
**/build/intermediates/**
**/build/tmp/**
**/build/outputs/logs/**
**/build/outputs/apk/**
**/mapping/**
!docs/obfuscation/mapping-samples/**
**/decompiled/**

Project rules live in .trae/rules/obfuscation.md with alwaysApply: true. This is the version I rewrote after deleting that safety-net keep and crashing twice in the staged rollout; each line corresponds to a concrete rework:

---
description: Master rule for modifications to proguard-rules.pro / build scripts / keep rules.
              All conversations touching R8, obfuscation, mapping, or release builds follow this.
alwaysApply: true
---

# Project background

Medical-health Android app, Kotlin + Compose mixed with some XML Views,
multi-module, minSdk 26 / targetSdk 36, AGP 8.x, R8 full mode enabled,
uses kotlinx.serialization, Room, Hilt, several JNI methods and third-party SDKs.

# Goal ordering

The first goal of obfuscation is to reduce DEX size; the second is to raise static-analysis cost.
"Looks more secure" is not a goal. Don't use conservative keeps for psychological comfort.

# Hard constraints

- Do not introduce any third-party obfuscation / hardening / string-encryption library; do not change the build chain.
- Forbidden: `-keep class <business-package>.** { *; }` style safety-net rules,
  and framework-level broad keeps like `-keep class androidx.** { *; }`.
- Every new keep rule must state: rationale (which doc, which runtime behavior),
  the scenario that would crash if removed, and the corresponding regression test case.
  If you can't answer, mark it "pending confirmation" and wait for my decision.
- Do not modify business code to "accommodate" obfuscation. When you find reflection patterns that build class names,
  first give two options: A narrow keep (to class or member) / B change to compile-time constant reference or RegisterNatives dynamic registration,
  with costs, and I pick before you act.
- Do not add `-dontwarn`, `-dontoptimize`, `-dontobfuscate`; if you find existing ones, report separately, don't delete them on your own.
- Release must use `proguard-android-optimize.txt`, not `proguard-android.txt`.
- mapping / seeds / usage must never be packaged into the APK or enter a public repository;
  archive directory name carries versionName + commit; same-name build fails, no overwrite.
- Compose / kotlinx.serialization / Hilt consumer rules are provided by plugins;
  do not hand-write duplicates, only verify they're actually in effect.
- When touching AGP / R8 version behavior, cite version and doc source.
  If unsure, write "needs manual check", don't fill in version numbers or defaults.

# Delivery requirements

- Give current-state data first (seeds / usage / mapping counts, dex size, obfuscation rate),
  then talk about changes; numbers are measured, not estimates.
- Each round lists changed files, validation commands, and archivable outputs.
- Rule files are grouped by "why it's needed" with comments: reflection / JNI /
  serialization / Parcelable / SDK requirements / annotation-generated classes / public API.
- No TODOs, no placeholders, no "and so on".

The prompt is still in five sections, the structure from Trae's official docs, which suits tasks with wide footprint and strict acceptance:

Goal:        What should exist when this round ends, observable and acceptable
Context:     @ which files / what changed last round / current build config
Requirements: Behavior listed item by item
Constraints:  Explicitly what not to touch
Validation:    Which command to run, which file to inspect, what counts as pass

Three fixed lines in the Constraints work better than a long background:

- No third-party obfuscation library, no build-chain changes, no safety-net or framework-level broad keeps
- Don't modify business code to fit obfuscation; for reflection points give me options first
- For AGP/R8 version behavior, cite source; if unsure write "needs manual check", don't fill in

Add one more crucial convention that wasn't mentioned earlier: define "what counts as obfuscated well" first, otherwise Trae will hand back something using its own standards. I use four numbers:

Rename coverage = number of class entries in mapping.txt with `->` / total number of classes participating in obfuscation
seeds count = number of kept classes and members (fewer the better, but must have rationale)
usage count = number of deleted classes and members
dex size    = sum of bytes of classes*.dex (compared to non-obfuscated baseline)

All four are mandatory. If you only look at coverage, it can use -dontshrink to keep all classes and produce a pretty number; if you only look at size, it might aggressively delete code until features vanish. Every subsequent Validation revolves around these four numbers.

Twelve rounds of prompts, in the order I ran them

The order came from stepping on rakes: the first four rounds are diagnostics, version checks, consolidating rule sources, and deleting safety nets — not a single new keep is written; the middle five rounds touch rules and dictionaries; the last three do validation, CI gates, and rollback. Measuring before changing saved at least two rounds of rework compared to just saying "rewrite the obfuscation rules."

Each round specifies the mode, @ files, the exact prompt, what I actually changed, how I accepted it, and how I chased down drift. The prompt blocks are copy-paste ready.

Round 1 · Baseline health check

IDE mode / Chat, #Code attached to all rule files and build scripts, explicitly no file modifications.

Goal: produce docs/obfuscation/baseline.md, state this project's current obfuscation status,
      analysis only, no modification of any file.

Context: @app/proguard-rules.pro @app/build.gradle.kts
        @gradle.properties @gradle/libs.versions.toml
        plus each module's consumer-rules.pro and build.gradle.kts.
        @docs/obfuscation/mapping-samples/ has archived mapping.txt / seeds.txt / usage.txt
        from the last release.

Requirements:
1. List all effective keep rule sources, separated into five categories with source file:
   module proguard-rules.pro / library module consumer-rules.pro /
   aapt2 generated / Gradle plugin generated /
   default rules from getDefaultProguardFile.
2. Find all "broad rules": matches covering more than one package,
   with `{ *; }` for all members, or with `**`;
   explain for each what classes it covers and why it's dangerous.
3. Find all -dontwarn / -dontoptimize / -dontobfuscate /
   -dontshrink, indicate who added them and whether they can be removed.
4. Report current AGP / Gradle / R8 versions, and whether full mode is enabled
   (check gradle.properties for android.enableR8.fullMode=false).
5. Using archived samples, compute four numbers: rename coverage, seeds count,
   usage count, total bytes of classes*.dex. Give the calculation method and commands.
6. A separate section "Content that cannot be inferred from build configuration":
   which classes are actually loaded via reflection, which resources are found via getIdentifier,
   and whether any external SDK documentation requires keeps — list for manual completion.

Constraints: Do not modify any file; do not add dependencies;
numbers come from archived sample measurements, no estimates;
if inference is impossible write "pending confirmation", don't fill in a plausible value.

Validation: I cross-check seeds count vs rule count,
and use `unzip -l <apk> classes*.dex` to recheck the dex size;
if mismatched, point out the difference and redo the report.

The result this round was uglier than I'd guessed: seeds had over 37,000 lines, and rename coverage was under 10%. Besides the -keep class com.example.** { *; }, there were two -keep class com.third.** { *; } lines copied from SDK docs, and five -dontwarn lines — added by a predecessor to make the build pass, suppressing warnings while the problems stayed.

I followed up with a question to pin down who added what:

List git blame information for each broad rule and dontwarn
(commit hash, time, commit message), categorized as: has explicit doc rationale /
historical leftover with no rationale / added to suppress build warnings.
Do not suggest deletion, only give facts; I decide.

That follow-up ended up in the remediation list, far more useful than a description like "optimize obfuscation config."

Round 2 · Verify current version behavior

SOLO mode, with internet, have it pull official docs itself.

Goal: produce docs/obfuscation/version-behavior.md,
      listing every version behavior in the current toolchain that changes keep-rule semantics.

Context: @docs/obfuscation/baseline.md @gradle.properties
        verify online against the original text, don't recite from memory:
        - Android Developers "Enable app optimization with R8" and
          the "AGP and R8 version behavior changes" table
        - AGP 3.4.0 release notes (the R8-becomes-default section)
        - AGP 8.12 / 9.x notes on optimizedResourceShrinking,
          proguard-android.txt, repackageclasses, keepRules source set
        - R8 retrace and mapping official docs

Requirements:
1. Table: AGP version / change point / original quote / which existing rule it affects /
   what to do / doc link.
2. A separate section on full mode vs compatibility mode differences,
   covering at least four categories: attribute retention, default constructors, visibility changes, reflection-based class deletion,
   each with a minimal reproducible code example.
3. Explicitly answer: what file does this project's
   getDefaultProguardFile("...") return, does it carry -dontoptimize, and does the current AGP still support it?
4. Explicitly answer: does this project set android.r8.optimizedResourceShrinking,
   and under the current AGP is it default or manual?
5. List "if we upgrade to AGP 9.x next year, which of the existing rules will break first",
   sorted by risk, each with a migration step.

Constraints: Each conclusion carries version and source; distinguish "explicitly required by docs" from "engineering inference";
if unsure write "needs manual check", don't fill in version numbers or defaults;
modify no files.

Validation: I cross-check each item against official docs,
focusing on the four full-mode differences and 9.x rule filtering;
if any one mismatches, regenerate the whole table.

The most valuable thing that round wasn't the table; it was turning "syntax unchanged, semantics changed" into four reproducible minimal examples. I wrote a small test relying on generic-signature reflection, and in full mode it indeed silently failed to get the type, with zero compile-time errors. That kind of thing is hard to feel by reading release notes.

It did make one error: it said AGP 9.3's new DSL was supported in all versions. I sent it back:

9.3's `optimization { enable = true }` and the keepRules source set
are only valid in that version and later; mark it clearly and note that legacy DSL is still supported.
Do not present future-version behavior as the current project's status.

Round 3 · Where do the rules come from, who overrides whom

IDE mode / Chat.

Goal: produce docs/obfuscation/rule-sources.md,
      reconstruct the exact rule set that ultimately participates in the build, and mark conflicts.

Context: @docs/obfuscation/baseline.md
        @app/build/outputs/mapping/release/configuration.txt
        (R8 writes the final merged config here; adjust path to actual artifact)

Requirements:
1. From R8's configuration.txt (or equivalent artifact), reconstruct the
   full effective rule set for this release, annotated by source section.
2. Identify duplicate rules, contradictory rules
   (e.g., same spot both kept and assumed no side effects),
   and patterns overridden by later rules, listing each.
3. For each library module, confirm whether its consumer-rules.pro was picked up by the main project;
   if not, list module name and reason.
4. Confirm kotlinx.serialization / Hilt / Room / Compose
   plugin-generated rules are actually effective, point to artifact path;
   don't make me hand-write them again.
5. Give a locally reproducible command to "print the final effective config",
   and archive output to docs/obfuscation/evidence/.

Constraints: Do not modify any rule file;
do not merge or simplify rules, only restore and annotate;
if an artifact isn't found, say clearly "not local, use which task to generate".

Validation: I compare configuration.txt with baseline.md across source annotations,
pick three rules at random and locate the original file.

Nobody usually checks "who overrides whom" until one day you find a keep you wrote never took effect. This round I found two library modules whose consumer-rules.pro wasn't being picked up by the main project — consumerProguardFiles was misconfigured, and it had been masked by a broad keep in the main project, so it never surfaced.

Round 4 · Tear down the safety-net keep

IDE mode / Agent, the first round that actually touches files.

Goal: delete or narrow the broad keep rules listed in baseline.md,
      produce a rule diff and regression checklist, no new keeps in this round.

Context: @app/proguard-rules.pro @docs/obfuscation/baseline.md
        @docs/obfuscation/rule-sources.md
        @.trae/rules/obfuscation.md

Requirements:
1. Handle each broad rule one by one: delete those that can be deleted directly;
   narrow the rest to specific classes or members, stating rationale.
   Each gives "original rule / new rule / which classes affected / risk scenario after deletion".
2. No new keeps or -dontwarn in this round.
   Where a keep is needed, record it in a "pending list"
   and handle it next round grouped by reflection / JNI / serialization.
3. Preserve the existing grouped comment structure in the rule file;
   each change should be visible in the diff.
4. Run :app:assembleRelease once, archive the new
   mapping.txt / seeds.txt / usage.txt to docs/obfuscation/evidence/round-04/,
   and compute the same four numbers as in Round 1, making a before/after table.
5. Produce docs/obfuscation/regression.md:
   list the features that must be manually verified after deleting these rules,
   grouped by "reflection / JNI / serialization / component startup / SDK init / Compose first screen",
   each with a concrete verification path.

Constraints: No business-code changes; no dependency additions;
no safety-net or framework-level broad keeps;
on build failure, report full error first, don't auto-add -dontwarn;
numbers are measured, not estimates.

Validation: Build passes; the four-number before/after difference is in the comparison table;
regression.md every item has an executable verification path,
if not, mark "pending confirmation".

When this version finished, rename coverage jumped from under 10% to over 80%, and dex shrank by nearly 4 MB. I was pretty pleased and shipped it straight to a staged rollout; three hours later came the ClassNotFoundException from the opening. The problem wasn't Trae — its regression.md had explicitly listed "reflection to build class name", and I hadn't run it.

Later I had it redo the round with a hard constraint added:

Append a "automated or manual" flag to the end of each item in regression.md,
write an instrumentation test on the spot when possible,
if not automatable, explicitly write "must be manual" and give steps.
Don't leave everything as a to-do list; I can't see priority.

After the change, 11 of the 17 items became runnable tests; the remaining 6 were the ones I actually had to click through.

Round 5 · Pick out what must stay, one by one

IDE mode / Agent. This is the most human-intensive round, and the only one that "adds rules."

Goal: generate minimal keep rules for reflection / JNI / serialization / Parcelable / SDK requirements,
      each with rationale and regression case, produce docs/obfuscation/keep-rationale.md.

Context: @app/proguard-rules.pro @docs/obfuscation/regression.md
        @docs/obfuscation/rule-sources.md
        @.trae/rules/obfuscation.md

Requirements:
1. Statically scan the whole project for all "find class or member by string" patterns:
   Class.forName / ClassLoader.loadClass / getDeclaredMethod /
   getDeclaredField / Method.invoke / string-concatenated package names / class names in annotation values /
   Resources.getIdentifier / reflection invoking Saver or Creator.
   Each with file and line, marked "R8 can see statically" or "cannot see".
2. For those R8 cannot see, give two options for each:
   A narrow keep (exact to class or member),
   B change to compile-time constant reference or RegisterNatives dynamic registration, eliminating reflection at the root.
   List costs for each; don't pick one and act, wait for my confirmation.
3. JNI: list all native methods and their classes,
   confirm whether -keepclasseswithmembernames class * { native <methods>; }
   covers them; give a cost estimate for switching to RegisterNatives.
4. Serialization: verify kotlinx.serialization / Room / Hilt plugin-generated rules
   are effective (point to artifact path), only handle the custom parts the plugins don't cover;
   don't duplicate framework rules. Parcelable's CREATOR is a separate group.
5. Each new rule is grouped by "why it's needed", with comments:
   rationale (doc or runtime behavior), the scenario that would crash if removed, and which regression case it maps to.
6. Output keep-rationale.md: rule / rationale / risk scenario / regression case /
   automatable or not, all five columns present.

Constraints: No dependency additions; no business-code changes (option B only as a proposal);
forbidden: -keep class ** safety nets and framework-level broad keeps;
forbidden: -dontwarn to suppress build warnings;
scope of every rule narrowed to the smallest class or member;
if rationale is uncertain write "pending confirmation", don't invent SDK doc requirements.

Validation:
- ./gradlew :app:assembleRelease passes
- cross-check each rule in keep-rationale.md against seeds.txt,
  confirm no rule accidentally covers a whole package
- compute new four numbers, compare with last round, explain how much coverage dropped and why it's worth it
- run all "automated" cases in regression.md

Trae's first version that round gave me -keep class **.Saver { *; } with a plausible-sounding justification: "Compose's rememberSaveable Saver might be reflection-accessed." In fact, most Savers can be traced through non-reflection call chains and R8 keeps them itself; this broad keep instead blocks inlining and elimination. My follow-up to reject:

Explain which of these Savers are actually loaded by name via reflection.
Give call-site file and line; delete every one without a call site,
and mark in the regression list "which pages need verification after deletion".

After deletion, only two remained that truly needed keeping, and both were member-precise rules like the INSTANCE field. This kind of "looks reasonable but no evidence" rule is the biggest silent killer of obfuscation rate.

Another rework: it wanted to hand-write three keeps for a @Serializable class. I told it to check rule-sources.md first for plugin-generated rules; found the plugin already covered it, and the hand-written copy was pure duplication. That later became a fixed constraint in the Rules.

Round 6 · Reproduce the four full-mode differences, one by one

IDE mode / Agent, run on a real device.

Goal: verify with minimal cases whether full mode touches the project's existing code,
      produce docs/obfuscation/evidence/full-mode-check.md.

Context: @docs/obfuscation/version-behavior.md
        @app/proguard-rules.pro @docs/obfuscation/keep-rationale.md

Requirements:
1. For the four differences between full mode and compatibility mode, write an instrumentation test each
   (using the project's existing test framework, no new dependencies):
   a) generic-signature reflection: get generic parameter type via Signature attribute
   b) default constructor: Class.newInstance() / getDeclaredConstructor().newInstance()
   c) visibility change: getDeclaredMethods() then filter by private
   d) classes referenced only by reflection string: confirm they still exist in dex
2. Each test first states "if R8 touched this, how would it fail",
   then gives test code and expected result; after running, write the actual result into the evidence file.
3. For failures, give two options: A narrow keep / B change code to remove implicit dependency,
   list costs, wait for my confirmation; do not auto-widen rules.
4. Check if app/build/outputs/mapping/release/missing_rules.txt exists and what it contains;
   compare its inferred rules one by one with keep-rationale.md, adopt only those with a clear call site, narrowed to concrete class.
5. Confirm android.r8.failOnMissingClasses current value,
   and whether the build failed due to missing classes; if so, list specific classes and dependency source.
6. Output a list "implicit behaviors still relied upon under current full mode",
   marking the ones to recheck first at the next AGP upgrade.

Constraints: No new test framework; no business-code changes (option B only as proposal);
no safety-net keeps; no -dontwarn;
on test failure, report the full case and stack first, do not hide by changing rules.

Validation: all four tests conclude (pass or explicitly fail);
full-mode-check.md has raw output for each case;
every adopted rule can be traced to missing_rules.txt or a call site.

One of the four failed: Class.newInstance() couldn't get the default constructor, exactly the second full-mode difference. Two options sat in front of me — add a void <init>(); keep, or change that instantiation to a factory method. I chose the latter and incidentally cleaned out a piece of decade-old code. AI is quickest at these patches, but if you don't ask, you never know what debt you're repaying.

Round 7 · Dictionaries and package structure; I tried and reverted to defaults

IDE mode / Agent. The conclusion this round was "don't do it", but the process is worth recording.

Goal: evaluate custom obfuscation dictionaries and package-scrambling for this project,
      measure before deciding whether to enable.

Context: @app/proguard-rules.pro
        @docs/obfuscation/evidence/round-04/ (archived mapping and dex from last round)
        @.trae/rules/obfuscation.md

Requirements:
1. Generate three candidate dictionaries:
   a) single-letter shortest identifiers (close to R8 default)
   b) common words
   c) non-ASCII / invisible chars
   write to scripts/obfuscation/dictionaries/, each noting
   legal-identifier constraints (which chars are valid in dex, which get rewritten by decompilers).
2. Build three APKs using -obfuscationdictionary / -classobfuscationdictionary /
   -packageobfuscationdictionary, each recording: dex size, mapping readability,
   actual identifier appearance after jadx decompilation, build time.
3. Separately evaluate -repackageclasses:
   list all code that searches or filters by package-name string,
   explain whether scrambling the package structure affects it;
   under the current AGP version is it default or manual, cite version and source.
4. Evaluate impact of -adaptclassstrings / -adaptresourcefilenames.
5. Give a conclusion table: each option's benefit, risk, and whether recommended.
   If benefit is negative, write directly "not recommended", don't invent a benefit to justify.

Constraints: No third-party libraries; no business-code changes;
do not enable dictionaries that make dex bigger;
for AGP default behavior cite version and source, if unsure write "needs manual check";
all three candidate APKs must be reproducible from clean builds.

Validation: three sets of measured numbers side by side;
if any set differs from expectation, explain before changing data;
each "recommended" in the conclusion table must be backed by numbers.

The result was counterintuitive: fancy dictionaries are negative-value. Non-ASCII characters do work as identifiers at the dex level, but decompilers like jadx automatically convert them to legal names — JAD was doing that back in the 1990s, so it's wasted effort; and longer identifiers grow string_data, making dex fatter. The default shortest name is both the most space-saving and the least likely to have compatibility issues.

-repackageclasses did stay: after scrambling the package structure, dex shrank a bit more, and the project has no code that filters by package-name string. But it gave me a reminder — the current AGP version needs it handwritten, while AGP 9.1 turns it on by default for apps. When upgrading, I'll need to delete that rule or have duplicates. I had it list these "version-dependent config debts" as its own table, tied to version-behavior.md.

This round I deliberately wrote "if benefit is negative, write 'not recommended'" into the prompt. Without that line, it would have spun all three options into "each has its use case."

Round 8 · The resource half; don't let dynamic resource names get deleted

IDE mode / Agent.

Goal: align resource shrinking with code obfuscation, produce a dynamic-resource-name list and verification report.

Context: @app/src/main/res/ @app/build.gradle.kts
        @docs/obfuscation/version-behavior.md
        @gradle.properties

Requirements:
1. Statically scan all dynamic resource references:
   Resources.getIdentifier, resource names built by string concatenation,
   server-pushed resource keys, resources explicitly required by SDK docs,
   resources referenced in AndroidManifest / notification icons / shortcuts.
   Each with file and line, marked "R8 visible" or "not visible".
2. For invisible ones, state whether the current AGP resource-shrinking pipeline would delete them,
   give the minimal tools:keep in res/raw/keep.xml,
   or the cost of switching to compile-time R.xxx reference; wait for my confirmation before acting.
3. Confirm isShrinkResources and isMinifyEnabled are both on,
   and check whether android.r8.optimizedResourceShrinking is default or manual in the current AGP (cite version and source).
4. Build two APKs (with and without resource shrinking),
   record APK size, resources.arsc size, res/ entry count,
   and classes*.dex size, make a comparison table.
5. Produce docs/obfuscation/dynamic-resources.md:
   list, retention method, and corresponding verification path (how to click to each dynamic resource name).
6. Output docs/obfuscation/evidence/resource-check.md,
   including raw numbers from both builds and sampled aapt dump resources output.

Constraints: No third-party resource obfuscation library; no modification to resources.arsc generation;
no business-code changes (proposal only);
tools:keep scoped to specific resources, not directory wildcards;
numbers are measured, not estimates.

Validation:
- both APKs install and pass through dynamic-resource-related pages
- walk through every verification path in dynamic-resources.md
- confirm retained items exist using aapt dump resources

The first version gave me a tools:keep="@drawable/*" with the reason "notification icons might be dynamically referenced." I made it turn "might" into a call site; in the end only three specific icons remained. That kind of wildcard is the resource version of a safety-net keep: it eats the shrink benefit and leaves a convincingly safe illusion.

Round 9 · Lose mapping, and obfuscation is pointless

IDE mode / Agent. I waited a long time to do this one; the cost was reading a production crash in despair.

Goal: establish versioned archiving for mapping / seeds / usage and a crash-restore drill,
      producing a reproducible retrace record.

Context: @app/build.gradle.kts @docs/obfuscation/baseline.md
        @.trae/rules/obfuscation.md

Requirements:
1. Write a Gradle task that, after assembleRelease, copies
   mapping.txt / seeds.txt / usage.txt / configuration.txt
   to docs/obfuscation/mapping/<versionName>-<gitShortCommit>/,
   with a build-info.json in the directory: versionName, versionCode, commit,
   build time, AGP / R8 versions, rule-file hash, APK SHA-256.
2. Archive path must correspond one-to-one with the APK artifact;
   no names like "latest" or "current" that get overwritten.
3. Verify -keepattributes SourceFile, LineNumberTable and
   -renamesourcefileattribute current settings,
   explain which are default in current AGP and which are manual (cite version and source).
4. Construct a synthetic obfuscated stack, use retrace from current cmdline-tools
   to restore to source line, write the command, mapping path,
   and before/after stack text into the evidence file.
   Also run with deliberately wrong mapping, record what happens.
5. Statically inspect build outputs: confirm mapping.txt is not in the APK,
   not in assets/res, not in a public version-control directory.
6. Explain how the crash platform (Play Console or the project's existing crash SDK)
   accepts mapping uploads; only write the one the project actually uses,
   if uncertain mark "pending confirmation", don't invent an API.

Constraints: No business-code changes; no new dependencies;
mapping never in APK, never in public repo;
no overwriting archives, same-name build fails;
where uncertain write "needs manual check".

Validation:
- build two APKs from different commits, confirm two separate archive directories with different hashes
- retrace restores to correct line, and wrong mapping clearly errors out
- unzip -l <apk> finds no mapping / seeds / usage

That "deliberately wrong mapping" step was my addition. The restored line numbers looked plausible but pointed to a different class — far more dangerous than a clear error, because it leads you in entirely the wrong direction during debugging. Leaving that failure sample in the evidence file is worth more than writing a note to "watch version correspondence."

One detail: I originally committed the mapping archive to Git and had to back it out after a colleague pointed it out. The essence of obfuscation is hiding names; mapping is the full decryption table. Putting it in a public repo is barely different from not obfuscating at all. The Rules line "mapping never enters a public repo" was added after that.

Round 10 · Real-device regression, verify all four numbers

IDE mode / Agent, with a device matrix.

Goal: produce a regression report for the release obfuscated APK, to docs/obfuscation/evidence/regression.md.

Context: @docs/obfuscation/regression.md @docs/obfuscation/keep-rationale.md
        @docs/obfuscation/evidence/ @app/build/outputs/

Requirements:
1. Device matrix: one each of Android 8 / 10 / 12 / 14 / 16,
   list model, OS version, ABI. Run debug and release each round,
   so you can compare (Compose performance differs greatly between them).
2. Each device covers: install, cold start, login, JNI call paths,
   serialization/deserialization round-trip, Parcelable read/write,
   deep-link navigation, notification tap, Compose first screen and list scrolling,
   dynamic-resource-name-related pages, upgrade install overwrite.
   Filter for ClassNotFoundException / NoSuchMethodError / VerifyError /
   UnsatisfiedLinkError / NoClassDefFoundError / ANR / crash.
3. Run all "automated" cases from regression.md,
   walk through the "must be manual" ones one by one and record results.
4. Recompute four numbers (rename coverage, seeds count, usage count,
   dex size), compare with Round 1 baseline, Round 4, Round 5 in a four-column table.
5. Cross-check: classes renamed in mapping.txt,
   classes kept in seeds.txt, rules registered in keep-rationale.md,
   find differences for "kept but no registered rationale" and
   "registered rule but no corresponding entry in mapping", output diff file.
6. jadx spot-check the final APK: are business class names changed,
   any plaintext interface URLs or keys, is mapping packaged into the APK,
   are Log.* calls removed; archive screenshots or output snippets.
7. Cold start with adb shell am start -W -S, 7 runs per device, take median,
   compare with the non-obfuscated baseline APK, record TotalTime difference.

Constraints: Don't modify business code to fit a particular device;
don't widen rules for unusual unit failures;
numbers are measured, not estimates;
when you find a plaintext string, report its location first, don't quietly change business code.

Validation:
- all devices in the matrix pass core paths, no new stack types
- two diff files land in evidence/, every keep without rationale explained one by one
- four-number comparison table complete, coverage and size move in consistent directions
- jadx spot-check finds no mapping or registered plaintext strings

That round turned up an interesting find: on one Android 10 device, a function that fetched an icon via getIdentifier failed, but everything worked on other models. Investigation showed the resource only existed in a density-qualified directory on that device, and resource shrinking had treated it as unused. The dynamic-resources.md from Round 8 had registered this entry, but the verification path had only been clicked on a high-density device. I added this to the matrix requirements — dynamic resource names must be verified by resource qualifier, not just on one device.

Round 11 · Nail the four numbers into CI

IDE mode / Agent.

Goal: make obfuscation quality a hard gate checked on every build, produce script and CI config.

Context: @app/build.gradle.kts @.github/workflows/
        @docs/obfuscation/baseline.md @docs/obfuscation/keep-rationale.md

Requirements:
1. Write scripts/obfuscation/verifyObfuscation.sh, taking APK and
   mapping archive directory as arguments, output a machine-readable JSON and a human-readable md:
   - rename coverage, seeds count, usage count, total dex bytes
   - difference vs baseline
   - safety-net keep detection: matches `-keep class ...**`,
     all-member `{ *; }` rules, keeps starting with framework package name
   - disabled-option detection: -dontwarn / -dontoptimize / -dontobfuscate / -dontshrink
   - whether mapping / seeds / usage / configuration archives are all present
   - whether rule-file hash matches the one recorded in build-info
2. Gate thresholds: coverage must not drop more than 5 points below baseline,
   dex size must not grow more than 3%, any safety-net keep or disabled option fails.
   On failure, print which rule, which file, which line, not just "failed".
3. Hook the retrace drill into CI: fabricate a synthetic obfuscated stack,
   restore with the round's mapping, fail if it doesn't restore to expected line.
4. CI job: assembleRelease → archive → verifyObfuscation.sh →
   instrumentation tests → upload evidence artifacts;
   post the four-number before/after as a PR comment (using the project's existing mechanism).
5. Output docs/obfuscation/pipeline.md:
   task order, each step's artifacts, failure-location method, single local reproduction command.

Constraints: Use only existing tools like python3 / unzip / aapt / grep,
no third-party binaries; don't auto-fix rules or baselines, only report;
thresholds as config items, not hardcoded in script logic;
private keys and passwords written to no files.

Validation:
- run once against current artifacts; should pass
- deliberately add `-keep class com.example.** { *; }` to a test branch,
  confirm the script exits non-zero and prints that line
- deliberately delete seeds.txt from archive, confirm script reports missing file
- re-run in CI, two JSON outputs identical except build time

The gate caught something in its second week: an SDK bumped a minor version and its shipped consumer-rules.pro gained a whole-package keep that dropped obfuscation rate by 7 points. Previously that would only be discovered when the package-size alarm went off; now it's flagged red on the PR. That's also how the opposite of "nobody dares to delete" — "nobody noticed it was added back" — got plugged.

Round 12 · Change identity; critique your own work

SOLO mode, three steps.

Goal: review this configuration from both a reverse-engineer's and a future-version's perspective,
      produce an issue list and rollback plan.

Context: @docs/obfuscation/ entire directory @app/build/outputs/

Step 1 · Reverse-engineer's view:
1. Assume you only have the final APK, with jadx / apktool,
   list how much business structure you can reconstruct: package names, module boundaries,
   interface URLs, keys, data flows. State for each "restored via what".
2. Point out the three least-useful rules in this config,
   and the most easily bypassed link, and the cost to bypass.
3. Check if any mapping or inferable naming info leaks into the artifact,
   including comments, SourceFile, strings, resource names, BuildConfig fields.
4. State plainly what R8 obfuscation cannot prevent; don't downplay.

Step 2 · Future-version view:
5. Assume upgrade to AGP 9.x, point out which changes in
   docs/obfuscation/version-behavior.md would break the current config:
   proguard-android.txt, -repackageclasses duplicates,
   -keepattributes wildcard, strictFullModeForKeepRules,
   global-option filtering, keepRules source set.
   Each with migration step and verification, sorted by risk.

Step 3 · Rollback:
6. Output docs/obfuscation/rollback.md:
   - shortest recovery path when "obfuscation causes production functional anomaly"
     (no code change, no dependency, effective within one release cycle)
   - how to disable each rule class, and how coverage / size / crash recoverability changes after disabling
   - how to handle historical crashes when mapping archive is lost
   - complete steps to locate and add a rule when a class was mistakenly deleted and staging crashes
   - owner placeholder and drill record location

Constraints: Do not modify any rule file or business code;
don't fabricate detection results;
review conclusions distinguish "confirmed issue" from "needs manual check";
if nothing is found, write "nothing found this round" and state the scope of the check.

Validation: I actually try each item from the reverse-engineer's list
"what can be reconstructed" against the final APK;
check the future-version list against official docs one by one;
run the rollback path in a staging environment and record time.

In step one it gave a conclusion I agree with: after obfuscation, interface URLs, field names, string constants, and resource names are all still there; jadx can still trace the business structure along call chains. What obfuscation slows down is "reading speed," not "whether it can be read." What really keeps an interface URL private is not hardcoding it in the client, not adding an obfuscation rule. That sentence stayed in the docs verbatim.

It also honestly noted the limitation: this round only looks at static artifacts, no Frida for dynamic observation, so runtime class-name restoration and reflection-call observation aren't covered. Writing the boundary is more trustworthy than giving a "all passed."

What the final files look like

After twelve rounds, the project gained a small set of things, mainly:

app/
├── proguard-rules.pro                # grouped by "why needed", each with rationale
├── build.gradle.kts                  # release config + mapping archive task
└── src/main/res/raw/keep.xml         # tools:keep, narrowed to three specific resources
scripts/obfuscation/
├── verifyObfuscation.sh              # four numbers + safety-net rule detection
├── retrace-smoke.sh                  # crash-restore drill
└── dictionaries/                     # three candidate dictionaries, not enabled after testing, kept for reference
.github/workflows/obfuscation-check.yml
docs/obfuscation/
├── baseline.md                       # Round 1's current-status check
├── version-behavior.md               # AGP / R8 version behavior verification
├── rule-sources.md                   # final effective rules by source
├── keep-rationale.md                 # each keep's rationale and regression case
├── dynamic-resources.md              # dynamic resource name list
├── regression.md                     # regression list, marked automated / manual
├── pipeline.md                       # pipeline and failure location
├── rollback.md                       # rollback plan
├── mapping/<version>-<commit>/       # one directory per build, no overwrite
└── evidence/                         # measured data and diffs from each round

The final proguard-rules.pro, unlike the version from two years ago, is different not in content but in that every rule can answer "what happens if I remove it":

# app/proguard-rules.pro

# --- JNI --------------------------------------------------------
# Native layer looks up symbols by Java_package_class_method; renaming class causes UnsatisfiedLinkError.
# Rationale: 6 statically registered methods under app/src/main/cpp/, see keep-rationale.md #JNI-01~06.
# If removed: health-data encryption module crashes on first call, regression case JniSmokeTest.
-keepclasseswithmembernames,includedescriptorclasses class * {
    native <methods>;
}

# --- Reflection (R8 statically invisible) ----------------------------------------
# Rationale: LegacyPluginLoader.kt:88 builds class name by string then Class.forName.
# Option B evaluated (changing to constant reference requires changing plugin protocol), narrowed keep for now.
# If removed: old plugin entry throws ClassNotFoundException, regression case PluginLoaderTest.
-keepnames class com.example.app.legacy.plugin.** { *; }
# Note: this is the only ** keep in the whole project; separately registered in the CI whitelist.

# --- Default constructor (full mode) --------------------------------------
# Rationale: AGP 8.0+ full mode no longer retains default constructor implicitly;
#       RecordFactory.kt:34 uses getDeclaredConstructor().newInstance().
# If removed: NoSuchMethodException, regression case FullModeCheckTest#b.
-keepclassmembers class com.example.app.data.RecordDraft {
    public <init>();
}

# --- Generic signature (full mode) ----------------------------------------
# In full mode Signature is kept only for explicitly kept classes and members;
# Rationale: ApiTypeResolver.kt:52 uses getGenericReturnType to obtain parameterized type.
-keepattributes Signature
-keep,allowoptimization,allowshrinking,allowobfuscation class com.example.app.data.ApiEnvelope

# --- Parcelable / serialization -----------------------------------------
-keepclassmembers class * implements android.os.Parcelable {
    public static final android.os.Parcelable$Creator *;
}
# kotlinx.serialization / Room / Hilt rules are generated by each Gradle plugin,
# artifact paths see rule-sources.md, no duplication here.
# Compose artifacts ship their own consumer-rules.pro, likewise no hand-writing.

# --- Annotations --------------------------------------------------------
-keepattributes *Annotation*, InnerClasses, EnclosingMethod
-keepclassmembers,allowoptimization,allowshrinking,allowobfuscation class * {
    @androidx.annotation.Keep *;
}

# --- Crash recoverability --------------------------------------------------
-keepattributes SourceFile, LineNumberTable
-renamesourcefileattribute SourceFile

# --- Release log cleanup --------------------------------------------
# Prerequisite: no -dontoptimize; proguard-android-optimize.txt doesn't carry it
-assumenosideeffects class android.util.Log {
    public static int v(...);
    public static int d(...);
    public static int i(...);
}

# --- Package structure scrambling --------------------------------------------------
# Verified: no code in the project searches or filters by package-name string.
# Version debt: AGP 9.1 turns this on by default for apps; delete this line after upgrade.
-repackageclasses
-adaptclassstrings

# --- Explicitly unused --------------------------------------------------
# Custom obfuscation dictionaries: measured non-ASCII dictionaries grow dex and get rewritten by jadx,
# negative value, see evidence/dictionary-eval.md.
# String encryption / control-flow obfuscation: outside R8 scope, no third-party libraries.

build.gradle.kts changes concentrate in the release block and archive task:

// app/build.gradle.kts (excerpt)
android {
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            isDebuggable = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
            // do not use proguard-android.txt: it carries -dontoptimize, turning off all optimization passes
        }
    }
}

// archive after every assembleRelease, directory name includes versionName + git short commit
val archiveObfuscation by tasks.registering(Copy::class.java) {
    val version = providers.provider { "5.9.0" }          // injected from version catalog
    val commit = providers.exec { commandLine("git", "rev-parse", "--short", "HEAD") }
        .standardOutput.asText.map { it.trim() }
    from(layout.buildDirectory.dir("outputs/mapping/release"))
    into(rootProject.layout.projectDirectory.dir("docs/obfuscation/mapping/${version.get()}-${commit.get()}"))
    include("mapping.txt", "seeds.txt", "usage.txt", "configuration.txt")
    // fail if same-name directory exists, never overwrite
    duplicatesStrategy = DuplicatesStrategy.FAIL
}

The verification script core is three segments, using python3 to read text and compute, no extra tools:

#!/usr/bin/env bash
# scripts/obfuscation/verifyObfuscation.sh
set -euo pipefail
APK="$1"; MAPPING_DIR="$2"

echo "== four numbers =="
python3 scripts/obfuscation/metrics.py "$APK" "$MAPPING_DIR" \
  --baseline docs/obfuscation/baseline.md \
  --out docs/obfuscation/evidence/metrics.json

echo "== safety-net keeps and disabled options =="
if grep -nE '^\s*-keep\s+class\s+[^*]*\*\*|^\s*-keep\s+class\s+androidx\.' app/proguard-rules.pro \
   | grep -v 'legacy.plugin' ; then
  echo "found unregistered broad keep, see line numbers above" >&2
  exit 1
fi
if grep -nE '^\s*-(dontwarn|dontoptimize|dontobfuscate|dontshrink)' app/proguard-rules.pro ; then
  echo "found disabled option" >&2
  exit 1
fi

echo "== archive completeness =="
for f in mapping.txt seeds.txt usage.txt configuration.txt build-info.json; do
  test -f "$MAPPING_DIR/$f" || { echo "missing $f" >&2; exit 1; }
done

echo "== mapping not packaged =="
if unzip -l "$APK" | grep -iE 'mapping|seeds|usage|configuration' ; then
  echo "mapping-related files packaged into APK" >&2
  exit 1
fi

echo "== crash-restore drill =="
bash scripts/obfuscation/retrace-smoke.sh "$MAPPING_DIR/mapping.txt"

CI puts it after the release build and before artifact upload:

# .github/workflows/obfuscation-check.yml (excerpt)
name: obfuscation-check
on: [pull_request, workflow_dispatch]

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '17'
      - name: Build release
        run: ./gradlew :app:assembleRelease archiveObfuscation
      - name: Four numbers and rule gates
        run: |
          APK=$(find app/build/outputs/apk/release -name '*.apk' | head -1)
          MAP=$(find docs/obfuscation/mapping -mindepth 1 -maxdepth 1 -type d | sort | tail -1)
          bash scripts/obfuscation/verifyObfuscation.sh "$APK" "$MAP"
      - name: Reflection and JNI cases
        run: ./gradlew :app:connectedReleaseAndroidTest
      - name: Upload evidence
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: obfuscation-evidence-${{ github.sha }}
          path: |
            docs/obfuscation/evidence/
            docs/obfuscation/mapping/

Finally, each release leaves a report like this; the four numbers sit side by side and anyone can tell at a glance whether this release regressed:

docs/obfuscation/evidence/metrics.md · 5.9.0 (a3f91c2)

                      baseline(Round1)   after-delete   after-keeps   final
rename coverage            8.7%            84.2%         79.6%      79.6%
seeds count              37,412            6,881          7,204      7,204
usage count               1,903           14,766         14,443     14,443
classes*.dex size        21.4 MB          17.6 MB        17.9 MB    17.9 MB
cold-start median (Android 14)  891 ms      827 ms      831 ms      831 ms

Keeps without registered rationale: 0
Kept but no rationale in rationale file: 0
mapping archive: 5.9.0-a3f91c2 ✓  retrace drill ✓

From 8.7% to 79.6%, the staging crash in between bought the fact that all 7,204 seeds now have a reason to exist. Cold start is actually 60 ms faster than baseline — method inlining, class merging, and smaller dex working together, which circles back to the opening line: obfuscation was always an optimization.

Pits I stepped in, sorted by error message

The fastest way to debug obfuscation issues isn't rereading the theory; it's knowing "this error is probably which rule misaligned." I've grouped by error:

Symptom Root cause Fix
jadx shows all original class names safety-net like -keep class business-package.** { *; } measure seeds count and coverage first, delete one by one, don't rewrite rules
ClassNotFoundException after deleting safety-net string-concatenated class name for reflection, R8 can't see narrow keep, or change to compile-time constant reference; the latter cleaner
NoSuchMethodException: <init> full mode no longer implicitly keeps default constructor explicit keep public <init>();, or use factory method
generic reflection gets null or raw type full mode keeps Signature only for explicitly kept classes and members -keepattributes Signature and keep the class too
getDeclaredMethods() then private filter finds nothing -allowaccessmodification changes private to public for inlining write private explicitly in rule, or don't filter by visibility
a whole class vanishes from dex only referenced via reflection string, judged unreachable check missing_rules.txt, copy only that line and narrow
UnsatisfiedLinkError JNI static registration looks up by class name keep native method's class, or switch to RegisterNatives dynamic
deserialized fields all default values field name changed, mismatches protocol rely on plugin-generated rules or @SerialName to fix the name, don't hand-write whole-class keeps
Parcelable read/write throws CREATOR deleted or renamed -keepclassmembers ... Parcelable$Creator
Gson TypeToken loses generic Signature not kept same as above, confirm keep scope until relevant class
Compose release performance no change / first screen abnormal used proguard-android.txt (with -dontoptimize), or added Compose broad keep switch to proguard-android-optimize.txt, delete framework-level keeps, trust consumer rules
dynamic icon occasionally missing getIdentifier-built resource name deleted by resource shrinking tools:keep narrowed to specific resource, or switch to R.xxx compile-time reference
only certain density/locale devices missing resource qualified-directory resource judged unused verify dynamic resource list per qualifier, add corresponding device to matrix
VerifyError type name in descriptor not updated (common with multi-toolchain or post-R8 dex modification) don't modify dex after R8, go back to single pipeline
NoClassDefFoundError transitive dependency shrunk away check usage.txt, confirm direct or optional dependency
crash stack all a.b.c and unrecoverable mapping overwritten by next build archive directory with versionName + commit, same-name fails
retrace restores to unrelated line wrong mapping version use APK SHA-256 to look up build-info.json before taking archive
crash platform shows gibberish didn't upload corresponding mapping to platform add a step in release process to upload, write in pipeline.md
Log.d still in release -dontoptimize makes -assumenosideeffects ineffective remove -dontoptimize, don't use non-optimize default rules
obfuscation rate drops a few points suddenly SDK upgrade brings new consumer-rules.pro CI gate reports diff, inspect source one by one, don't rush to modify own rules
build suddenly fails with "missing class" AGP 8.0+ failOnMissingClasses default on check dependency source, add dependency or precise rule, don't add -dontwarn
upgrade AGP 9.x build errors proguard-android.txt no longer supported; library global options filtered; -repackageclasses default migrate per version-behavior.md, delete duplicate config
fancy dictionary obfuscation and jadx still reads fine decompiler auto-converts illegal identifiers to legal names; JAD did that in the 90s don't use non-ASCII dictionaries; shortest identifier saves most dex
Trae gives "requires by some SDK doc" but no such line exists prompt didn't constrain "what to do when unsure" Constraints fixed with "mark needs manual check, don't fill in"

A few deserve extra mention.

seeds being thicker than mapping is always the first signal. A healthy release build should have seeds.txt in the thousands, not tens of thousands. My habit now is to check the line-count ratio of these two files right after packaging before deciding whether to dig deeper.

-dontwarn is the most expensive line of config. It solves nothing, just makes the build shut up. In our project, three of the five dontwarn lines correspond to runtime issues that later actually surfaced. CI now treats all four dont* as banned; adding one fails the build.

The full-mode class of problems is silent at compile time. Generic-signature loss, deleted default constructors, private-to-public visibility, whole-class deletion — all four pass compilation and unit tests (many unit tests run on the debug variant) and only blow up on specific release paths. That's why Round 10's "run both debug and release" is not a formality; it's the only way to catch them.

Command quick reference

I basically only type these few during debugging:

# build and archive
./gradlew :app:assembleRelease archiveObfuscation
./gradlew :app:minifyReleaseWithR8 --dry-run

# four numbers
grep -c ' -> ' docs/obfuscation/mapping/*/mapping.txt
wc -l docs/obfuscation/mapping/*/seeds.txt
wc -l docs/obfuscation/mapping/*/usage.txt
unzip -l app-release.apk | grep 'classes.*\.dex'

# what's actually in the artifact
aapt dump badging app-release.apk
aapt dump resources app-release.apk | head -60
unzip -l app-release.apk | grep -iE 'mapping|seeds|usage|configuration'   # should be empty
jadx -d /tmp/decompiled app-release.apk
unzip -p app-release.apk classes.dex | strings | grep -iE 'secret|token|api/'

# crash restore (retrace in current cmdline-tools)
$ANDROID_HOME/cmdline-tools/latest/bin/retrace \
  docs/obfuscation/mapping/5.9.0-a3f91c2/mapping.txt stacktrace.txt

# who added the rule
git blame app/proguard-rules.pro

# real device
adb shell am start -W -S com.example.app/.MainActivity
adb logcat -v brief | grep -E \
  'ClassNotFoundException|NoSuchMethodError|VerifyError|UnsatisfiedLinkError|ANR|FATAL'

The git blame line looks out of place but is the most used. Obfuscation rule files are the classic "only add, never remove" file; a rule lives for five years and nobody dares to touch it. Checking what problem it was added to solve beats guessing.

A few numbers I watch before release

Not in the table but checked every time: does every rule in keep-rationale.md have a corresponding regression case, is dynamic-resources.md's verification path fully walked, does the mapping archive directory name carry the commit, does the retrace drill restore to the correct line, and has CI's four numbers regressed from last version.

Item Baseline Final My threshold
Rename coverage 8.7% 79.6% no lower than last version minus 5 points
Keeps without registered rationale 9 0 0
classes*.dex size 21.4MB 17.9MB no larger than last version by 3%
retrace drill not done passed must pass every time
mapping archive completeness often lost 4/4 100%
dynamic-resource verification paths 0 11/11 all pass
cold-start median 891ms 831ms must not degrade

Coverage didn't climb toward ninety-odd points, deliberately: the remaining twenty percent includes old plugin protocols, JNI, and public APIs that genuinely can't be touched. Forcing them to rename only buys the next staging crash. Obfuscation rate isn't the higher the better; it's that every point has a rationale. That sentence now sits as the first line of keep-rationale.md.

Where to dig further

These are the ones I want to do next, roughly ordered by ROI:

Tune Baseline Profile / Startup Profile together with R8. Official docs mention that after enabling R8, you should add a Startup Profile to precompile startup-path classes. Obfuscation changes class names, so the profile has to be regenerated; we haven't wired that up yet.

Automate library-module consumer-rule completeness. Right now we manually verify with rule-sources.md which modules' rules the main project picks up; Round 3 missed two. I want a build-time check that fails if consumerProguardFiles is missing.

Migration rehearsal for AGP 9.3's new DSL. optimization { enable = true } and keepRules source set change the configuration entry point entirely. Since the rule file is now clean, we test it on a branch and verify the risk list in version-behavior.md.

Regression after resource-optimization shrinking becomes default. It auto-activates in AGP 9.0; our dynamic-resource list was verified on the 8.x pipeline. Before upgrading, we need to rerun all 11 verification paths, especially the density-qualified directory ones.

Turn jadx self-audit into a scheduled task. Round 12's reverse-engineer view is currently manual. I want CI to run it monthly on the latest APK, outputting a "how much business structure can be reconstructed" trend rather than a one-off conclusion.

Static analysis with ProGuardCORE. No runtime dependency; just scan on the build machine for reflection call sites and string-built class names, early-flagging the "R8 can't see" code. That turns Round 5 from manual code reading into an automated checklist.

Hook mapping to the crash platform. We still manually upload at release; want it in the pipeline, and cross-match "APK hash → archive directory → crash platform mapping version," with an alert when wrong mapping is used.

Evaluate R8's optimization passes individually. Method inlining, class merging, -processkotlinnullchecks — we only looked at totals, not broken down. Especially class merging, which affects class-name-based analytics; worth measuring separately.

Re-verify the negative dictionary conclusion. The three dictionaries were tested on one build machine and one version; might differ on a large dex project. Keep the conclusion but don't write it as a universal law.

Turn the four numbers into a long-term trend dashboard. Record coverage, seeds, usage, dex size per release; the impact of SDK upgrades, AGP upgrades, and module splits becomes visible at a glance. That Round 11 SDK silently lowering coverage would have been caught earlier with a dashboard.

Afterwards

That -keep class com.example.** { *; } was eventually deleted. I couldn't bear to throw away the comment, so it became a different line at the top of proguard-rules.pro:

# Before deleting any rule, first see why it exists in docs/obfuscation/keep-rationale.md.

From "nobody dares to delete" to "first check why," it's really just replacing fear with evidence. The toolchain has changed several times this year, AGP 8 to 9 rule semantics keep shifting, but this approach hasn't changed much: measure the four numbers first, ask the rationale for each rule, only then act.

There's an irony: after all this tinkering, I no longer treat obfuscation as a security measure. Don't hardcode interface URLs, don't put keys on the client, don't over-grant permissions — these are far more useful than turning UserRepositoryImpl into a.b.c. Obfuscation's real job was defined twenty years ago: make the package smaller and faster, and incidentally make people who want to copy the code spend more time.

Now every time I open jadx and see a screen full of a.b.c, I don't think "this is safe," I think "this round's dex is 3.5 MB smaller."