R8 works on the program, not on the file
R8 runs inside the Android build after compilation and before packaging. It replaces ProGuard in the standard Gradle pipeline and produces the dex the app ships. Its passes are usually described as shrinking, optimization, obfuscation and preverification, and the part that matters for analysis is that the passes interact. Shrinking removes code R8 cannot reach, optimization rewrites what remains, and obfuscation renames the result.
Because those passes interact, the shipped program is not the source with new names. It is a smaller program that has been restructured to do the same work.
What the optimizer does to structure
- Unused classes, methods and fields are removed, including code that only supported a path the build never reaches.
- Short methods are inlined into their callers, so a signer or a small parser can exist only as statements inside the method that used to call it.
- Classes and interfaces are merged when the optimizer can show that the split does not matter, which removes the type boundaries an analysis would use to orient itself.
- Constant expressions are folded, and a branch whose condition is now constant is removed along with the code on the dead side.
- Lambdas and other synthetic constructs can appear as generated classes whose names carry no meaning.
Any of these can explain why a search for a class the source clearly had returns nothing at all.
Optimizations that break the decompiler
Some R8 output is hard to read for reasons unrelated to names. Compiler-generated accessors are removed once their callers move, bridge methods disappear, and exception handling is restructured, so a try block in the decompiled output may not match the one in the source. Constant and enum handling can also change shape, with small helper types replaced by integers or folded away.
These are artifacts of optimization rather than deliberate protection, and they are recognizable because the surrounding code still reads normally. Where a method looks like it was assembled by a different author, compare it with its caller and assume the compiler merged the two.
What R8 keeps readable
R8 has to leave intact anything the platform resolves at run time, and it does not encrypt data. Several categories survive because of that.
- Manifest components and other classes the system instantiates by name.
- Anything held by a keep rule, which covers classes named from XML, native methods and many serialization models.
- Network constants: hosts, paths, header names and content types appear as string constants unless a protector encrypts them separately.
- Serialized field names, which appear in JSON payloads regardless of what the class is called in dex.
- Native libraries, which R8 does not rewrite and whose exported symbols are unchanged.
Keep rules behave differently under R8
R8 reads rules written for ProGuard, and it runs passes ProGuard does not. A rule that preserves a class in a ProGuard build can allow more aggressive work in an R8 build, because R8 is also free to shrink and merge around it. A keep rule recovered from an older project is evidence about intent rather than a guarantee about what the shipped dex contains, and the dex is where the answer is.
What a mapping file changes
A build can emit a mapping file that lists original names against shipped names. When it is available, it removes the guessing from renaming and lets retrace recover readable stack traces. It is not part of the distributed APK. A mapping for a different build can mislead, because inlining and class merging move code between owners.
Reading an optimized build
Start from behavior the user can see, then follow the values. Find the HTTP client the app builds and the code that configures it. The application classes around that client are the ones worth naming, and data flow tells you which is which. When a signer has been inlined, the operation appears inside a method that does several other things, so read that method for the arithmetic and hashing calls rather than for a function that looks like the source.
Where the reading stalls, a runtime trace resolves it. Instrument the HTTP layer, capture the target call, and compare the values in the request with the constants and parameters you found. That comparison localizes the logic that still needs reconstruction, and it usually narrows the remaining work to one method or one native function.
Related work
Reviewed 28 September 2026 · SReverse research desk