Skip to content

Crash in 2.0.0-beta-6: lateinit purchaseProductParams dereferenced unguarded in onPurchasesUpdated #131

Description

@talhatsk

Artifact: com.chargebee:chargebee-android:2.0.0-beta-6 (Maven Central, published 2026-08-21)
Introduced by: PR #124 "Upgrade Google billing library to 8.3.0" (merged 2026-08-14)
Play Billing: com.android.billingclient:billing:8.3.0
Regression from: 1.2.4 (does not crash)
Severity: hard crash on the main thread, inside a BroadcastReceiver, on a monetization path


Summary

BillingClientManager.purchaseProductParams is a lateinit property that is written in exactly
one
place — BillingClientManager.purchase(PurchaseProductParams) — but is dereferenced
unguarded by both acknowledgePurchase(Purchase) and notifyPurchaseError(CBException), which
are reachable from onPurchasesUpdated.

PurchasesUpdatedListener.onPurchasesUpdated is an out-of-band callback: Google Play delivers
purchase updates that the SDK did not initiate. Whenever that happens before (or without) a
Chargebee-initiated purchase in the same BillingClientManager instance, the SDK throws
UninitializedPropertyAccessException from inside Play's broadcast receiver, which Android
escalates to a fatal RuntimeException.

This is made much more likely in beta-6 by the newly added enableAutoServiceReconnection(), which
keeps the SDK's BillingClient connected — and therefore subscribed to purchase broadcasts —
indefinitely.

Stack trace

FATAL EXCEPTION: main
Process: com.onestream.live.development, PID: 18946
java.lang.RuntimeException: Error receiving broadcast Intent { act=com.android.vending.billing.LOCAL_BROADCAST_PURCHASES_UPDATED flg=0x10 pkg=com.onestream.live.development (has extras) } in com.android.billingclient.api.zzaa@ba2effc
	at android.app.LoadedApk$ReceiverDispatcher$Args.lambda$getRunnable$0(LoadedApk.java:1822)
	at android.app.LoadedApk$ReceiverDispatcher$Args.$r8$lambda$mcNAAl1SQ4MyJPyDg8TJ2x2h0Rk(LoadedApk.java:0)
	at android.app.LoadedApk$ReceiverDispatcher$Args$$ExternalSyntheticLambda0.run(D8$$SyntheticClass:0)
	at android.os.Handler.handleCallback(Handler.java:959)
	at android.os.Handler.dispatchMessage(Handler.java:100)
	at android.os.Looper.loopOnce(Looper.java:232)
	at android.os.Looper.loop(Looper.java:317)
	at android.app.ActivityThread.main(ActivityThread.java:8705)
	at java.lang.reflect.Method.invoke(Native Method)
	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:580)
	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:886)
Caused by: kotlin.UninitializedPropertyAccessException: lateinit property purchaseProductParams has not been initialized
	at com.chargebee.android.billingservice.BillingClientManager.acknowledgePurchase(BillingClientManager.kt:358)
	at com.chargebee.android.billingservice.BillingClientManager.onPurchasesUpdated(BillingClientManager.kt:314)
	at com.android.billingclient.api.zzaa.onReceive(com.android.billingclient:billing@@8.3.0:38)
	at android.app.LoadedApk$ReceiverDispatcher$Args.lambda$getRunnable$0(LoadedApk.java:1814)
	... 11 more

Bytecode evidence

All offsets from javap -p -c on classes.jar inside the published
chargebee-android-2.0.0-beta-6.aar.

1. onPurchasesUpdated routes PURCHASED straight into acknowledgePurchase

public void onPurchasesUpdated(BillingResult, List<Purchase>);
      63: invokevirtual  Purchase.getPurchaseState:()I
      68: tableswitch { 0: 145, 1: 96, 2: 105, default: 182 }
      96: aload_0
      97: aload         7
      99: invokespecial  acknowledgePurchase:(Lcom/android/billingclient/api/Purchase;)V

No check that this SDK initiated the purchase.

2. acknowledgePurchase dereferences the lateinit immediately

private final void acknowledgePurchase(Purchase);
       0: aload_0
       1: getfield       purchaseProductParams:Lcom/chargebee/android/models/PurchaseProductParams;
       4: dup
       5: ifnonnull      16
       8: pop
       9: ldc_w          "purchaseProductParams"
      12: invokestatic   Intrinsics.throwUninitializedPropertyAccessException:(Ljava/lang/String;)V   // <-- crash
      15: aconst_null
      16: invokevirtual  PurchaseProductParams.getProduct:()Lcom/chargebee/android/models/CBProduct;
      19: invokevirtual  CBProduct.getType:()Lcom/chargebee/android/billingservice/ProductType;

3. notifyPurchaseError has the identical unguarded deref

So the error path crashes too, meaning there is no safe way for the SDK to report this condition:

private final void notifyPurchaseError(CBException);
       0: aload_0
       1: getfield       purchaseProductParams:...
       5: ifnonnull      16
      12: invokestatic   Intrinsics.throwUninitializedPropertyAccessException   // <-- crash
      16: invokevirtual  PurchaseProductParams.getProduct:()...

4. Exactly one writer, four readers

putfield purchaseProductParams   ->  1 site, inside  private final void purchase(PurchaseProductParams)
getfield purchaseProductParams   ->  4 sites

So the field is only ever initialised by an SDK-initiated purchase, while the readers are reachable
from an out-of-band Play broadcast.

Why this is not specific to one integration

onPurchasesUpdated legitimately fires without a preceding SDK-initiated purchase in at least these
cases, all of which should crash:

  • a subscription renewal delivered while the app is in the foreground
  • a promo code redeemed in the Play Store app
  • a PENDING purchase completing later (deferred payment methods)
  • a purchase syncing from another device on the same Google account
  • any purchase started by a second BillingClient in the same process — Play broadcasts
    LOCAL_BROADCAST_PURCHASES_UPDATED to every registered client

Our own reproduction is the last one: we run a second BillingClient for subscription
replacement (upgrade/downgrade proration), because the Chargebee SDK has no replacement API —
BillingFlowParams.SubscriptionUpdateParams.setOldPurchaseToken and
setSubscriptionReplacementMode have zero references in beta-6. So an app that supports plan
changes must run a second client, and then every plan change crashes.

Reproduction

  1. Integrate chargebee-android:2.0.0-beta-6.
  2. Buy a subscription via CBPurchase.purchaseProduct (succeeds).
  3. Complete a purchase that this SDK did not initiate — easiest is a second BillingClient in
    the same process performing a subscription replacement, but a promo-code redemption or a renewal
    arriving while the app is open should also do it.
  4. Play broadcasts the update, onPurchasesUpdated fires, and the app crashes as above.

Regression detail vs 1.2.4

Two changes combine to make this reachable where 1.2.4 was safe.

(a) 1.2.4 used a plain nullable field, not lateinit.

1.2.4:   public com.chargebee.android.models.CBProduct product;   // + getProduct()/setProduct()
beta-6:  private com.chargebee.android.models.PurchaseProductParams purchaseProductParams;  // lateinit

A null field degraded silently; the lateinit throws.

(b) isAcknowledgedPurchase no longer short-circuits on an already-acknowledged purchase.

1.2.4   isAcknowledgedPurchase @0-4:
       1: invokevirtual  Purchase.isAcknowledged:()Z
       4: ifne 54                       // already acknowledged -> jump to end, DO NOTHING

beta-6  isAcknowledgedPurchase @0-26:
       1: invokevirtual  Purchase.isAcknowledged:()Z
       4: ifeq 27
      11: ldc_w          "Google Purchase - already acknowledged"
      14: invokestatic   Log.i
      23: invokespecial  onAcknowledged:(Purchase;I;Function0;Function1;)V   // proceeds to validateReceipt
      26: return

This looks like the intended "fixing stalled acknowledgment logic" change in PR #124, and in
isolation it is an improvement. But combined with (a) it means an externally-acknowledged purchase
now walks into onAcknowledgedvalidateReceipt, using whatever purchaseProductParams happens
to hold. In our app that produces a second, quieter bug: when a Chargebee purchase did occur
earlier in the session, the SDK silently validates the wrong product against Chargebee and
surfaces BillingErrorCode.SERVICE_UNAVAILABLE ("The service is currently unavailable") to the
user after a payment that actually succeeded.

Suggested fix

Guard both readers, e.g.:

private fun acknowledgePurchase(purchase: Purchase) {
    if (!::purchaseProductParams.isInitialized) {
        // Purchase not initiated by this SDK instance (renewal, promo code, pending
        // completion, other BillingClient). Acknowledge without product-typed handling,
        // or ignore.
        return
    }
    ...
}

More robustly, onPurchasesUpdated could correlate the incoming Purchase.products against the
product the SDK actually launched, and ignore updates it did not initiate — that also prevents the
wrong-product validation described above.

Making purchaseProductParams a nullable var rather than lateinit would restore 1.2.4's
failure-tolerance as a minimum.

Additional note

2.0.0-beta-6 is on Maven Central but has no git tag, no GitHub release, and no CHANGELOG
entry
— the newest tag is 2.0.0-beta-5. Since it is currently the only Chargebee Android build
on Play Billing 8, and Google requires Billing ≥ 8.0.0 for app updates from 31 Aug 2026,
integrators are being pushed onto an artifact with no release notes. A tagged release (and a
follow-up with this fix) would help a lot.

Environment

chargebee-android 2.0.0-beta-6
Play Billing 8.3.0
React Native wrapper @chargebee/react-native-chargebee 2.5.2 (patched for the beta-6 API)
App React Native 0.79.7, Kotlin 2.0.21, AGP 8.8.2, minSdk 24, targetSdk 35
Build debuggable development flavor (com.onestream.live.development), R8 disabled
Device Android 15 (confirm/replace with the exact OS version and model)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions