Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions sentry-java/r8-throwable-class-merging/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.gradle/
local.properties
**/build/
113 changes: 113 additions & 0 deletions sentry-java/r8-throwable-class-merging/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# R8 throwable class merging reproduction

**SDK area:** `sentry-java` / Android exception capture

## Description

This minimal Android app demonstrates how R8 can horizontally merge two custom
`RuntimeException` subclasses. The app asks for an `ExampleNonFatal`, but the
optimized runtime class is the residual class chosen for
`DiagnosticTestException`.

This matters to crash reporters because Java can only observe the optimized
runtime identity through `throwable.getClass().getName()`. Retrace can still
recover an `ExampleNonFatal.<init>` frame from line information, producing an
apparent mismatch between the exception type and constructor frame.

No customer names, code, Sentry DSN, or event data are included.

## Steps to reproduce

1. Install JDK 17 and Android SDK Platform 36.
2. Build the optimized release APK:

```bash
./gradlew :app:assembleRelease
```

3. Inspect the relevant mapping entries:

```bash
grep -n -A20 -E 'DiagnosticTestException|ExampleNonFatal|MainActivity' \
app/build/outputs/mapping/release/mapping.txt
```

4. Install and launch the release APK on a device or emulator:

```bash
adb install -r app/build/outputs/apk/release/app-release-unsigned.apk
adb shell am start -n com.example.myapp/.MainActivity
```

The default path creates `ExampleNonFatal`. To select the other source type:

```bash
adb shell am start -n com.example.myapp/.MainActivity \
--ez diagnostic true
```

## Expected behavior

For observability, creating `ExampleNonFatal` would preserve a distinct runtime
class identity, allowing a crash reporter to identify it independently from
`DiagnosticTestException`.

## Actual behavior

With AGP 9.2.1 and its bundled R8, the release mapping contains one residual
throwable class:

```text
com.example.myapp.DiagnosticTestException -> a:
```

There is no separate class mapping for `ExampleNonFatal`. Instead, its
constructor is represented as an inlined frame under `MainActivity`:

```text
void com.example.myapp.ExampleNonFatal.<init>(java.lang.String) -> onCreate
```

The default app path therefore displays:

```text
Requested source type: ExampleNonFatal
Runtime type: a
```

A crash reporter records `a` as the raw type. Class-name retracing maps `a` to
`DiagnosticTestException`, while stack retracing can recover the
`ExampleNonFatal.<init>` frame.

### R8 Retrace behavior

Verified that the R8's own Retrace tool v9.3.16 (build 65eb2ed58d2ac1dbce414b51a8ea877c3ce5f68a from go/r8bot (luci-r8-custom-ci-archive-0-ib6e)) also cannot retrace this back to the original (`ExampleNonFatal`) exception type:

```txt
com.example.myapp.DiagnosticTestException: example failure
at com.example.myapp.ExampleNonFatal.<init>(ExampleNonFatal.java:5)
at com.example.myapp.MainActivity.createThrowable(MainActivity.java:37)
at com.example.myapp.MainActivity.onCreate(MainActivity.java:17)
at android.app.Activity.performCreate(Activity.java:8000)
at android.app.Activity.performCreate(Activity.java:7984)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1309)
```

## Mitigation

Uncomment this rule in `app/proguard-rules.pro` and rebuild:

```proguard
-keep,allowshrinking,allowobfuscation class * extends java.lang.Throwable
```

This disallows class optimization/merging while still allowing unused
throwables to be removed and retained throwables to be renamed.

## Environment

- Android Gradle Plugin: 9.2.1
- Gradle: 9.4.1
- JDK: 17
- compileSdk / targetSdk: 36
- minSdk: 23
36 changes: 36 additions & 0 deletions sentry-java/r8-throwable-class-merging/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
plugins {
id("com.android.application")
}

android {
namespace = "com.example.myapp"
compileSdk = 36

defaultConfig {
applicationId = "com.example.myapp"
minSdk = 23
targetSdk = 36
versionCode = 1
versionName = "1.0"
}

signingConfigs {
getByName("debug") {
storeFile = rootProject.file("debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
}

buildTypes {
release {
isMinifyEnabled = true
signingConfig = signingConfigs.getByName("debug") // to be able to run release mode
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
}
3 changes: 3 additions & 0 deletions sentry-java/r8-throwable-class-merging/app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Intentionally empty: this reproduction demonstrates R8's default behavior.
# Uncomment this mitigation to preserve distinct runtime Throwable identities:
# -keep,allowshrinking,allowobfuscation class * extends java.lang.Throwable
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="false"
android:label="R8 Throwable Repro"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.myapp;

final class DiagnosticTestException extends RuntimeException {
DiagnosticTestException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.myapp;

final class ExampleNonFatal extends RuntimeException {
ExampleNonFatal(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.example.myapp;

import android.app.Activity;
import android.os.Bundle;
import android.widget.TextView;

import java.io.PrintWriter;
import java.io.StringWriter;

public final class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

// The Intent extra is unknown to R8, so both Throwable classes remain reachable.
boolean diagnostic = getIntent().getBooleanExtra("diagnostic", false);
Throwable throwable = createThrowable(diagnostic);

String runtimeType = throwable.getClass().getName();
StringWriter stack = new StringWriter();
throwable.printStackTrace(new PrintWriter(stack));

TextView output = new TextView(this);
output.setText("Requested source type: "
+ (diagnostic ? "DiagnosticTestException" : "ExampleNonFatal")
+ "\nRuntime type: " + runtimeType
+ "\n\n" + stack);
setContentView(output);
android.util.Log.e("Error", "It failed", throwable);
}

private static Throwable createThrowable(boolean diagnostic) {
String message = "example failure";
if (diagnostic) {
return new DiagnosticTestException(message);
}
return new ExampleNonFatal(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">monospace</item>
</style>
</resources>
3 changes: 3 additions & 0 deletions sentry-java/r8-throwable-class-merging/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.2.1" apply false
}
2 changes: 2 additions & 0 deletions sentry-java/r8-throwable-class-merging/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Loading
Loading