bug(android): R8 full mode folds PluginHandle.getPluginAnnotation() to null → Bridge.getPermissionStates becomes `throw null`; consumer ProGuard rules do not keep the annotation classes
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;.<init>:()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 nullR8'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":
- The annotations are still in the DEX.
mapping.txtshowscom.getcapacitor.annotation.CapacitorPlugin -> j2.bandcom.getcapacitor.annotation.Permission -> j2.c(renamed, not removed), anddexdump -ashowsLj2/b;on 21 plugin classes with their@Permissionvalues 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. -keepattributes *Annotation*is already present and does not help. Our app rules have-keepattributes SourceFile, LineNumberTable, Signature, InnerClasses, EnclosingMethod, *Annotation*, JavascriptInterface, and the mergedconfiguration.txtconfirms 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 <methods>;
@com.getcapacitor.annotation.ActivityCallback <methods>;
@com.getcapacitor.annotation.Permission <methods>;
@com.getcapacitor.PluginMethod public <methods>;
}
-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
npm init @capacitor/app, add@capacitor/[email protected]and any plugin that callscheckPermissions()at startup (Firebase Messaging on API 33+, or@capacitor/cameraand callCamera.checkPermissions()on load).- AGP 8.13.0, leave
android.enableR8.fullModeunset (default true). - In
android/app/build.gradlerelease:minifyEnabled true,proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'. - Add
-keepattributes *Annotation*to the app rules to show it is not sufficient. No other Capacitor keeps. ./gradlew :app:bundleRelease, install on a device, trigger the plugin call.- Confirm from the artifact without a device:
unzip app-release.aab base/dex/classes.dexthendexdump -d classes.dexand look at the method R8 mappedPlugin.getPermissionStates()to. Athrowimmediately aftergetPluginHandle()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();
<init>(...);
}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 24Additional 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 theusage.txtlines above and thedexdump.
Source: ionic-team/capacitor