fix: move Google BillingClient connection off the main thread#3369
Conversation
`BillingClient.startConnection()` has been reported to cause ANRs when
called on the main thread. Play Billing Library supports calling it from
a background thread, so post both `startConnection()` and
`endConnection()` to a dedicated `HandlerThread("revenuecat-billing")`.
Pending-request draining and consumer callbacks still run on the main
handler, so observable threading for SDK consumers is unchanged. Amazon
keeps its main-thread requirement (needed during `Activity.onCreate`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| stateProvider, | ||
| ) | ||
| Store.PLAY_STORE -> { | ||
| val backgroundThread = HandlerThread("revenuecat-billing").apply { start() } |
There was a problem hiding this comment.
Note I decided to use a separate thread from the one we use for network connections and other things, so we avoid overloading that single thread with many operations. This thread will be dedicated to just connecting/disconnecting from the billing client, so won't have much work normally.
There was a problem hiding this comment.
Do we need to tear down this tread at some point? Reading the docs we shouldn't call stop(), but do you know what will happen if we reconfigure the SDK and create a new HandlerThread with the same identifier again?
There was a problem hiding this comment.
Yup, seeing the comment from cursor, I'm now actually handling the disconnection right now, so we stop the thread when disconnecting. Regarding having multiple HandlerThread with the same name. That's allowed, since the name is mostly meant as a debugging tool, but each thread created is actually unique.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3369 +/- ##
==========================================
+ Coverage 79.34% 79.37% +0.03%
==========================================
Files 354 354
Lines 14268 14276 +8
Branches 1951 1954 +3
==========================================
+ Hits 11321 11332 +11
Misses 2144 2144
+ Partials 803 800 -3 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 6b36349. Configure here.
rickvdl
left a comment
There was a problem hiding this comment.
Nice work 🙌 Few small questions
| stateProvider, | ||
| ) | ||
| Store.PLAY_STORE -> { | ||
| val backgroundThread = HandlerThread("revenuecat-billing").apply { start() } |
There was a problem hiding this comment.
Do we need to tear down this tread at some point? Reading the docs we shouldn't call stop(), but do you know what will happen if we reconfigure the SDK and create a new HandlerThread with the same identifier again?
| // Start connection has to be called on onCreate, otherwise Amazon fails to detect the foregrounded Activity | ||
| // Be careful with doing mainHandler.post since that will not guarantee that it's called in onCreate | ||
| // runOnUIThread checks if the function is called in the UI thread and doesn't do post, so we are good since | ||
| // startConnection is called on the main thread |
There was a problem hiding this comment.
This is probably still true, but it's no longer in the method signature (onMainThread), could we add an assertion in this method or similar?
There was a problem hiding this comment.
Actually, it doesn't matter which thread this is called on for Amazon, since we do runOnUIThread, to actually start the connection. So we should be safe here. I think this comment mostly refers to the need of wrapping that connection in that function. So I don't think we need any assertion here. Lmk if you think otherwise!
|
|
||
| testStoreBilling.close() | ||
| assertThat(testStoreBilling.isConnected()).isFalse() | ||
| } |
There was a problem hiding this comment.
Are we able to reproduce the ANR in a test by any chance? If so we could verify the fix as well. But I assume it's tricky..
There was a problem hiding this comment.
In a test it's indeed tricky I'm afraid... But I added some tests to make sure we keep this in the main thread 🙏 Lmk what you think!
| stateProvider, | ||
| ) | ||
| Store.PLAY_STORE -> { | ||
| val backgroundThread = HandlerThread("revenuecat-billing").apply { start() } |
There was a problem hiding this comment.
Can we inject this as a dependency? Maybe it's not worth it? I was thinking it would be helpful for tests, maybe we can test we are quitting it (when adding Cursor's suggestion https://github.com/RevenueCat/purchases-android/pull/3369/files#r3115790059)
There was a problem hiding this comment.
I did something like that in the follow-up commits, including adding some tests. However, what we inject is a Handler so it's easier to test. Otherwise, if we inject a HandlerThread we would need to mock the internal behavior anyway... Lmk what you think! 🙏
Follow-up to the previous change that moved `BillingClient.startConnection()` to a background thread. The factory was creating a `HandlerThread` and dropping the reference, so each `Purchases.configure()` leaked a native thread. Move ownership into `BillingWrapper` via a default-valued constructor parameter and quit the thread in an overridden `close()` after `endConnection()`'s cleanup drains. Tests that inject a mocked `Handler` don't spin up a real thread. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Assert startConnection() posts to the injected background handler and does not touch the main handler. - Assert endConnection() posts cleanup to the background handler only. - Assert close() quits the HandlerThread owned by BillingWrapper (leak regression guard for the default-owned thread path). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…the connection to finish
vegaro
left a comment
There was a problem hiding this comment.
Love the changes! Very confident this will fix a lot of the ANRs
**This is an automatic release.** ## RevenueCat SDK ### 🐞 Bugfixes * fix: move Google BillingClient connection off the main thread (RevenueCat#3369) via Toni Rico (@tonidero) * [EXTERNAL] fix(google): guard showInAppMessages against BillingClient runtime crashes (RevenueCat#3367) by @matteinn (RevenueCat#3368) via Monika Mateska (@MonikaMateska) ## RevenueCatUI SDK ### Paywallv2 #### 🐞 Bugfixes * Add Workflows network layer (RevenueCat#3300) via Cesar de la Vega (@vegaro) ### 🔄 Other Changes * Fix `revenuecat.useWorkflowsEndpoint` compiler flag (RevenueCat#3374) via Cesar de la Vega (@vegaro) * Create paywall from workflow response. Add `USE_WORKFLOWS_ENDPOINT` BuildConfig (RevenueCat#3350) via Cesar de la Vega (@vegaro) * Refactor: Remove unnecessary lint suppressions (RevenueCat#3373) via cursor[bot] (@cursor[bot]) * Bump fastlane-plugin-revenuecat_internal from `a1eed48` to `b822f01` (RevenueCat#3371) via dependabot[bot] (@dependabot[bot]) * Bump fastlane from 2.232.2 to 2.233.0 (RevenueCat#3370) via dependabot[bot] (@dependabot[bot]) * Attempt to fix `AssertionError` "ms is denormalized" in `QueryPurchasesUseCaseTest` (RevenueCat#3361) via Cesar de la Vega (@vegaro) * Update baseline profiles (RevenueCat#3296) via Jaewoong Eum (@skydoves) * fix: reduce precision for flaky HeaderDirectHeroImage snapshot (RevenueCat#3362) via Cesar de la Vega (@vegaro) * Fix test failures reported twice (RevenueCat#3360) via Cesar de la Vega (@vegaro) * refactor: extract `updateStateFromOffering` in `PaywallViewModel` (RevenueCat#3359) via Cesar de la Vega (@vegaro) * [Fix] Include parent tabs component_name in tab-control switch interaction events (RevenueCat#3358) via Monika Mateska (@MonikaMateska) * Refactor: Remove unnecessary lint suppressions (RevenueCat#3348) via cursor[bot] (@cursor[bot]) * fix: always upload CI test results even when tests fail (RevenueCat#3357) via Cesar de la Vega (@vegaro) * refactor: extract `RevenueCatDialogScaffold` (RevenueCat#3355) via Cesar de la Vega (@vegaro) * Fix Slack notifications for nightly integration tests (RevenueCat#3354) via Toni Rico (@tonidero) * UI events for paywall component interactions (RevenueCat#3287) via Monika Mateska (@MonikaMateska) * Bump fastlane-plugin-revenuecat_internal from `20911d1` to `a1eed48` (RevenueCat#3351) via dependabot[bot] (@dependabot[bot]) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Primarily a version bump and release automation updates (docs deploy/redirect and changelog); no functional library code changes beyond updating embedded version constants. > > **Overview** > Cuts the `10.2.1` release by updating version references across the repo (Gradle `VERSION_NAME`, internal `frameworkVersion`, sample/test app dependency pins, and `.version`). > > Updates the docs release pipeline and website redirect to publish and point at `10.2.1`, and refreshes `CHANGELOG.md`/`CHANGELOG.latest.md` with the 10.2.1 release notes. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a0a325b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->

Summary
BillingClient.startConnection()andendConnection()to a dedicatedHandlerThread("revenuecat-billing")to address reported ANRs when the connection work blocks the main thread duringbindService.startConnection()from a background thread. Pending-request draining and consumer callbacks still run on the mainHandler, so observable threading for SDK consumers is unchanged.registerListenerto run synchronously duringActivity.onCreateto detect the foreground activity).BillingAbstract.startConnectionOnMainThread(delay)and the parameterlessstartConnection()into a singlestartConnection(delayMilliseconds: Long = 0)entry point.BillingAbstractis@InternalRevenueCatAPI, so the rename only affects hybrid SDKs.Changes
BillingAbstract: singlestartConnection(delay)entry point, with KDoc noting that impls choose the thread.BillingWrapper(Google):backgroundHandler: Handler.startConnection(delay)now posts tobackgroundHandler; the actual work lives in a new privateperformStartConnection()helper.endConnection()also posts tobackgroundHandlerfor symmetry.@UiThreadannotation onClientFactory.buildClient—BillingClient.newBuilder()doesn't require the UI thread.executePendingRequests) still posts tomainHandler, preserving callback-thread semantics for callers.BillingFactory: creates aHandlerThread("revenuecat-billing")for theStore.PLAY_STOREbranch and passes its looper intoBillingWrapper.AmazonBilling: override renamed; retains the existingrunOnUIThread { … }and the comment explaining the Amazon main-thread requirement. Connection logic extracted toperformStartConnection()(internal@VisibleForTesting).GalaxyBillingWrapper,SimulatedStoreBillingWrapper: override renamed; no behavior change.PurchasesOrchestrator: call site renamed.BillingWrapperTest,BaseBillingUseCaseTest,BasePurchasesTest,SimulatedStoreBillingWrapperTest): pass through to the new signature; the existing mockedHandleris reused for bothmainHandlerandbackgroundHandlersopost/postDelayedkeep running synchronously in tests.baseline-prof.txt: symbol renames updated.Test plan
./gradlew :purchases:testDefaultsBc8DebugUnitTest./gradlew :purchases:testDefaultsBc7DebugUnitTest./gradlew detektAll./gradlew :ui:revenuecatui:compileDefaultsBc8DebugKotlin+:feature:amazon:compileDefaultsBc8DebugKotlinpurchase-testerwith StrictMode enabled; confirm no main-threadbindServiceviolation fromBillingClient.startConnection.🤖 Generated with Claude Code
Note
Medium Risk
Changes threading and lifecycle behavior around Play Billing service connection, which can affect stability (race conditions, leaked threads, missed callbacks) if not handled correctly, though the change is localized to billing wrappers and covered by updated tests.
Overview
Moves Google Play Billing connection setup/teardown off the main thread by introducing a background
Handler/ownedHandlerThreadinBillingWrapper, postingstartConnection(...)andendConnection()work there while keeping pending-request callbacks on the mainHandler.Refactors the billing abstraction to a single
BillingAbstract.startConnection(delayMilliseconds)entry point (removingstartConnectionOnMainThread/parameterlessstartConnection) and updates Amazon, Galaxy, Simulated Store, orchestrator call sites, tests, andbaseline-prof.txtto the new API;BillingWrapper.close()now also shuts down the owned background thread.Reviewed by Cursor Bugbot for commit b39d21e. Bugbot is set up for automated code reviews on this repo. Configure here.