R8 broke Capacitor permissions in release
R8 removed a method Google's consent SDK looks up by name and broke Capacitor's permission requests. Only the store build failed; my tests ran a debuggable one.
Contents
In the Play build of my game, the notifications switch did nothing. I tapped it, no Android permission dialog appeared, and logcat had nothing to say about it, not even an error. On my test build the same tap brought up the dialog every time.
The cause was R8, the Android code shrinker I had switched on two days earlier. It was the second feature R8 had quietly broken that week. Neither one crashed the app, and both got past every device test I had, because those tests ran a build that R8 handles differently from the one Play ships.
The game is ASMR Logo Craft Block: React and Vite in a WebView, wrapped by Capacitor 8.5.2.
The plugins involved are @capacitor/local-notifications 8.3.1,
@capacitor-firebase/analytics 8.5.2 and @capacitor-community/admob, which brings Google's
consent SDK (UMP). The Android Gradle plugin is 8.13.0.
Why R8 was on at all
Play Console had two complaints about the bundle. It rated the code optimisation low, and it said:
There is no deobfuscation file associated with this App Bundle.
Both came from the same line. The release build type had minifyEnabled false, which is what
the Capacitor project generator writes. Turning R8 on took the DEX from 22.8 MiB to 6.5 MiB and
put the mapping file in the bundle, where Play picks it up on upload. The size side of that is
in Cutting a Capacitor Android build in half; this
post is about what it cost.
R8 failures only show up in the release variant, so I did what looked like the careful thing. I
added a qa build type that inherits everything from release, and pointed the device tests
at it. A simplified version:
buildTypes {
qa {
initWith release // same minifyEnabled, same keep rules
signingConfig signingConfigs.debug
debuggable true // the test driver needs the WebView inspectable
matchingFallbacks = ['release'] // plugins only publish debug and release
}
}
All 23 device scenarios passed on it. Remember the debuggable true line.
Break one: setConsent went missing
Walking through the signed store build on an emulator, with logcat open, I found this line just after the consent form:
W UserMessagingPlatform: java.lang.NoSuchMethodException: com.google.firebase.analytics.FirebaseAnalytics.setConsent [interface java.util.Map]
A warning. The game carried on, the ads loaded, and nothing else complained. What the line
says is that Google's consent SDK hands the player's answer to Firebase Analytics by looking
up FirebaseAnalytics.setConsent(Map) by name, and that method was gone. R8 cannot see a call
made through reflection, so as far as it could tell, nothing used that overload. R8 writes
everything it strips to usage.txt next to the mapping file, and the method was in it:
com.google.firebase.analytics.FirebaseAnalytics:
…
public static void resetAnalyticsData()
public static void setAnalyticsCollectionEnabled()
public static void setConsent()
So with R8 on, the player's consent choice stopped reaching Analytics. No crash, no failed test, one warning line in a log nobody reads in production. The fix is a keep rule:
-keepclassmembers class com.google.firebase.analytics.FirebaseAnalytics {
public void setConsent(java.util.Map);
}
With it, the device tests answered the consent form 59 times on the R8 build and the
NoSuchMethodException did not come back once.
I found that one by reading logcat by hand. No test failed on it, and no test was going to: a warning is invisible to a test that checks what is on the screen.
The permission dialog that never appeared
Two days later I switched notifications off in the game's settings and could not switch them back on. There was a real bug in the switch's logic, and I fixed it. The next Play build, with that fix in it, still did nothing when tapped.
The part I am least proud of: I had already seen this. During the R8 work, walking through the
store build, I tapped the game's own "Allow" prompt and no system dialog appeared. I logged it
as unresolved. I couldn't reproduce it on the qa build, and R8's mapping file showed every
method name the notifications plugin needs had survived. So I moved on.
Three things I checked before finding it, none of which helped:
- Method names. Every
@PluginMethodin the mapping file kept its name. That was true and irrelevant. - A stale cached answer. Android's backup can restore an app's storage onto a fresh
install, and Capacitor caches a
deniedpermission state. I built that state on purpose, refusing twice and then resetting only the OS side. Theqabuild still behaved. - WebView console logging. Android's WebView DevTools app has a flag,
webview-log-js-console-messages, that copies any app's page console into logcat, release builds included. For the tap, it showed nothing.
What worked was to stop testing the qa build. I built the real release variant and turned on
WebView debugging for that one build. Capacitor only makes the WebView inspectable on
debuggable builds by default, but android.webContentsDebuggingEnabled: true overrides that,
and I set it in the generated capacitor.config.json under android/app/src/main/assets,
which is git-ignored, so nothing tracked changed. Then I forwarded the WebView's DevTools
socket with adb and called the plugin directly from the page:
checkPermissions -> undefined # release build
checkPermissions -> {"display":"prompt"} # qa build, same code
requestPermissions() in the release build was worse. No dialog, and the promise never
settled. My probe sat for two minutes with the game still in front and got no answer at all.
The binaries showed where the two builds differed. dexdump on the notifications plugin, the
Play APK against the qa APK:
== Play build
Annotations on method #16256 'permissionsCallback'
VISIBILITY_RUNTIME Ln2/d;
== qa build
Annotations on method #33483 'permissionsCallback'
VISIBILITY_RUNTIME Lcom/getcapacitor/annotation/PermissionCallback;
In the store build, Capacitor's annotation types had been renamed: @CapacitorPlugin,
@Permission and @PermissionCallback became n2.b, n2.c and n2.d, and @PluginMethod
became com.getcapacitor.f0. In the qa build they kept their names. Capacitor reads these
annotations at run time to find a plugin's permissions and the method a permission answer
comes back to.
Capacitor's Android library does ship R8 rules, and they start like this:
-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>;
}
They keep the annotated plugin classes and methods. Nothing in them keeps the annotation types themselves. In the size post I wrote that Capacitor is safe under R8 because of these rules. That holds for the plugin classes, and it is not the whole story.
The fix, after a broader experiment that kept all of com.getcapacitor.** and also worked:
-keep @interface com.getcapacitor.annotation.** { *; }
-keep @interface com.getcapacitor.PluginMethod { *; }
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
With those three lines, the release build answers checkPermissions with
{"display":"prompt"}. Through the real screens, the tap brings up "Allow ASMR Logo Craft Block
to send you notifications?", Allow returns {"display":"granted"}, and the switch turns on.
What I cannot tell you is which line inside Capacitor gives up when those types are renamed. I did not step through its runtime. I have a before and an after that I can reproduce, and a rule, but not the mechanism.
Why 23 green device tests missed it
The qa build type shares every R8 setting with release. What it adds is debuggable true,
because my test driver talks to the game through the WebView, and by default Capacitor only
exposes the WebView on debuggable builds.
R8 treats a debuggable build differently, and the numbers show it. The qa APK came out at 11 MB
of DEX against 6.5 MiB for the store build, and Capacitor's classes kept their names in it. So
the build I tested on was shrunk but much less transformed than the build players get. The
annotation renaming never happened there, and neither did the bug. The suite even had a
scenario for this exact permission flow, written the same evening as the R8 change, and it
passed.
Both keep rules now live in shared packages whose rule files every game's Gradle build picks up, so the next game starts with them. The gap in the tests is still open: they run a debuggable build, and I have not yet moved them onto a non-debuggable one with WebView debugging switched on, the way I ran the diagnosis.
Takeaway
- A build that went through R8 is not the build you ship unless it is also non-debuggable. If your device tests need a debuggable app, they are not testing what R8 does to the release.
- Read logcat on the release build before you publish. A
NoSuchMethodExceptionfrom a Google SDK is a keep rule you are missing, and it will never fail a test. - When a feature works in your test build, does nothing in the Play build, and logs nothing,
compare the two DEX files before reading more of your own code. One
dexdumpof each found what reading the mapping file had missed. - Don't log "couldn't reproduce" and move on when the build you tried to reproduce it on is not the build where it happened. I did, and it shipped.