·8 min read

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.

capacitorandroidr8testing

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 @PluginMethod in 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 denied permission state. I built that state on purpose, refusing twice and then resetting only the OS side. The qa build 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 NoSuchMethodException from 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 dexdump of 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.

More posts

New writing when there is something worth saying. By email or by RSS, whichever you prefer.