Skip to content

fix(android): keep plugin annotation types from R8 full mode - #8646

Open
bo-516 wants to merge 1 commit into
ionic-team:mainfrom
bo-516:fix/android-keep-annotation-types
Open

bo-516 wants to merge 1 commit into
ionic-team:mainfrom
bo-516:fix/android-keep-annotation-types

Conversation

@bo-516

@bo-516 bo-516 commented Oct 8, 2026

Copy link
Copy Markdown

Fixes #8589.

R8 full mode treats annotation types that are only reached through Class.getAnnotation() / Method.getAnnotation() as unused, then folds the cached annotation in PluginHandle to null. -keepattributes *Annotation* does not stop that. The consumer rules kept the annotated plugin classes, but not the annotation interfaces themselves.

#8400 (8.5.2) guards Bridge.getPermissionStates() so checkPermissions() no longer crashes. On current main the annotation is still null, so Plugin.requestPermissions() takes the legacy @NativePlugin branch and NPEs on legacyAnnotation.permissions() (Plugin.java:883, inlined into requestPermissions).

This adds -keep @interface rules for the annotation types Capacitor reads reflectively (com.getcapacitor.annotation.**, PluginMethod, NativePlugin). NativePlugin was removed on next, so that one line should be dropped when this is merged forward to Capacitor 9.

Verification

Minimal release app, minifyEnabled true, proguard-android-optimize.txt, R8 full mode, one plugin:

@CapacitorPlugin(name = "Repro", permissions = { @Permission(alias = "camera", strings = { Manifest.permission.CAMERA }) })

The app calls inherited checkPermissions() then requestPermissions(). Same app, three Capacitor builds:

Build checkPermissions requestPermissions
8.5.1 NPE in getPermissionStates, process dies not reached
main (includes #8400) logs missing @CapacitorPlugin annotation for plugin Repro, resolves undefined NPE, process dies
this branch resolves {"camera":"prompt"} system permission dialog, process stays alive

dexdump -d of Plugin.getPermissionStates() on main allocates the map, logs the missing-annotation warning, and returns. It never reads permissions(). On this branch the same method loads the kept field and calls the interface:

iget-object v2, v2, Lcom/getcapacitor/f0;.f:Lcom/getcapacitor/annotation/CapacitorPlugin;
if-eqz v2, ...
invoke-interface {v2}, Lcom/getcapacitor/annotation/CapacitorPlugin;.permissions:()[Lcom/getcapacitor/annotation/Permission;

Plugin.requestPermissions() on main is folded to a null check and then invoke-virtual on a null reference (the legacy annotation). On this branch it loads both kept fields:

iget-object v1, v0, Lcom/getcapacitor/f0;.f:Lcom/getcapacitor/annotation/CapacitorPlugin;
if-nez v1, ...          # @CapacitorPlugin path
iget-object v0, v0, Lcom/getcapacitor/f0;.e:Lcom/getcapacitor/NativePlugin;
invoke-interface {v0}, Lcom/getcapacitor/NativePlugin;.permissions:()[Ljava/lang/String;

usage.txt on main still removes PluginHandle.pluginAnnotation and legacyPluginAnnotation, and the annotation types are renamed (CapacitorPlugin -> v0.b). With this change those fields are no longer removed, and seeds.txt keeps CapacitorPlugin, Permission, PermissionCallback, ActivityCallback, PluginMethod, and NativePlugin under their original names.

R8 full mode removes annotation instances that Capacitor only reads
through reflection. PluginHandle.pluginAnnotation and
legacyPluginAnnotation are dropped, so getAnnotation() is null at
runtime. The null check in getPermissionStates stops that crash, but
requestPermissions still NPEs on the null annotation.

Keep the annotation interfaces in the consumer ProGuard rules.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

1 participant