Skip to content

fix: move Google BillingClient connection off the main thread#3369

Merged
tonidero merged 8 commits into
mainfrom
fix/billing-client-connection-background-thread
Apr 22, 2026
Merged

fix: move Google BillingClient connection off the main thread#3369
tonidero merged 8 commits into
mainfrom
fix/billing-client-connection-background-thread

Conversation

@tonidero

@tonidero tonidero commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Moves Google's BillingClient.startConnection() and endConnection() to a dedicated HandlerThread("revenuecat-billing") to address reported ANRs when the connection work blocks the main thread during bindService.
  • Play Billing Library supports calling startConnection() from a background thread. 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 (Amazon's SDK needs registerListener to run synchronously during Activity.onCreate to detect the foreground activity).
  • API refactor: collapses BillingAbstract.startConnectionOnMainThread(delay) and the parameterless startConnection() into a single startConnection(delayMilliseconds: Long = 0) entry point. BillingAbstract is @InternalRevenueCatAPI, so the rename only affects hybrid SDKs.

Changes

  • BillingAbstract: single startConnection(delay) entry point, with KDoc noting that impls choose the thread.
  • BillingWrapper (Google):
    • New constructor-injected backgroundHandler: Handler.
    • startConnection(delay) now posts to backgroundHandler; the actual work lives in a new private performStartConnection() helper.
    • endConnection() also posts to backgroundHandler for symmetry.
    • Removed the self-imposed @UiThread annotation on ClientFactory.buildClientBillingClient.newBuilder() doesn't require the UI thread.
    • Pending-request queue draining (executePendingRequests) still posts to mainHandler, preserving callback-thread semantics for callers.
  • BillingFactory: creates a HandlerThread("revenuecat-billing") for the Store.PLAY_STORE branch and passes its looper into BillingWrapper.
  • AmazonBilling: override renamed; retains the existing runOnUIThread { … } and the comment explaining the Amazon main-thread requirement. Connection logic extracted to performStartConnection() (internal @VisibleForTesting).
  • GalaxyBillingWrapper, SimulatedStoreBillingWrapper: override renamed; no behavior change.
  • PurchasesOrchestrator: call site renamed.
  • Tests (BillingWrapperTest, BaseBillingUseCaseTest, BasePurchasesTest, SimulatedStoreBillingWrapperTest): pass through to the new signature; the existing mocked Handler is reused for both mainHandler and backgroundHandler so post/postDelayed keep 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:compileDefaultsBc8DebugKotlin
  • Manual: cold-start purchase-tester with StrictMode enabled; confirm no main-thread bindService violation from BillingClient.startConnection.
  • Manual: verify product fetch, purchase, and restore still work on a real device.
  • Integration tests on CI

🤖 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/owned HandlerThread in BillingWrapper, posting startConnection(...) and endConnection() work there while keeping pending-request callbacks on the main Handler.

Refactors the billing abstraction to a single BillingAbstract.startConnection(delayMilliseconds) entry point (removing startConnectionOnMainThread/parameterless startConnection) and updates Amazon, Galaxy, Simulated Store, orchestrator call sites, tests, and baseline-prof.txt to 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.

`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>
@tonidero tonidero added the pr:fix A bug fix label Apr 20, 2026
stateProvider,
)
Store.PLAY_STORE -> {
val backgroundThread = HandlerThread("revenuecat-billing").apply { start() }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

@codecov

codecov Bot commented Apr 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.90909% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.37%. Comparing base (e7967c2) to head (b39d21e).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
.../com/revenuecat/purchases/google/BillingWrapper.kt 87.87% 2 Missing and 2 partials ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tonidero tonidero marked this pull request as ready for review April 21, 2026 07:29
@tonidero tonidero requested a review from a team as a code owner April 21, 2026 07:29
@tonidero tonidero requested a review from vegaro April 21, 2026 07:30

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread purchases/src/main/kotlin/com/revenuecat/purchases/BillingFactory.kt Outdated

@rickvdl rickvdl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work 🙌 Few small questions

stateProvider,
)
Store.PLAY_STORE -> {
val backgroundThread = HandlerThread("revenuecat-billing").apply { start() }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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..

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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! 🙏

tonidero and others added 2 commits April 21, 2026 10:00
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>
Comment thread purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingWrapper.kt Outdated
Comment thread purchases/src/test/java/com/revenuecat/purchases/google/BillingWrapperTest.kt Outdated

@vegaro vegaro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love the changes! Very confident this will fix a lot of the ANRs

Comment thread purchases/src/main/kotlin/com/revenuecat/purchases/amazon/AmazonBilling.kt Outdated
@tonidero tonidero added this pull request to the merge queue Apr 22, 2026
Merged via the queue into main with commit a8179a9 Apr 22, 2026
38 checks passed
@tonidero tonidero deleted the fix/billing-client-connection-background-thread branch April 22, 2026 12:35
matteinn pushed a commit to matteinn/purchases-android that referenced this pull request Apr 28, 2026
**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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants