DEV Community

nicoinu
nicoinu

Posted on

Android needed almost no code changes, and still ate a full day in configuration

Once subscriptions were working on iOS, I added Android billing to the same app.

The app code I changed came to one place, in practice: the branch that picks an API key per platform. And I had already written that back when I did iOS.

The configuration is where the day went. Play Console, RevenueCat, and a handful of Android behaviours I didn't know about. This is a record of that.

I'm on Expo SDK 54, react-native-purchases v10, with a NestJS backend.

"Shipping subscriptions with RevenueCat: from store setup to environment layout" (3 parts)

  1. My subscribe button was greyed out, and that got my app rejected
  2. Android needed almost no code changes, and still ate a full day in configuration ← you are here
  3. Where to draw the line between test and production, when there is no right answer

1. First, the vocabulary: a Play product has two levels

In part 1 I described how RevenueCat's Product, Entitlement and Offering do three different jobs. Adding Android doesn't change that structure. You just get more Products.

What changes is how you create a Product, and this is where Google diverges sharply from Apple.

App Store Connect Google Play Console
Shape of a product One product, one level A "base plan" inside a "subscription": two levels
Monthly vs yearly Put them in the same Subscription Group Create them as separate subscriptions (there is no group concept)
ID you register in RevenueCat premium_monthly premium_monthly:monthly (subscription ID : base plan ID)

On Google Play you first create a container called a subscription, then create a base plan inside it that holds the billing period and the price. The base plan is the thing that actually charges.

Note
premium_monthly and monthly in that table are names I made up for this article. Neither is a reserved word, so you pick both strings yourself.

What matters is that when you register the product in RevenueCat, those two levels become a single ID joined by a colon.

Those two levels are also what cost me half a day later on.

2. The code I changed came to one place

I'd written the platform split during the iOS work. This is the only thing I touched in the app for Android.

// src/providers/purchases-provider.tsx
const getApiKey = (): string | null => {
  if (Platform.OS === 'ios') return process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY ?? null;
  if (Platform.OS === 'android') return process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY ?? null;
  return null;
};
Enter fullscreen mode Exit fullscreen mode

Since that was already there, all I really did was add the Android key to my environment variables.

The server needed zero changes. In the webhook RevenueCat sends, the field naming the store flips from APP_STORE to PLAY_STORE, and the existing handling takes it from there. Neither the app nor the server looks at anything but the entitlement, so adding a store doesn't add a branch.

Note
You don't have to add the Android billing permission by hand either. Autolinking in react-native-purchases merges it into AndroidManifest.xml. There was no native configuration work at all.

So the substance of "adding Android" was configuration from the start.

3. Snag 1: not a single product came back

I created the subscription and base plan in Play Console and activated them. I created a GCP service account, connected it to RevenueCat, registered the Android products, and attached them to an entitlement.

I opened the purchase screen and got zero products. The log had this.

Offerings fetch failed [23] ConfigurationError
Enter fullscreen mode Exit fullscreen mode

With this body.

You have configured the SDK with a Play Store API key, but there are no
Play Store products registered in the RevenueCat dashboard for your offerings.
Enter fullscreen mode Exit fullscreen mode

Open RevenueCat's Products screen and they're right there. The Entitlements column even says "1 Entitlements".

The RevenueCat Products list, with

Registered. Attached. Still not delivered. I spent about an hour going back and forth over the Play Console side. Is the base plan active, is there a price on it, is the app published to a track. All of it was fine.

4. Six words: "for your offerings"

Then I reread the error.

there are no Play Store products registered in the RevenueCat dashboard for your offerings

It doesn't say there are no products. It says there are no products for your offerings. I had been reading straight past those last three words.

Caution
RevenueCat configuration has three stages, and stopping at the second one gets you zero products.

1. Products      register the product      premium_monthly:monthly
      ↓
2. Entitlements  attach it to premium      ← this is what shows "1 Entitlements"
      ↓
3. Offerings     put it in a Package on default   ← the one you forget

Finish stage two and the Products list reports "1 Entitlements". That is the trap: it looks done. But the app fetches from the Offering, so skipping stage three leaves you with an Offering that holds no Play products at all.

In the vocabulary section of part 1, I drew Product with two arrows coming out of it, one to Entitlement and one to Offering. The Offering arrow is exactly the one I forgot. I drew the diagram myself and then walked into it.

The fix is just RevenueCat → Offerings → default → add the Android products to each Package. Don't remove the iOS products. A Package means something like "the monthly slot", so the App Store product and the Play Store product sit in it side by side.

5. Snag 2: the release build failed lint

With products showing up, I built for distribution. lintVitalRelease failed, on ExtraTranslation.

The cause was the locales setting in app.config.ts. I had written it thinking of iOS only, and it applies to Android too. It generates values-b+{lang}/strings.xml per locale, and iOS-only keys (the usage descriptions for camera and microphone) were being emitted into them. There's no matching string on the default locale, so lint calls it an extra translation and fails the build.

I fixed it by nesting those under an "ios" key inside the locale JSON so they never reach Android.

Warning
This is the kind of problem you cannot see from iOS. I should have pushed one release build through the moment I added Android. The development build passes, so it stays hidden until you're about to distribute.

6. Snag 3: the app I'd just fixed wouldn't start

I fixed the lint error, built locally, installed to the device, and got the old behaviour. My change looked like it hadn't taken.

I started suspecting configuration again. This time two causes were stacked on top of each other.

Caution
First. I use appVersionSource: "remote", so versionCode in android/app/build.gradle was still sitting at its initial value of 1. The device had the build Play had delivered (31 at the time), so the install was being refused as a downgrade.

Second. adb install can return exit code 0 even when it fails. My build script took that as success and moved on, and what actually launched was the old app.

So my fix had never reached the device, and I was reading behaviour as though it had. I suspected the code, then the configuration, and skipped the one thing worth suspecting first: whether the thing was installed at all.

I now sync versionCode in the build script to something above what's on the device, and verify the install result explicitly.

7. Snag 4: the manage button opened nothing

Purchases were going through, so I checked the cancellation path. Tapping "Manage subscription" produced a generic error and opened nothing.

I took the long way round here too. I suspected the device couldn't open Play's subscription screen, so I fired it with am start, and it opened fine. I thought the management URL from RevenueCat might be empty, but the code wasn't even getting that far. I assumed a configuration mistake, but there was not one matching error anywhere.

I found it the moment I stopped swallowing the exception.

// Before. The exception isn't bound, so nothing reaches the log
try {
  await manageSubscriptions();
} catch {
  Alert.alert(t('common.error'), t('pricing.error.manageFailed'));
}
Enter fullscreen mode Exit fullscreen mode

I changed catch {} to catch (err), logged it, rebuilt, and had the answer on the first run.

[Purchases] showManageSubscriptions failed
  [Error: This method is not available in the current platform.]
Enter fullscreen mode Exit fullscreen mode

Caution
Purchases.showManageSubscriptions() is an iOS-only API. It isn't implemented on Android. The management URL was never empty. The method simply doesn't exist on the platform.

iOS has a system-provided management sheet. Android has no equivalent. You have to send the user to the relevant Play Store screen yourself.

The fix branches on platform and keeps three levels of fallback.

// src/providers/purchases-provider.tsx
const STORE_SUBSCRIPTION_URL: Record<string, string> = {
  ios: 'https://apps.apple.com/account/subscriptions',
  android: 'https://play.google.com/store/account/subscriptions',
};

const manageSubscriptions = async () => {
  // showManageSubscriptions() is iOS-only and always fails when called on Android
  if (Platform.OS === 'ios') {
    try {
      await Purchases.showManageSubscriptions();
      return;
    } catch (err) {
      console.warn('[Purchases] showManageSubscriptions failed', err);
    }
  }

  const fallbackUrl = customerInfo?.managementURL ?? STORE_SUBSCRIPTION_URL[Platform.OS];
  if (!fallbackUrl) {
    throw new Error(`No subscription management URL available for platform ${Platform.OS}`);
  }
  await Linking.openURL(fallbackUrl);
};
Enter fullscreen mode Exit fullscreen mode

Warning
The hardcoded URL at the third level is there because the management URL RevenueCat returns can be empty on a perfectly healthy path. The type is string | null, and it comes back empty for test purchases and in the window right after a purchase while state is still syncing. Getting stuck here removes the user's way to cancel, so I keep a last resort.

8. What the four had in common

Looking back at the day, none of the four snags was technically hard. What was hard is that they were failing without looking like they were failing.

Where I got stuck Why the failure was hidden
No products returned "1 Entitlements" in the Products list made it look finished
Release build failed The development build passes, so it stays out of sight until distribution
Fix didn't take adb install returned 0 after failing
Manage screen wouldn't open catch {} threw the exception away

An error you can see is an error you can read. The nasty case is when a healthy-looking screen or exit code is covering a failure. What I added over that day wasn't a feature. It was emulator startup logging, install verification, and exception output on the purchase screen.

9. Test settings that keep you off a real card

I push to the internal test track and verify on a real device.

pnpm build:development:android    # build the AAB
pnpm submit:development:android   # upload to the internal test track
Enter fullscreen mode Exit fullscreen mode

In Play Console you register your testers' email addresses, and each tester opens the opt-in URL and accepts. Without that, they can't even see the app.

On top of that, there is a second registration you need.

Caution

Users incur actual charges for their test track purchases unless the user is a license tester.
(Test your Google Play Billing Library integration)

Buy with an account that isn't registered as a license tester and you are charged on a real credit card. That holds even for a build installed from the internal test track. It isn't decided by the track. It's decided by whether the purchasing account is a license tester.

You register them under Settings → License testing in Play Console. You can tell it worked by whether the purchase sheet says "Test card, always approves". If you see an actual card, stop right there.

10. On Android, a cancellation path is mandatory

The management link I fixed in section 7 is not a nice-to-have on Android.

Google Play policy requires that you provide a way to cancel from inside the app (Manage and cancel subscriptions). Linking out to the Play Store management screen is the straightforward way to satisfy it.

Apple's review requirement for the purchase screen (3.1.2(c)) asks for something different: that the subscription auto-renews, its duration, its price, and links to terms and privacy. An in-app management link isn't on that list.

Warning
So the bug in section 7 reads as "one button doesn't work" if you're only looking at iOS, and as a policy risk on Android. Shipping it broken wasn't an option.

11. Wrapping up

Three things came out of adding Android.

Absorbing platform differences in the first implementation really does pay. Because I'd written the API key branch during the iOS work, app code changes were close to zero. The flip side is that anywhere I skipped a branch because iOS worked fine, like showManageSubscriptions(), Android surfaced it immediately.

Don't carry iOS concepts over unexamined. There is no Subscription Group, product IDs are two levels joined together, there's no native management API, and even the renewal interval during testing is different. About all the two have in common is the word "store".

Make failures look like failures. Don't write catch {}, don't trust an exit code, don't treat a dashboard label as proof of completion. All four of these were preventable by that alone.

Next: where to draw the line between development billing and production billing. RevenueCat's own docs offer two options and stop there, and it's an area where staff answers point in opposite directions depending on who asked.

References

Top comments (0)