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 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":
- 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.
-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 <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/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).
- AGP 8.13.0, leave
android.enableR8.fullMode unset (default true).
- In
android/app/build.gradle release: 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.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();
<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 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.
Bug Report
Capacitor Version
Platform(s)
Android only.
minifyEnabled true,shrinkResources true,proguard-android-optimize.txtCurrent 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:
(Retraced with
retrace mapping.txtfrom the same build;pg_map_idin mapping.txt matched ther8-map-idin the raw trace. Three consecutive Login taps produced three identical crashes.)The caller is incidental. Any plugin that reaches
Plugin.checkPermissions(),Plugin.requestPermissions()orPlugin.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 withminifyEnabled false.What R8 actually did (from the shipped AAB, not inferred)
dexdump -dofcom.getcapacitor.Plugin.getPermissionStates()(obfuscated tocom.getcapacitor.d0) in the release DEX.Bridge.getPermissionStateshas been inlined into it, and the body after theHashMapallocation is an unconditionalthrow null:R8's
usage.txtfor the same build lists what it removed fromPluginHandle:So R8 concluded the
pluginAnnotationfield is never non-null, removed the field and its getter, constant-propagatedannotationinBridge.getPermissionStatestonull, and replaced theannotation.permissions()dereference withthrow null.Two things make this worse than a normal "forgot a keep rule":
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 asconsumerProguardFiles, unchanged onmaintoday) keeps:That keeps the annotated plugin classes and members. It never keeps the annotation interfaces in
com.getcapacitor.annotation.*(orcom.getcapacitor.PluginMethod), and it does not keepPluginHandle'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 thatpluginClass.getAnnotation(CapacitorPlugin.class)in thePluginHandleconstructor can never return a live instance.Steps to Reproduce
npm init @capacitor/app, add@capacitor/android@8.4.1and any plugin that callscheckPermissions()at startup (Firebase Messaging on API 33+, or@capacitor/cameraand callCamera.checkPermissions()on load).android.enableR8.fullModeunset (default true).android/app/build.gradlerelease:minifyEnabled true,proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'.-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.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.prorestores the correct method body withminifyEnabled trueand full mode still on. No blanketcom.getcapacitor.**keep is needed:Verified workaround: a release build with exactly these rules (same AGP 8.13.0 / R8 8.13.6 full mode,
minifyEnabled true) passes thedexdumpcheck (our internal build 71, commit fd765405).Plugin.getPermissionStates()is back to a 168-unit method that callsPluginHandle.getPluginAnnotation()and iteratesCapacitorPlugin.permissions(),usage.txtno longer lists thepluginAnnotationfield 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 (getPermissionStatesnow callsgetPluginAnnotation()and iteratespermissions(), no barethrow), and there is no process death on Login.Setting
android.enableR8.fullMode=falseingradle.propertiesis 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:
The
PluginHandlemember 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@interfacekeeps and report back.Optionally,
PluginHandlecould also fail loudly instead of cachingnull: the constructor already throwsInvalidPluginExceptionwhen neither annotation is found, so the null can only reachBridge.getPermissionStatesthrough an optimizer, which argues for a one-line guard there with a clear message pointing at R8 configuration.Other Technical Details
Additional Context
minifyEnabled false. The only delta between the last good build and the first bad one was enabling R8.configuration.txt, or as a missing-class warning. The only pre-device signals are theusage.txtlines above and thedexdump.