OPIc apppaymentsR8diagnosis

R8 optimisation deleted the payment plugin

Turning on R8 as Play Console required stripped the Cordova payment plugin registry. In-app payments sat at zero for ten days; one keep rule fixed it.

Sep 11, 20267 min

Not a single Android in-app payment came in between September 1st and 10th. I found out on the night of the tenth day.

Most of the OPIc speaking-practice app's revenue comes through those in-app payments. Of 54 payments across the app's whole life, 40 of them — 74% — are the app. There's a payment path on the web too, but the app is the one that matters.

They asked, so I turned it on

Play Console wanted DEX code optimization. It was filed as a requirement, with a deadline attached.

On August 31st I enabled minifyEnabled. On September 1st I enabled shrinkResources, adjusted the optimizer settings, and shipped a lightweight release, one build after another. Production review passed on September 2nd.

Every build succeeded. There were no warnings.

Everything but the payment test

After it was approved I opened the app and checked it. Login, recording, scoring, history. All fine.

I didn't press buy. I could have pushed it to the internal test track first, and I didn't do that either. It went straight to production.

The reason isn't mysterious. Most of this app's code is written and changed by an AI agent. Nothing had gone wrong before. So I assumed nothing would go wrong this time. I assumed that about a release which changed the build configuration.

The agent didn't do anything wrong. minifyEnabled and shrinkResources are the options Google asked for, turned on the way the documentation says to turn them on.

Ten days, and nothing said a word

Two things stacked.

One was that the failure ended on the client. A payment failure here finishes before the server is involved. If the store never initializes, products never load, and with no products the purchase request is never sent. Nothing arrives at the server at all. There was nothing for the Vercel logs or Sentry to hold.

The other was the error handler. It was filing real errors as user cancellations. Three plugin error codes were bundled together as the cancel family and quietly swallowed.

6777001  ERR_SETUP      store init failed     → treated as cancelled
6777002  ERR_LOAD       product load failed   → treated as cancelled
6777006  ERR_CANCELLED  an actual cancellation

The actual error message was Init failed - Class not found, and because of that handler it wasn't recorded anywhere for ten days. On screen it was one alert() that appeared and went away.

Web payments really were growing

An SEO effort started two months earlier had begun to land. Desktop traffic was up, and web payments with it: three in August, nine in September. Three times as many.

So the dashboard did not look wrong. It read like the payment channel had shifted to the web.

What had actually happened was that web payments growing and app payments disappearing landed in the same window, and the two had nothing to do with each other.

On the night of September 10th, while looking at the web's share of payments, one question was left over. Why is there not a single Android payment, when there is always a steady trickle?

I took out my phone and pressed buy. It stopped at "Processing…". Leaving and coming back produced a message saying the product information could not be loaded — and it told me to check App Store Connect. An iOS message, on Android.

What R8 deleted

R8 leaves a record of what it deleted: mapping/release/usage.txt.

At first I found the payment callbacks gone. The product-details response callback, a constants class in its entirety, several BillingClient builder methods. A Cordova plugin is found by string and called only through interface callbacks, so it never appears in R8's reference graph. I added keep rules. Still broken.

Next I looked wider. It was not only the plugin. All of org.apache.cordova had been cut, and the bridge class that carries JS calls into native was gone entirely. I had protected the plugin and left the framework unprotected. With the framework kept too, removals dropped from 98 to 9. Still broken.

At this point I deployed instrumentation on every failure point, and for the first time got values back.

[IAP] store_init_timeout
[IAP] product_not_found  registered: 0     ← 3ms later

Three milliseconds. And logcat had not one line of BillingClient output, which means the native call was never even made.

After that I opened the app itself. Web debugging enabled on the release build, attached over the DevTools protocol, and initialization run by hand down the same path the app takes.

code: 6777001
message: "Init failed - Class not found"
platform: android-playstore

The actual error message, at last. Then apkanalyzer opened the APK and every class was present.

cc.fovea.PurchasePlugin              present
org.apache.cordova.PluginManager     present
org.apache.cordova.ExposedJsApi      present
res/xml/config.xml                   missing

Every class was there. The one thing missing was the plugin registry, res/xml/config.xml.

There were two causes

config.xml is the Cordova plugin registry. It records which service name maps to which class. Capacitor looks that file up reflectively, by resource ID, and the resource shrinker cannot see that lookup. It decided nothing referenced the file and deleted it.

lookupWebView JSstore.initialize()ExposedJsApiJS→nativePluginManagerCordovaPurchasePlugincc.foveaconfig.xmlres/xmlBillingClientPlay Billing

Hover a node to keep only what it connects to

CauseEnabledWhat disappeared
Resource shrinkingSep 1The plugin registry, res/xml/config.xml
Code shrinkingAug 31Payment callbacks, the Cordova bridge classes

Both produce the same symptom. It stops at "Processing…". So fixing one of them exactly changed nothing on screen, and I'd fixed each one correctly while thinking both were wasted effort.

When the first fix was logically right and the symptom didn't move, I think I'd have done better to suspect a second cause before abandoning the hypothesis.

The fix

One line telling the resource shrinker to keep the registry.

<!-- android/app/src/main/res/raw/keep.xml -->
<resources xmlns:tools="http://schemas.android.com/tools"
    tools:keep="@xml/config" />

Four lines for code shrinking.

-keep class cc.fovea.** { *; }
-keep class com.android.billingclient.** { *; }
-keep class org.apache.cordova.** { *; }
-keep class com.getcapacitor.cordova.** { *; }

The error classification lost two entries.

// before: [6777001, 6777002, 6777006]  ← SETUP and LOAD were filed as cancels
const userCancelCodes = [6777006];

Then an event and a Sentry message on nine failure points: plugin missing, init timeout, product not found, verification failed. Next time this path dies, an alert says so before the revenue does.

On the same phone, initialization finished in 1.1 seconds and four products came back. It went to production at 23:41 on September 10th.

How to check

For anyone hitting the same thing, here are the verification commands. The resource side is the trap: inside the APK the path is shortened to something like res/ZW.xml, so searching by filename finds nothing.

# what R8 removed (after a build)
grep -c "cc.fovea" android/app/build/outputs/mapping/release/usage.txt
 
# whether the resource survived — check the mapping, not the name
grep "xml:config" android/app/build/outputs/mapping/release/resources.txt
 
# whether a real device got as far as native
adb logcat | grep "D/CdvPurchase"

A release that skipped the check

The loss itself is small. Eleven app payments in August, 69,300 KRW, became zero in September. The problem was more that one revenue path was completely dead and for ten days nothing told me.

I'll never know how many people tried to pay and gave up. The failures never reached the server, so there's no data to count.

At some point I'd replaced a check I used to do on every release with "it's been fine so far," and the first day that substitution failed happened to be the day I changed the build configuration. There was a store deadline pushing too. A release with a deadline is exactly the one that should have gone through the internal test track, and my judgement at the time went the other way.

Capacitor's own plugins ship their keep rules with the library, so they're protected; Cordova-compatible plugins aren't. Since normally only the web gets deployed, I'd forgotten that layer was there at all, and it showed up this time because I touched the native build.

It hasn't been a full day yet. On a real device I've confirmed initialization and product lookup, but until payments from real users start coming back I can't really call it fixed. I think it needs a few more days of watching.

End