Skip to content

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

Description

@im24601

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":

  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 <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

  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();
    <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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions