Skip to content

CC-628: Refresh Customer Center after returning from manage subscriptions#3152

Merged
facumenzella merged 20 commits into
mainfrom
facundo/cc-628-refresh-on-resume-rn
Mar 16, 2026
Merged

CC-628: Refresh Customer Center after returning from manage subscriptions#3152
facumenzella merged 20 commits into
mainfrom
facundo/cc-628-refresh-on-resume-rn

Conversation

@facumenzella

@facumenzella facumenzella commented Feb 27, 2026

Copy link
Copy Markdown
Member

Summary

  • fix Customer Center duplicate reload on foreground by removing collector re-subscription auto-load and running initial load once in init
  • refresh Customer Center after returning from manage subscriptions and force a stronger refresh via awaitSyncPurchases()
  • keep existing lifecycle refresh behavior and add a one-time delayed follow-up refresh guard for post-Play propagation
  • remove temporary debug instrumentation logs added during investigation
  • add regression tests to verify refresh path uses sync

Testing

  • ./gradlew :ui:revenuecatui:testDefaultsBc8DebugUnitTest --tests "*CustomerCenterViewModelTests"

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_RESUME lifecycle hook and a one-shot shouldRefreshOnResume flag set when launching external subscription management, so returning triggers a targeted refresh while avoiding duplicate refreshes on ON_START. Refresh now preserves key UI/navigation state when updating data, and initial loading via the state flow is guarded to prevent repeated loadCustomerCenter() calls on re-subscription.

Updates tests to cover the new resume-driven refresh path and assert refresh uses awaitCustomerInfo(CacheFetchPolicy.FETCH_CURRENT) (not awaitSyncPurchases()), plus a small newline fix in MockPurchasesType.

Written by Cursor Bugbot for commit 0bcac84. This will update automatically on new commits. Configure here.

@codecov

codecov Bot commented Feb 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.40%. Comparing base (81133f3) to head (0bcac84).
⚠️ Report is 6 commits behind head on main.

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

@facumenzella facumenzella marked this pull request as ready for review February 27, 2026 13:59
@facumenzella facumenzella requested review from a team as code owners February 27, 2026 13:59
@facumenzella facumenzella force-pushed the facundo/cc-628-refresh-on-resume-rn branch from 0213708 to 8aa1dff Compare February 27, 2026 14:02

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

Some questions :)

facumenzella and others added 4 commits March 9, 2026 11:05
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>
facumenzella and others added 3 commits March 9, 2026 12:01
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>
facumenzella and others added 2 commits March 9, 2026 12:52
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>
@facumenzella facumenzella requested a review from tonidero March 9, 2026 12:10
github-merge-queue Bot pushed a commit that referenced this pull request Mar 13, 2026
…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>
facumenzella and others added 3 commits March 13, 2026 15:53
…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>

@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 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Guard in onStart excludes Error state, breaking recovery
    • I updated the onStart guard to include CustomerCenterState.Error, restoring automatic reload when collection restarts after an error.
  • ✅ Fixed: Double identical refresh on manage subscriptions return
    • I made onActivityStarted skip its refresh when shouldRefreshOnResume is true so returning from manage subscriptions performs only the resume-triggered refresh.

Create PR

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.

@facumenzella

Copy link
Copy Markdown
Member Author

@vegaro @tonidero thanks for the feedback!! this works like a charm now :)

@facumenzella facumenzella requested a review from vegaro March 13, 2026 15:16
facumenzella and others added 2 commits March 13, 2026 16:21
- 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>
@facumenzella

facumenzella commented Mar 16, 2026

Copy link
Copy Markdown
Member Author

gentle re-ping for a look @vegaro @tonidero

cc @GuilhermeMota93

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

Mostly some questions, since I'm missing context

facumenzella and others added 2 commits March 16, 2026 16:50
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 tonidero 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.

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!

@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, enable autofix in the Cursor dashboard.

@facumenzella facumenzella added this pull request to the merge queue Mar 16, 2026
Merged via the queue into main with commit b92a9b3 Mar 16, 2026
37 checks passed
@facumenzella facumenzella deleted the facundo/cc-628-refresh-on-resume-rn branch March 16, 2026 21:04
tonidero pushed a commit that referenced this pull request Mar 17, 2026
**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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants