rea-reverse

Deobfuscate Android Stack Traces with R8 mapping.txt and Retrace

Published by rea-reverseUpdated 9 min read
On this page

A release build with minifyEnabled true crashes and the trace reads at a.c.b(r8-map-id-c701195c…:3). The class is a.c, the method is b, and the “file name” is a hash. None of that is in your source tree. R8 wrote a file during the build that maps every one of those names and line numbers back to the original, mapping.txt, and a tool called retrace applies it to the trace.

This guide is for Android and Java developers who ship shrunk builds and need to read their own crash reports. It walks through a small program we wrote and shrank for this article: where the mapping file comes from, how to read the lines in it (including the inline groups that turn one obfuscated frame into three), how the Android SDK’s retrace handles it, and how the ProGuard / R8 retrace tool on this site compares on the same input.

Test setup. macOS 27.0.1 on Apple silicon, Temurin JDK 17.0.18, Android SDK command-line tools 20.0 (its r8 and retrace scripts report R8 9.0.32), and for the Gradle part, Android Gradle plugin 9.4.1 on Gradle 9.6.1, which embeds R8 9.4.24. Every trace and mapping excerpt below is copied from those runs.

From R8 Names Back to SourceSilent walkthrough with English captions.

The sample and its obfuscated crash

The program is about 60 lines in one file, App.java. It parses CSV-like order lines ("apple,3,0.50"), and the third line has x as its quantity:

final class LineParser {
  static Item parse(String line) {
    String[] parts = line.split(",");
    return new Item(parts[0], quantity(parts[1]), Double.parseDouble(parts[2]));   // line 31
  }

  private static int quantity(String field) {
    int q = toInt(field);                                                          // line 35
    if (q <= 0) throw new IllegalArgumentException("quantity must be positive: " + q);
    return q;
  }

  private static int toInt(String field) {
    try {
      return Integer.parseInt(field.trim());                                       // line 42
    } catch (NumberFormatException e) {
      throw new IllegalStateException("bad quantity field '" + field + "'", e);    // line 44
    }
  }
}

Compiled with javac --release 11 and run unshrunk, the trace is what you would expect:

Exception in thread "main" java.lang.IllegalStateException: bad quantity field 'x'
	at org.sample.shop.LineParser.toInt(App.java:44)
	at org.sample.shop.LineParser.quantity(App.java:35)
	at org.sample.shop.LineParser.parse(App.java:31)
	at org.sample.shop.App.main(App.java:10)
Caused by: java.lang.NumberFormatException: For input string: "x"
	...
	at org.sample.shop.LineParser.toInt(App.java:42)
	... 3 more

Then we ran R8 on the jar, in class-file mode so the result still runs on a desktop JVM, keeping only main:

printf '%s\n' '-keep class org.sample.shop.App { public static void main(java.lang.String[]); }' > keep.pro
r8 --release --classfile --lib "$(/usr/libexec/java_home)" \
   --pg-conf keep.pro --pg-map-output out/mapping.txt \
   --output out/app-r8.jar out/in.jar
java -cp out/app-r8.jar org.sample.shop.App

R8 took 1.4 seconds. The jar went from 3,860 to 3,621 bytes and the mapping file is 1,494 bytes, 31 lines. The same crash now reads:

Exception in thread "main" java.lang.IllegalStateException: bad quantity field 'x'
	at a.c.b(r8-map-id-c701195cdf4e3ff721f2bcebca323794ae5b89d15c9c2c3eee87b3ffc0600733:3)
	at a.c.a(r8-map-id-c701195cdf4e3ff721f2bcebca323794ae5b89d15c9c2c3eee87b3ffc0600733:1)
	at org.sample.shop.App.main(r8-map-id-c701195cdf4e3ff721f2bcebca323794ae5b89d15c9c2c3eee87b3ffc0600733:5)
Caused by: java.lang.NumberFormatException: For input string: "x"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:668)
	at java.base/java.lang.Integer.parseInt(Integer.java:786)
	at a.c.b(r8-map-id-c701195cdf4e3ff721f2bcebca323794ae5b89d15c9c2c3eee87b3ffc0600733:1)
	... 2 more

Three things changed. The classes are renamed (LineParser is now a.c). The line numbers are R8’s own small numbers, not source lines. And there are only three application frames instead of four, because parse() no longer exists as a method: R8 inlined it into main. The ... 3 more also became ... 2 more for the same reason.

The source file attribute is replaced by r8-map-id- plus a hash. That hash is the pg_map_id in the header of the mapping file. It is the most useful string in the whole trace, as you will see further down.

Where mapping.txt comes from

On the command line you choose the path with --pg-map-output. In an Android Gradle project, the plugin writes it for every minified variant. After ./gradlew :app:assembleRelease on a one-activity test app (no AndroidX, just android.app.Activity), the folder looked like this:

app/build/outputs/mapping/release/configanalyzer.html
app/build/outputs/mapping/release/configuration.txt
app/build/outputs/mapping/release/configanalyzer.pb
app/build/outputs/mapping/release/seeds.txt
app/build/outputs/mapping/release/mapping.txt
app/build/outputs/mapping/release/mapping.prt
app/build/outputs/mapping/release/usage.txt

mapping.txt is the file retrace wants. configuration.txt is the merged keep-rule configuration R8 actually used, seeds.txt lists what the keep rules matched and usage.txt what was removed. AGP 9.4.1 also wrote mapping.prt, which file identifies as a ZIP archive; retrace does not need it.

For an app bundle, the same mapping travels inside the .aab. We built bundleRelease and compared:

unzip -p app/build/outputs/bundle/release/app-release.aab \
  BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map \
  | cmp - app/build/outputs/mapping/release/mapping.txt && echo SAME
SAME

So if all you kept is the .aab you uploaded, the mapping for that build is still recoverable from it.

Two problems on the way to that build are worth knowing. First, AGP 9.4.1 refused to run on the Gradle 9.4.1 we tried first: Minimum supported Gradle version is 9.6.0. Current version is 9.4.1. Gradle 9.6.1 worked. Second, we built --offline, and lintVitalAnalyzeRelease failed with No cached version of com.android.tools.lint:lint-gradle:32.4.1 available for offline mode. Skipping the three lintVital* tasks with -x let the build finish; the mapping file does not depend on them.

Reading the mapping lines

Here is the complete LineParser and App part of the class-file mapping:

# compiler: R8
# compiler_version: 9.0.32
# {"id":"com.android.tools.r8.mapping","version":"2.2"}
# pg_map_id: c701195cdf4e3ff721f2bcebca323794ae5b89d15c9c2c3eee87b3ffc0600733
org.sample.shop.App -> org.sample.shop.App:
# {"id":"sourceFile","fileName":"App.java"}
    1:3:void main(java.lang.String[]):7:9 -> main
    4:5:org.sample.shop.Item org.sample.shop.LineParser.parse(java.lang.String):30:31 -> main
    4:5:void main(java.lang.String[]):10 -> main
    6:6:void org.sample.shop.Cart.add(org.sample.shop.Item):53:53 -> main
    6:6:void main(java.lang.String[]):10 -> main
    7:7:void main(java.lang.String[]):12:12 -> main
...
org.sample.shop.LineParser -> a.c:
# {"id":"sourceFile","fileName":"App.java"}
    1:2:int quantity(java.lang.String):35:36 -> a
    1:3:int toInt(java.lang.String):42:44 -> b

A class line is original -> obfuscated:. The # {...} comment under it restores the source file name that the trace replaced with the map id.

A member line has the form objStart:objEnd:signature:origStart:origEnd -> obfuscatedName. Take 1:3:int toInt(java.lang.String):42:44 -> b. Obfuscated lines 1 to 3 of method b in a.c are original lines 42 to 44 of toInt. The trace’s a.c.b(...:3) is the third line of that range, so it is source line 44, the throw. The Caused by frame a.c.b(...:1) is line 42, the parseInt call.

Inlining shows up as several lines with the same obfuscated range. Lines 4 to 5 of main appear twice: once as LineParser.parse(...):30:31 and once as main(...):10. Read the group top to bottom as innermost to outermost. Code at obfuscated line 5 is line 31 of parse, and that code was inlined at line 10 of main. So the single frame App.main(...:5) expands into two frames. Notice also that the outer line has a single number (:10), not a range. That is the call-site line, which stays the same whatever position inside the inlined code you are at.

The Android build produced a deeper case. In that app LineParser was called from MainActivity.onCreate, and R8 removed the class entirely:

org.sample.shop.LineParser -> R8$$REMOVED$$CLASS$$0:
org.sample.shop.MainActivity -> org.sample.shop.MainActivity:
    1:2:void onCreate(android.os.Bundle):9:10 -> onCreate
    3:3:int org.sample.shop.LineParser.toInt(java.lang.String):24:24 -> onCreate
    3:3:int org.sample.shop.LineParser.quantity(java.lang.String):17 -> onCreate
    3:3:void onCreate(android.os.Bundle):10 -> onCreate
...
    6:6:int org.sample.shop.LineParser.toInt(java.lang.String):26:26 -> onCreate
    6:6:int org.sample.shop.LineParser.quantity(java.lang.String):17 -> onCreate
    6:6:void onCreate(android.os.Bundle):10 -> onCreate

Both helper methods now live inside onCreate, so line 6 of onCreate is a three-level inline group: toInt line 26, called from quantity line 17, called from onCreate line 10. R8$$REMOVED$$CLASS$$0 means no class with that name exists in the APK at all. If you search for LineParser in a decompiler, you will not find it.

Retrace with the Android SDK tool

The command-line tools ship retrace next to r8:

~/Library/Android/sdk/cmdline-tools/latest/bin/retrace --version
Retrace 9.0.32 (build c3ce613f2fe60ca6edc3c2103508e6c966d7fc51 from go/r8bot ...)

retrace out/mapping.txt out/trace.txt

Output:

Exception in thread "main" java.lang.IllegalStateException: bad quantity field 'x'
	at org.sample.shop.LineParser.toInt(App.java:44)
	at org.sample.shop.LineParser.quantity(App.java:35)
	at org.sample.shop.LineParser.parse(App.java:31)
	at org.sample.shop.App.main(App.java:10)
Caused by: java.lang.NumberFormatException: For input string: "x"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:668)
	at java.base/java.lang.Integer.parseInt(Integer.java:786)
	at org.sample.shop.LineParser.toInt(App.java:42)
	... 2 more

Ten input lines became eleven. The four application frames match the unshrunk run exactly, parse included. The JDK frames passed through untouched because their classes are not in the mapping. The one thing retrace does not restore is the ... 2 more count; it is copied from the input.

We could not run the Android build on a device for this article, so to test the three-level group we typed one frame by hand, with a logcat prefix, using the map id from the Android mapping and obfuscated line 6:

E AndroidRuntime: java.lang.IllegalStateException: bad quantity field 'x'
E AndroidRuntime: 	at org.sample.shop.MainActivity.onCreate(r8-map-id-72621bc4...:6)
E AndroidRuntime: 	at android.app.Activity.performCreate(Activity.java:9002)
E AndroidRuntime: 	at org.sample.shop.MainActivity.onCreate(Unknown Source)

That is a constructed input, not a captured crash, but retrace’s answer is real:

E AndroidRuntime: 	at org.sample.shop.LineParser.toInt(MainActivity.java:26)
E AndroidRuntime: 	at org.sample.shop.LineParser.quantity(MainActivity.java:17)
E AndroidRuntime: 	at org.sample.shop.MainActivity.onCreate(MainActivity.java:10)
E AndroidRuntime: 	at android.app.Activity.performCreate(Activity.java:9002)
E AndroidRuntime: 	at org.sample.shop.MainActivity.onCreate(MainActivity.java)

The logcat prefix survives, one frame becomes three, and the Unknown Source frame without a line number gets its file name but no line, since nothing in the trace says which range it was in.

The same traces in the browser tool

The retrace tool is a port of R8’s retrace that runs in a Web Worker. To make sure what we say about it is true, we ran its code (src/tools/retrace/analyze.ts from the site’s repository) under Node.js 22 on the same two inputs and diffed the results against the SDK’s retrace 9.0.32 output. Both were identical line for line; the only byte difference was the final newline, which the tool’s text output does not add.

The summary the tool computes for the class-file trace was:

{"lines":10,"frames":7,"mapped":4,"retraced":4,"ambiguous":0,"inlined":1,"removed":0,"exceptions":0}

and for the mapping: 4 classes in 31 lines, 2 of the 6 class names in the trace found, compiler R8 9.0.32. The other four are java.lang.IllegalStateException, java.lang.NumberFormatException, java.lang.Integer and, oddly, "x": retrace’s default pattern reads the text after the colon in For input string: "x" as a class name, exactly as R8’s own parser does. None of those is in an app mapping, so “2 of 6” is normal here. The whole run took 6 ms. The mapping is read in the browser and is not uploaded; the tool page explains how to check that with the Network tab.

In practice the CLI is the better fit when retrace is part of a script or CI job, and the page is quicker when someone pastes a trace into a chat and you have the mapping on disk but no SDK on that machine.

When the result looks right but is wrong

The failure that cost us the most time while preparing this was not an error at all. We changed the source slightly (added a four-line label() helper above toInt), rebuilt, and then retraced the old trace with the new mapping:

	at org.sample.shop.LineParser.toInt(App.java:48)
	...
	at org.sample.shop.LineParser.toInt(App.java:46)

The first frame came out as line 48 instead of 44, the Caused by frame as 46 instead of 42. Both are believable line numbers in the right method, and neither retrace 9.0.32 nor the browser tool printed any warning. The method names matched only because this change did not rename anything. A larger change between builds would give wrong method names with the same confidence.

The check is the map id. The trace carries r8-map-id-c701195c…; the new mapping’s header said pg_map_id: 8e5ed241…. Before retracing, compare the hash in any frame with the pg_map_id line of the mapping:

grep -o 'r8-map-id-[0-9a-f]*' trace.txt | head -1
grep '^# pg_map_id' mapping.txt

If they differ, find the right mapping. In the browser tool the opposite case is easy to spot: we loaded the Android mapping with the JVM trace and the summary showed 0 of 6 class names found and 0 frames mapped.

Keep rules did not change the line numbers

We expected -keepattributes SourceFile,LineNumberTable to give us real source lines, as it did with old ProGuard setups. With R8 9.0.32 in class-file mode it did not. We built the same jar four ways:

Extra rule First frame of the trace
none a.c.b(r8-map-id-c701195c…:3)
-keepattributes SourceFile,LineNumberTable a.c.b(r8-map-id-c701195c…:3)
-keepattributes LineNumberTable + -renamesourcefileattribute SourceFile a.c.b(r8-map-id-c701195c…:3)
-dontobfuscate org.sample.shop.LineParser.toInt(r8-map-id-f1c042af…:3)

Even with -dontobfuscate the class names are readable but the line is still :3 and parse is still missing from the trace, because inlining and line renumbering are optimizations, not obfuscation. So “turn off obfuscation” does not remove the need for the mapping file. You need it for any build R8 optimized. Note also that AGP’s default proguard-android-optimize.txt (as extracted in configuration.txt) keeps Signature, InnerClasses and annotation attributes but not LineNumberTable or SourceFile, and the Android build still had line numbers in its mapping.

Doing this with REA

REA, the open-source project by morluto, analyzes APKs through headless JADX. In the published [email protected] package, the Android tools are inspect_android_package, search_android_classes, inspect_android_class, inspect_android_method and trace_android_references. None of their contracts mentions a mapping file, so REA works on the names that are actually in the APK. That means you retrace first, then translate the original name to the obfuscated one with the mapping (org.sample.shop.LineParser -> a.c) before asking REA or JADX to open the class. For a class that shows up as R8$$REMOVED$$CLASS$$0, look in the method it was inlined into instead. The setup is in Android APK analysis with REA and JADX MCP with Claude Code. Only analyze apps you build or are authorized to inspect; see the disclaimer.

Limits of this walkthrough

  • The JVM trace is real; the Android frame was typed by hand from the Android mapping, because we did not run the APK on a device. The retrace output for both is real.
  • The sample does not produce ambiguous <OR> frames, outlines or rewriteFrame rules. The tool page describes how those look.
  • Native frames from .so libraries need ndk-stack and unstripped libraries, not retrace. Capturing the crash in the first place is covered in debugging crashes with adb logcat and bugreport.

Next steps