DEV Community

Robin for Capawesome

Posted on Originally published at capawesome.io

Google Play DEX Code Optimization in Capacitor Apps

If Play Console shows a "DEX code optimization" finding for your Capacitor app, the Android project template is the cause. From February 2027, Google Play requires at least 25% obfuscation, optimization and shrinking for every app with more than 10 MB of DEX code, and the Capacitor Android template ships with R8 turned off. In this post, we walk through the fix: two lines in build.gradle, a short proguard-rules.pro, and one check before you upload.

Prerequisites

You'll need:

  • A Capacitor 8 Android app
  • Android Gradle Plugin (AGP) 8.x

Everything below comes from our own production app at Capawesome, which uses about 40 plugins and runs on Capacitor 8.5.0 with AGP 8.13.2.

Play's DEX optimization requirement

Google Play measures three percentages for every app bundle you upload: obfuscation, optimization and shrinking. A Capacitor app built from the default template scores close to zero on all three. The Android project that npx cap add android generates sets minifyEnabled false in the release build type, so R8 never touches your DEX code. For our app, Play Console reported 1% obfuscation.

The Play Console technical quality requirements say that from February 2027, apps need at least 25% in each category, and that "not meeting a requirement can affect an app's visibility and publishing capabilities on Google Play". The thresholds apply to apps with more than 10 MB of DEX code and to games with more than 50 MB. Capacitor, Firebase and Play Services dependencies put a plugin-heavy app over the 10 MB line.

The Android vitals page on DEX code optimization explains that for apps built with AGP 8.10 or higher, Play reads them from the r8.json file inside the bundle. Any shrinker counts, and R8 is the one every Android project already has.

Enable R8

Open android/app/build.gradle, set minifyEnabled true in the release build type and switch the default rules file to the optimizing variant:

android {
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The template points to proguard-android.txt. That file contains -dontoptimize, which blocks most of what R8 could do, and AGP 9 no longer accepts it by default. We covered that build failure in How to Fix Capacitor Plugin Build Errors with AGP 9, so switching now avoids it later.

R8 has run in full mode by default since AGP 8.0. That detail is behind the keep rule in the next section.

Rules you need

On top of the rules that Capacitor, AGP and the large SDKs contribute on their own, a Capacitor app needs these lines in android/app/proguard-rules.pro:

# Capacitor annotations: required in R8 full mode (ionic-team/capacitor#8589)
-keep @interface com.getcapacitor.** { *; }

# Plugin-specific rules from each plugin's README
-keep class io.capawesome.capacitorjs.plugins.** { *; }

# Optional backends that plugins probe for at runtime (see missing_rules.txt)
-dontwarn io.requery.android.database.**
-dontwarn net.zetetic.database.**
Enter fullscreen mode Exit fullscreen mode

The first rule is the one every Capacitor app needs, and we'll explain it below. The second line is an example of a plugin-specific rule. Most Capacitor plugins ship no consumer rules, and a plugin that relies on reflection or serialization documents its rule in its README. The Capacitor SQLite plugin does exactly that for the io.capawesome.capacitorjs.plugins package. The -dontwarn lines depend on which plugins you use; the "Missing class errors" section shows how to find yours.

The rest is already handled for you:

  • Capacitor's consumer rules: @capacitor/android declares its own proguard-rules.pro as consumer rules. They keep every class annotated with @CapacitorPlugin, its public @PluginMethod methods, and its @PermissionCallback and @ActivityCallback methods, even private ones. Capacitor resolves those callbacks by method name at runtime, so permission requests and activity results survive R8 without any rule of yours.
  • AGP's defaults: they keep @JavascriptInterface methods, which is how Capacitor's MessageHandler receives calls from JavaScript.
  • Large SDKs: Firebase and Play Billing ship consumer rules of their own.

Why the keep rule

Without the @interface rule, the build passes and the app crashes on startup. Capacitor's consumer rules keep the classes annotated with @CapacitorPlugin, but not the annotation types themselves. Here is what the first launch of our minified build looked like in logcat:

FATAL EXCEPTION: CapacitorPlugins
java.lang.RuntimeException: java.lang.reflect.InvocationTargetException
Caused by: java.lang.NullPointerException
    at com.getcapacitor.Plugin.getPermissionStates
    at com.getcapacitor.Plugin.checkPermissions
    at ...FirebaseMessagingPlugin.checkPermissions
Enter fullscreen mode Exit fullscreen mode

According to the Android documentation on R8 full mode, R8 strips annotations from classes that are not explicitly kept. Once the CapacitorPlugin annotation is gone, getAnnotation(CapacitorPlugin.class) can only return null. R8 then removes the pluginAnnotation field from PluginHandle, and Bridge.getPermissionStates() dereferences null the first time any plugin checks its permissions. All plugins still register, because registration only needs the class.

The -keep @interface com.getcapacitor.** { *; } rule keeps every annotation type in the package. That covers @CapacitorPlugin, @Permission, @PermissionCallback, @ActivityCallback, @PluginMethod and the legacy @NativePlugin.

Capacitor does not ship this rule in its consumer rules yet. Issue #8589 describes the same behavior with the same workaround and is open as of this writing. Capacitor 8.5.2 merged a null check in getPermissionStates(), so on 8.5.2 the app no longer crashes. Instead, checkPermissions() returns an empty permission map. You still need the keep rule on 8.5.2, because the null check only hides the symptom.

You could also avoid the crash by disabling full mode with android.enableR8.fullMode=false in gradle.properties. That costs you optimization across the whole app and all its dependencies, which is the exact number Play measures. The keep rule is the cheaper fix.

Missing class errors

R8 fails the build with "Missing class" errors when a plugin was compiled against a library your app does not bundle. Some Capacitor plugins do this on purpose. The Capacitor SQLite plugin compiles against both SQLCipher (net.zetetic.database.**) and requery's sqlite-android (io.requery.android.database.**) as compileOnly dependencies, and bundles only the one you select in variables.gradle. At runtime it probes for them with Class.forName. The missing classes are expected, but R8 can't know that, so the two -dontwarn lines above tell it.

Whenever R8 reports missing classes, it writes a ready-to-paste file to android/app/build/outputs/mapping/release/missing_rules.txt. Read each entry before you paste it:

  • A -dontwarn for an optional backend that the plugin checks for at runtime is correct.
  • A -dontwarn for a class a plugin calls unconditionally hides a real NoClassDefFoundError until a user hits it. The right fix there is to add the dependency.

Verify locally

Debug builds never run R8 with this configuration, so the release build is the only one that tells you whether your rules are complete. It also has to be signed before you can install it. The signing properties that Android Studio injects into Gradle let you sign a local release build with the debug keystore, without touching build.gradle:

cd android
./gradlew assembleRelease \
  -Pandroid.injected.signing.store.file=$HOME/.android/debug.keystore \
  -Pandroid.injected.signing.store.password=android \
  -Pandroid.injected.signing.key.alias=androiddebugkey \
  -Pandroid.injected.signing.key.password=android
adb install -r app/build/outputs/apk/release/app-release.apk
Enter fullscreen mode Exit fullscreen mode

With the build installed, run three checks:

  1. Watch logcat for FATAL EXCEPTION, ClassNotFoundException and NoSuchMethodException while the app starts. Then run every flow that requests a permission or waits for another activity, such as the camera, a file picker or a settings screen. The annotation crash shows up within the first second.
  2. Open mapping.txt in android/app/build/outputs/mapping/release/ and confirm that your plugin classes still appear under their original names. A renamed plugin class means a keep rule is missing. Archive this file with every release, because you'll need it for production stack traces.
  3. Read the Play numbers from the bundle before you upload it. Build it with ./gradlew bundleRelease and run unzip -p app/build/outputs/bundle/release/app-release.aab BUNDLE-METADATA/com.android.tools/r8.json. Play Console shows the same percentages for that upload in the app bundle explorer.

Conclusion

Our advice: enable R8 now, not when the February 2027 deadline arrives. The first minified build of a plugin-heavy Capacitor app surfaces every missing rule at once, and you want that to happen on an emulator, not in a Play release. Treat the release build as a standing test target. Run your permission and activity-result flows on it before every upload, and check missing_rules.txt again whenever you add a plugin. For the other Android build pitfalls Capacitor apps run into, see The Android Troubleshooting Guide for Capacitor.

The full post on the Capawesome blog also has an FAQ on mapping files for Play and Crashlytics and on resource shrinking, and if you have questions, drop them in the comments below.

Top comments (0)