ionic-team / ionic-team/capacitor

bug(android): R8 full mode folds PluginHandle.getPluginAnnotation() to null → Bridge.getPermissionStates becomes `throw null`; consumer ProGuard rules do not keep the annotation classes

Open
#8,589 2 comments 1 reaction 0 assignees View on GitHub
triage
Dominant language
TypeScript
Stars
16.7k
Forks
1.3k
Avg merge
3d 15h
Merged PRs (30d)
10

Description

## Bug Report

### Capacitor Version

```
@capacitor/android 8.4.1
@capacitor/core 8.4.1
@capacitor-firebase/messaging 8.4.0 (first caller; not the cause)
```

### Platform(s)

Android only.

- Android Gradle Plugin 8.13.0, Gradle 8.14.3, R8 8.13.6 (full mode, AGP default)
- JDK 21.0.10
- `minifyEnabled true`, `shrinkResources true`, `proguard-android-optimize.txt`
- Device: Samsung SM-A176U1 (Galaxy A17), Android 16 / API 36. Reproduces on every launch.

### Current Behavior

With R8 enabled (no other change from a working non-minified build), the app launches, but the first plugin call that reaches the base-class permission path crashes the process:

```
FATAL EXCEPTION: CapacitorPlugins
Process: com.kineticdetective.detective, PID: 30615
java.lang.RuntimeException: java.lang.reflect.InvocationTargetException
at com.getcapacitor.Bridge.lambda$callPluginMethod$0(Bridge.java:850)
at android.os.Handler.handleCallback(Handler.java:1070)
at android.os.Handler.dispatchMessage(Handler.java:125)
at android.os.Looper.dispatchMessage(Looper.java:358)
at android.os.Looper.loopOnce(Looper.java:288)
at android.os.Looper.loop(Looper.java:392)
at android.os.HandlerThread.run(HandlerThread.java:139)
Caused by: java.lang.reflect.InvocationTargetException
at java.lang.reflect.Method.invoke(Native Method)
at com.getcapacitor.PluginHandle.invoke(PluginHandle.java:138)
at com.getcapacitor.Bridge.lambda$callPluginMethod$0(Bridge.java:841)
... 6 more
Caused by: java.lang.NullPointerException
at com.getcapacitor.Bridge.getPermissionStates(Bridge.java:1217)
at com.getcapacitor.Plugin.getPermissionStates(Plugin.java:619)
at com.getcapacitor.Plugin.checkPermissions(Plugin.java:779)
at io.capawesome.capacitorjs.plugins.firebase.messaging.FirebaseMessagingPlugin.checkPermissions(FirebaseMessagingPlugin.java:99)
... 9 more
```

(Retraced with `retrace mapping.txt` from the same build; `pg_map_id` in mapping.txt matched the `r8-map-id` in the raw trace. Three consecutive Login taps produced three identical crashes.)

The caller is incidental. Any plugin that reaches `Plugin.checkPermissions()`, `Plugin.requestPermissions()` or `Plugin.getPermissionState()` crashes the same way (Camera, Geolocation, LocalNotifications, Filesystem all share the path). In our app the first caller is Firebase Messaging on API 33+, so the symptom looks like a Firebase bug, and because Android returns to the task underneath (the Play Store listing we installed from) it also looks like a stray deep link. Both are red herrings.

### Expected Behavior

`Bridge.getPermissionStates(Plugin)` iterates `@CapacitorPlugin.permissions()` and returns the permission map, as it does with `minifyEnabled false`.

### What R8 actually did (from the shipped AAB, not inferred)

`dexdump -d` of `com.getcapacitor.Plugin.getPermissionStates()` (obfuscated to `com.getcapacitor.d0`) in the release DEX. `Bridge.getPermissionStates` has been inlined into it, and the body after the `HashMap` allocation is an unconditional `throw null`:

```
com.getcapacitor.d0.getPermissionStates:()Ljava/util/Map;
0000: iget-object v0, v1, Lcom/getcapacitor/d0;.bridge:Lcom/getcapacitor/e;
0002: iget-object v0, v0, Lcom/getcapacitor/e;.b:Lh/i;
0004: new-instance v0, Ljava/util/HashMap;
0006: invoke-direct {v0}, Ljava/util/HashMap;.:()V
0009: invoke-virtual {v1}, Lcom/getcapacitor/d0;.getPluginHandle:()Lcom/getcapacitor/g0;
000c: move-result-object v0
000d: invoke-virtual {v0}, Ljava/lang/Object;.getClass:()Ljava/lang/Class; <- null-check idiom
0010: const/4 v0, #int 0
0011: throw v0 <- throw null
```

R8's `usage.txt` for the same build lists what it removed from `PluginHandle`:

```
com.getcapacitor.PluginHandle:
public com.getcapacitor.NativePlugin legacyPluginAnnotation
public com.getcapacitor.annotation.CapacitorPlugin pluginAnnotation
public static com.getcapacitor.annotation.CapacitorPlugin getPluginAnnotation()
public static com.getcapacitor.NativePlugin getLegacyPluginAnnotation()
```

So R8 concluded the `pluginAnnotation` field is never non-null, removed the field and its getter, constant-propagated `annotation` in `Bridge.getPermissionStates` to `null`, and replaced the `annotation.permissions()` dereference with `throw null`.

Two things make this worse than a normal "forgot a keep rule":

1. **The annotations are still in the DEX.** `mapping.txt` shows `com.getcapacitor.annotation.CapacitorPlugin -> j2.b` and `com.getcapacitor.annotation.Permission -> j2.c` (renamed, not removed), and `dexdump -a` shows `Lj2/b;` on 21 plugin classes with their `@Permission` values intact. Reflection at runtime would have worked. The optimizer's assumption about the reflective lookup is what is wrong, so the crash is invisible until the method actually runs.
2. **`-keepattributes *Annotation*` is already present and does not help.** Our app rules have `-keepattributes SourceFile, LineNumberTable, Signature, InnerClasses, EnclosingMethod, *Annotation*, JavascriptInterface`, and the merged `configuration.txt` confirms Capacitor's consumer rules were applied. Keeping the annotation *attributes* is not the same as keeping the annotation *classes*, and in full mode R8 only treats an annotation as reflectively reachable if the annotation type itself is kept.

### Why Capacitor's consumer rules are not enough

`android/capacitor/proguard-rules.pro` (shipped as `consumerProguardFiles`, unchanged on `main` today) keeps:

```
-keep @com.getcapacitor.annotation.CapacitorPlugin public class * {
@com.getcapacitor.annotation.PermissionCallback ;
@com.getcapacitor.annotation.ActivityCallback ;
@com.getcapacitor.annotation.Permission ;
@com.getcapacitor.PluginMethod public ;
}
-keep public class * extends com.getcapacitor.Plugin { *; }
```

That keeps the *annotated* plugin classes and members. It never keeps the annotation interfaces in `com.getcapacitor.annotation.*` (or `com.getcapacitor.PluginMethod`), and it does not keep `PluginHandle`'s cached annotation field. In compat mode this was harmless because R8 kept annotation classes referenced by kept annotations. In full mode (the AGP 8.x default) those interfaces are ordinary unreferenced types, and R8 is free to decide that `pluginClass.getAnnotation(CapacitorPlugin.class)` in the `PluginHandle` constructor can never return a live instance.

### Steps to Reproduce

1. `npm init @capacitor/app`, add `@capacitor/android@8.4.1` and any plugin that calls `checkPermissions()` at startup (Firebase Messaging on API 33+, or `@capacitor/camera` and call `Camera.checkPermissions()` on load).
2. AGP 8.13.0, leave `android.enableR8.fullMode` unset (default true).
3. In `android/app/build.gradle` release: `minifyEnabled true`, `proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'`.
4. Add `-keepattributes *Annotation*` to the app rules to show it is not sufficient. No other Capacitor keeps.
5. `./gradlew :app:bundleRelease`, install on a device, trigger the plugin call.
6. Confirm from the artifact without a device: `unzip app-release.aab base/dex/classes.dex` then `dexdump -d classes.dex` and look at the method R8 mapped `Plugin.getPermissionStates()` to. A `throw` immediately after `getPluginHandle()` is the bug.

### Workaround (app side)

Adding this to the app's `proguard-rules.pro` restores the correct method body with `minifyEnabled true` and full mode still on. No blanket `com.getcapacitor.**` keep is needed:

```
# Runtime annotation interfaces read via Class.getAnnotation() / Method.getAnnotation()
-keep @interface com.getcapacitor.annotation.** { *; }
-keep @interface com.getcapacitor.PluginMethod { *; }

# PluginHandle caches the annotations at construction; R8 must not fold them away
-keepclassmembers class com.getcapacitor.PluginHandle {
java.lang.Class pluginClass;
com.getcapacitor.annotation.CapacitorPlugin pluginAnnotation;
com.getcapacitor.NativePlugin legacyPluginAnnotation;
com.getcapacitor.annotation.CapacitorPlugin getPluginAnnotation();
com.getcapacitor.NativePlugin getLegacyPluginAnnotation();
(...);
}
```

Verified workaround: a release build with exactly these rules (same AGP 8.13.0 / R8 8.13.6 full mode, `minifyEnabled true`) passes the `dexdump` check (our internal build 71, commit fd765405). `Plugin.getPermissionStates()` is back to a 168-unit method that calls `PluginHandle.getPluginAnnotation()` and iterates `CapacitorPlugin.permissions()`, `usage.txt` no longer lists the `pluginAnnotation` field or its getter as removed, and the annotation types keep their original names. Installed on the same SM-A176U1 (Android 16) from the Play internal track: the previously crashing Login flow now completes, `FirebaseMessagingPlugin.checkPermissions()` runs (`getPermissionStates` now calls `getPluginAnnotation()` and iterates `permissions()`, no bare `throw`), and there is no process death on Login.

Setting `android.enableR8.fullMode=false` in `gradle.properties` is the blunt alternative and also avoids it, at the cost of Play's optimization score.

### Proposed fix

Add the annotation-class keeps to Capacitor's consumer rules so every app gets them automatically:

```
# Annotation types are read reflectively (PluginHandle, Bridge.getPermissionStates,
# PluginMethodHandle). R8 full mode removes annotation types that are not kept and
# then folds Class.getAnnotation()/Method.getAnnotation() to null, even when
# -keepattributes *Annotation* is set.
-keep @interface com.getcapacitor.annotation.** { *; }
-keep @interface com.getcapacitor.PluginMethod { *; }
-keep @interface com.getcapacitor.NativePlugin { *; }
```

The `PluginHandle` member keep may be unnecessary once the annotation types are kept (R8 would then have no basis to prove the field null). Happy to test a Capacitor build that only adds the `@interface` keeps and report back.

Optionally, `PluginHandle` could also fail loudly instead of caching `null`: the constructor already throws `InvalidPluginException` when neither annotation is found, so the null can only reach `Bridge.getPermissionStates` through an optimizer, which argues for a one-line guard there with a clear message pointing at R8 configuration.

### Other Technical Details

```
npm --version: 10.9.8
node --version: v22.22.3
Capacitor CLI 8.4.1
AGP 8.13.0 / Gradle 8.14.3 / R8 8.13.6 / JDK 21.0.10
compileSdk 36, minSdk 24
```

### Additional Context

- The failing build had byte-identical R8 config in two consecutive releases, and the app was fully usable in both with `minifyEnabled false`. The only delta between the last good build and the first bad one was enabling R8.
- Because the crash is a compile-time constant fold, it does not show up in lint, in `configuration.txt`, or as a missing-class warning. The only pre-device signals are the `usage.txt` lines above and the `dexdump`.

Contributor guide

Open the contributing guide

Research direction

Start in android/capacitor/proguard-rules.pro and compare the current consumer rules with the reported annotation keeps. Run ./gradlew :app:bundleRelease with R8 full mode, then inspect usage.txt and dexdump for Plugin.getPermissionStates(); done means permission calls no longer fold to an unconditional throw null.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, java
Domain
build-system, mobile
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.