CC-628: Refresh Customer Center after returning from manage subscriptions#3152
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3152 +/- ##
=======================================
Coverage 79.40% 79.40%
=======================================
Files 356 356
Lines 14345 14345
Branches 1959 1959
=======================================
Hits 11390 11390
Misses 2151 2151
Partials 804 804 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
0213708 to
8aa1dff
Compare
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Addresses review feedback to avoid side effects in constructor. Uses a state check (_state.value is NotLoaded) to prevent duplicate loads when the flow is re-subscribed during activity stop/start. Also fixes test mocks to use GoogleStoreProduct after merge with main. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… retry Replaces the refreshIfPossible/shouldRefreshOnResume retry pattern with a simpler approach: onActivityResumed awaits any active refresh job before starting its own, and chains the follow-up refresh inline. This avoids multiple simultaneous syncs and the sticky flag bug. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…hange Reverts the early-return to mutable-variable refactoring that didn't change behavior. The only meaningful diff is the forceSync parameter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tion When refreshing (e.g. follow-up refresh after returning from subscription management), carry over navigationState, navigationButtonType, restorePurchasesState, and showSupportTicketSuccessSnackbar from the current state so the user isn't yanked back to the main screen. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…urrent syncs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Separates forceSync from isRefresh so that onActivityStarted (normal background return) uses the lighter FETCH_CURRENT, while only onActivityResumed (after manage-subscription flows) uses syncPurchases to pick up external subscription changes from the Play Store. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
If the sharing coroutine is cancelled mid-load (e.g. subscriber disconnects during slow network), state is left at Loading. The onStart guard now also re-loads in this case to prevent a stuck spinner. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
If the user backgrounds during the follow-up delay, launchRefreshIfPossible now awaits the previous job before starting its refresh, preventing concurrent loadCustomerCenter calls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ases (#3198) ## Summary - Caches `isAutoRenewing` status per hashed token in `DeviceCache` (new SharedPreferences key) - `PostPendingTransactionsHelper` now detects when `isAutoRenewing` changes (e.g., user cancels/resubscribes via Play Store) and reposts only those tokens - This avoids needing `syncPurchases` to detect external subscription changes, which is problematic because it posts *all* purchases with `RESTORE` initiation source and can transfer purchases between users ### How it works 1. After `queryPurchases()`, the helper compares each token's current `isAutoRenewing` with the cached value 2. Tokens with changed status are included in the sync alongside any new (unposted) tokens 3. After detecting changes, the new statuses are saved to cache for next comparison Context: #3152 (comment) ## Test plan - [x] Unit tests for `DeviceCache.getPurchasesWithAutoRenewingChange` (5 tests: no cache, true→false, false→true, unchanged, not in sent cache) - [x] Unit tests for `DeviceCache.saveAutoRenewingStatus` (2 tests: saves JSON, skips null) - [x] Unit tests for `PostPendingTransactionsHelper` auto-renewing detection (4 tests: changed synced, combined with new purchases, no changes, status saved) - [ ] Manual test: cancel subscription in Play Store, return to app, verify cancellation detected without syncPurchases 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches core purchase token caching and pending-transaction sync behavior, including a SharedPreferences migration; bugs here could cause missed or repeated posts of active subscriptions. > > **Overview** > Adds a new unified token cache in `DeviceCache` that stores **hashed posted tokens plus per-token metadata** (currently `isAutoRenewing`) in a JSON map, with in-memory caching and migration from the legacy `StringSet` key. > > Updates pending purchase syncing to **repost only tokens whose `isAutoRenewing` changed** (in addition to newly-seen tokens), saving unchanged tokens’ auto-renewing status eagerly and updating changed tokens only after a successful post. > > Threads `isAutoRenewing` through receipt posting and billing wrappers (`Google`, `Amazon`, `Galaxy`) by extending `addSuccessfullyPostedToken` / `postTokenWithoutConsuming`, and expands unit tests to cover migration, change detection, and the new caching call sites. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 563fc97. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Will Taylor <wtaylor151@gmail.com>
…e refresh FETCH_CURRENT already posts pending purchases before returning, so syncPurchases is unnecessary here. This removes the forceSync parameter, the follow-up delayed refresh, and simplifies the onActivityResumed path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Guard in
onStartexcludesErrorstate, breaking recovery- I updated the
onStartguard to includeCustomerCenterState.Error, restoring automatic reload when collection restarts after an error.
- I updated the
- ✅ Fixed: Double identical refresh on manage subscriptions return
- I made
onActivityStartedskip its refresh whenshouldRefreshOnResumeis true so returning from manage subscriptions performs only the resume-triggered refresh.
- I made
Or push these changes by commenting:
@cursor push c762ef5beb
Preview (c762ef5beb)
diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModel.kt
--- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModel.kt
+++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/viewmodel/CustomerCenterViewModel.kt
@@ -210,7 +210,10 @@
override val state = _state
.onStart {
val currentState = _state.value
- if (currentState is CustomerCenterState.NotLoaded || currentState is CustomerCenterState.Loading) {
+ if (currentState is CustomerCenterState.NotLoaded ||
+ currentState is CustomerCenterState.Loading ||
+ currentState is CustomerCenterState.Error
+ ) {
loadCustomerCenter()
}
}
@@ -1036,7 +1039,9 @@
override fun onActivityStarted() {
if (wasBackgrounded) {
wasBackgrounded = false
- launchRefreshIfPossible()
+ if (!shouldRefreshOnResume) {
+ launchRefreshIfPossible()
+ }
}
}
diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelTests.kt
--- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelTests.kt
+++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/customercenter/data/CustomerCenterViewModelTests.kt
@@ -58,6 +58,7 @@
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
import org.assertj.core.api.Assertions.assertThat
import org.junit.After
import org.junit.Before
@@ -2589,6 +2590,31 @@
}
@Test
+ fun `state retries loading on restart when current state is Error`(): Unit = runBlocking {
+ setupPurchasesMock()
+
+ var configCalls = 0
+ coEvery { purchases.awaitCustomerCenterConfigData() } coAnswers {
+ configCalls++
+ if (configCalls == 1) {
+ throw PurchasesException(PurchasesError(PurchasesErrorCode.NetworkError, "Network error"))
+ }
+ configData
+ }
+
+ val model = setupViewModel()
+ model.state.filterIsInstance<CustomerCenterState.Error>().first()
+
+ // Wait past WhileSubscribed timeout so a new collection restarts upstream and runs onStart.
+ Thread.sleep(5_200)
+
+ withTimeout(2_000) {
+ model.state.filterIsInstance<CustomerCenterState.Success>().first()
+ }
+ assertThat(configCalls).isEqualTo(2)
+ }
+
+ @Test
fun `refreshCustomerCenter uses FETCH_CURRENT not syncPurchases`(): Unit = runBlocking {
setupPurchasesMock()
@@ -2647,18 +2673,12 @@
}
@Test
- fun `onActivityResumed awaits in-flight refresh before starting its own`(): Unit = runBlocking {
+ fun `onActivityStarted skips refresh when a resume refresh is pending`(): Unit = runBlocking {
setupPurchasesMock()
- // Gate the refresh from onActivityStarted
var fetchCurrentCalls = 0
- val fetchCurrentGate = CompletableDeferred<Unit>()
coEvery { purchases.awaitCustomerInfo(CacheFetchPolicy.FETCH_CURRENT) } coAnswers {
fetchCurrentCalls++
- if (fetchCurrentCalls == 2) {
- // Gate on the second call (first is initial load, second is onActivityStarted refresh)
- fetchCurrentGate.await()
- }
customerInfo
}
@@ -2685,23 +2705,19 @@
model.pathButtonPressed(context, cancelPath, purchaseInformation)
verify(timeout = 2_000) { context.startActivity(any()) }
- // Simulate lifecycle: stop -> start (triggers refresh) -> resume
+ // Simulate lifecycle: stop -> start -> resume
model.onActivityStopped(isChangingConfigurations = false)
model.onActivityStarted()
+ // onActivityStarted should not refresh when shouldRefreshOnResume is true.
+ assertThat(fetchCurrentCalls).isEqualTo(1)
+
model.onActivityResumed()
- // onActivityStarted refresh is in progress (gated at fetchCurrentCalls == 2).
- // onActivityResumed should be waiting for it to finish.
- assertThat(fetchCurrentCalls).isEqualTo(2)
-
- // Unblock the in-flight refresh — onActivityResumed will then start its own refresh
- fetchCurrentGate.complete(Unit)
-
val deadline = System.currentTimeMillis() + 2_000
- while (System.currentTimeMillis() < deadline && fetchCurrentCalls < 3) {
+ while (System.currentTimeMillis() < deadline && fetchCurrentCalls < 2) {
Thread.sleep(25)
}
- // Third call is from onActivityResumed's refresh
- assertThat(fetchCurrentCalls).isGreaterThanOrEqualTo(3)
+ // Second call is from onActivityResumed's refresh.
+ assertThat(fetchCurrentCalls).isEqualTo(2)
}
}This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
- Allow onStart to retry loadCustomerCenter when state is Error, restoring automatic recovery after failed initial loads - Skip onActivityStarted refresh when onActivityResumed will handle it, avoiding a redundant FETCH_CURRENT round-trip after manage subscriptions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tonidero
left a comment
There was a problem hiding this comment.
Mostly some questions, since I'm missing context
Use isRefreshing state check as guard instead of job-joining pattern, since onActivityStarted already skips when shouldRefreshOnResume is set. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tonidero
left a comment
There was a problem hiding this comment.
Ok, I think this looks good. I would still recommend trying to listen to the existing customer info listener to get updates in the background and update CustomerCenter then... but other than that, I think it makes sense!
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, enable autofix in the Cursor dashboard.
**This is an automatic release.** ## RevenueCatUI SDK ### Customer Center #### 🐞 Bugfixes * CC-628: Refresh Customer Center after returning from manage subscriptions (#3152) via Facundo Menzella (@facumenzella) ### 🔄 Other Changes * Remove experimental annotation from trackCustomPaywallImpression (#3241) via Rick (@rickvdl) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk: this is a release/version bump and documentation deployment update, with no functional code changes beyond updating the reported SDK version string. > > **Overview** > Cuts the `9.26.1` release by updating version constants across the project (Gradle `VERSION_NAME`, `.version`, `Config.frameworkVersion`) and aligning sample/test apps to depend on `9.26.1` instead of the `9.27.0-SNAPSHOT`. > > Updates release artifacts/docs metadata: CircleCI `docs-deploy` now syncs the `docs/9.26.1` directory to S3, `docs/index.html` redirects to `9.26.1`, and changelogs are rolled forward with the `9.26.1` entries. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5a7609c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->


Summary
initawaitSyncPurchases()Testing
Follow-up of #3061
Closes CC-628
Note
Medium Risk
Touches Customer Center lifecycle/refresh behavior; mis-ordering lifecycle events or refresh gating could cause missed or duplicate reloads when foregrounding or returning from external subscription management.
Overview
Improves Customer Center refresh behavior to better reflect subscription changes after users leave the app to manage/cancel subscriptions.
Adds an
ON_RESUMElifecycle hook and a one-shotshouldRefreshOnResumeflag set when launching external subscription management, so returning triggers a targeted refresh while avoiding duplicate refreshes onON_START. Refresh now preserves key UI/navigation state when updating data, and initial loading via the state flow is guarded to prevent repeatedloadCustomerCenter()calls on re-subscription.Updates tests to cover the new resume-driven refresh path and assert refresh uses
awaitCustomerInfo(CacheFetchPolicy.FETCH_CURRENT)(notawaitSyncPurchases()), plus a small newline fix inMockPurchasesType.Written by Cursor Bugbot for commit 0bcac84. This will update automatically on new commits. Configure here.