Skip to content

Feat: Restore gating in paywalls UI#3171

Merged
JZDesign merged 7 commits into
mainfrom
PF-343/resumable-restore-purchases
Mar 18, 2026
Merged

Feat: Restore gating in paywalls UI#3171
JZDesign merged 7 commits into
mainfrom
PF-343/resumable-restore-purchases

Conversation

@JZDesign

@JZDesign JZDesign commented Mar 3, 2026

Copy link
Copy Markdown
Contributor

Checklist

  • If applicable, unit tests
  • If applicable, create follow-up issues for purchases-ios and hybrids

Motivation

We have customers who like the onPurchaseInitiated flow where they can block and resume or cancel the flow so that they can authenticate or perform a parental gate. We've received a request to do the same for restoring purchases.

Description

Introduce a Resumable functional interface and onRestoreInitiated callback to let clients pause/prepare before restore proceeds. Added Resumable.kt and updated CustomerCenterListener and PaywallListener APIs with default implementations that immediately resume. Propagated support across the UI and internals: PaywallActivity, PaywallView, OriginalTemplatePaywallFooterView, CustomerCenterView, and api-tester stubs were updated to accept and forward Resumable. ViewModels (CustomerCenterViewModel, PaywallViewModel) now suspend and await the listener decision (using suspendCoroutine) and abort the restore when the listener resumes with false. Tests were updated to mock Resumable behaviour and add coverage for the cancellation path.


Note

Medium Risk
Adds a new restore-gating callback (onRestoreInitiated) that can pause or cancel restore flows, changing restore behavior and listener contracts across UI and Customer Center. Moderate risk due to coroutine gating and potential hangs if clients never resume, but defaults preserve existing behavior.

Overview
Adds a resumable “restore initiated” gate to both Customer Center and paywall restore flows via a new Resumable functional interface and onRestoreInitiated(resume) callback (defaulting to immediate resume).

Updates the UI restore implementations (PaywallViewModel.handleRestorePurchases, CustomerCenterViewModel.restorePurchases) to suspend until listeners resume and to abort restore when resumed with false, and wires the callback through wrapper listeners in PaywallActivity/View wrappers.

Refreshes public API surface files (api.txt/api-defaults*.txt), api-tester stubs, and unit tests to cover the new callback and the “cancel restore” path.

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

Introduce a Resumable functional interface and onRestoreInitiated callback to let clients pause/prepare before restore proceeds. Added Resumable.kt and updated CustomerCenterListener and PaywallListener APIs with default implementations that immediately resume. Propagated support across the UI and internals: PaywallActivity, PaywallView, OriginalTemplatePaywallFooterView, CustomerCenterView, and api-tester stubs were updated to accept and forward Resumable. ViewModels (CustomerCenterViewModel, PaywallViewModel) now suspend and await the listener decision (using suspendCoroutine) and abort the restore when the listener resumes with false. Tests were updated to mock Resumable behaviour and add coverage for the cancellation path.
@JZDesign JZDesign requested a review from a team March 3, 2026 20:28
@JZDesign JZDesign marked this pull request as ready for review March 16, 2026 20:10
@JZDesign JZDesign requested review from a team as code owners March 16, 2026 20:10
@JZDesign JZDesign added pr:feat A new feature pr:RevenueCatUI feat:PaywallV2 and removed pr:feat A new feature labels Mar 16, 2026
@JZDesign JZDesign changed the title Add Resumable callback to control restore flows Feat: Restore gating in paywalls UI Mar 16, 2026

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

Fix All in Cursor

Bugbot Autofix prepared a fix for 1 of the 2 issues found in the latest run.

  • ✅ Fixed: Cancelled restore leaves non-dismissable dialog stuck on screen
    • When restore initiation is cancelled, restorePurchases() now clears restorePurchasesState before returning, and the restore-cancellation test now verifies the dialog state is reset.

Create PR

Or push these changes by commenting:

@cursor push 5271dcc692
Preview (5271dcc692)
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
@@ -510,6 +510,13 @@
         if (!shouldResumeRestorePurchases(listener, "listener") ||
             !shouldResumeRestorePurchases(purchases.customerCenterListener, "purchases.customerCenterListener")
         ) {
+            _state.update { currentState ->
+                if (currentState is CustomerCenterState.Success) {
+                    currentState.copy(restorePurchasesState = null)
+                } else {
+                    currentState
+                }
+            }
             return
         }
         notifyListenersForRestoreStarted()

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
@@ -963,13 +963,30 @@
             listener = directListener
         )
 
+        model.state.filterIsInstance<CustomerCenterState.Success>().first()
+
+        val missingPurchasePath = HelpPath(
+            id = "missing_purchase_id",
+            title = "Restore Purchases",
+            type = HelpPath.PathType.MISSING_PURCHASE
+        )
+        model.pathButtonPressed(mockk(relaxed = true), missingPurchasePath, null)
+
+        model.state.first {
+            it is CustomerCenterState.Success &&
+                it.restorePurchasesState == RestorePurchasesState.RESTORING
+        }
+
         model.restorePurchases()
 
+        val updatedState = model.state.value as CustomerCenterState.Success
+
         verify(exactly = 1) { directListener.onRestoreInitiated(any()) }
         verify(exactly = 0) { purchasesListener.onRestoreInitiated(any()) }
         coVerify(exactly = 0) { purchases.awaitRestore() }
         verify(exactly = 0) { directListener.onRestoreStarted() }
         verify(exactly = 0) { purchasesListener.onRestoreStarted() }
+        assertThat(updatedState.restorePurchasesState).isNull()
     }
 
     @Test

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

Replace suspendCoroutine with suspendCancellableCoroutine in CustomerCenterViewModel and PaywallViewModel so the coroutine waiting on onRestoreInitiated can be cancelled. Move the restore-detail log message out of the listener callback to run after the continuation resumes. Add suspendCancellableCoroutine imports and remove the now-unused suspendCoroutine import.
@emerge-tools

emerge-tools Bot commented Mar 18, 2026

Copy link
Copy Markdown

📸 Snapshot Test

582 unchanged

Name Added Removed Modified Renamed Unchanged Errored Approval
TestPurchasesUIAndroidCompatibility
com.revenuecat.testpurchasesuiandroidcompatibility
0 0 0 0 325 0 N/A
TestPurchasesUIAndroidCompatibility Paparazzi
com.revenuecat.testpurchasesuiandroidcompatibility.paparazzi
0 0 0 0 257 0 N/A

🛸 Powered by Emerge Tools

@codecov

codecov Bot commented Mar 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.37%. Comparing base (bfc519f) to head (8c441a3).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
...purchases/customercenter/CustomerCenterListener.kt 0.00% 2 Missing ⚠️
...m/revenuecat/purchases/customercenter/Resumable.kt 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3171      +/-   ##
==========================================
- Coverage   79.39%   79.37%   -0.02%     
==========================================
  Files         356      357       +1     
  Lines       14346    14349       +3     
  Branches     1959     1959              
==========================================
  Hits        11390    11390              
- Misses       2152     2155       +3     
  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.

JZDesign commented Mar 18, 2026

Copy link
Copy Markdown
Contributor Author

Merge activity

  • Mar 18, 8:23 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Mar 18, 8:24 PM UTC: Graphite couldn't merge this PR because it failed for an unknown reason (This repository has GitHub's merge queue enabled. Please configure the GitHub merge queue integration in Graphite settings.).

@JZDesign JZDesign added this pull request to the merge queue Mar 18, 2026
Merged via the queue into main with commit e60c267 Mar 18, 2026
34 checks passed
@JZDesign JZDesign deleted the PF-343/resumable-restore-purchases branch March 18, 2026 20:45
github-merge-queue Bot pushed a commit that referenced this pull request Mar 26, 2026
**This is an automatic release.**

## RevenueCat SDK
### 🐞 Bugfixes
* [EXTERNAL] fix: ensure activity is attached before showing in-app
messages (#3274) contributed by @matteinn (#3275) via Toni Rico
(@tonidero)
* Ensure MediaPlayer has dedicated thread owner that is not the main
thread (#3148) via Jacob Rakidzich (@JZDesign)
* Fix heartbeat monitor and Slack notifications for nightly integration
tests (#3259) via Rick (@rickvdl)

## RevenueCatUI SDK
### Paywallv2
#### ✨ New Features
* Feat: Restore gating in paywalls UI (#3171) via Jacob Rakidzich
(@JZDesign)

### 🔄 Other Changes
* security: pin GitHub Actions to SHA hashes (#3272) via Alfonso
Embid-Desmet (@alfondotnet)
* Bump activesupport from 8.0.2 to 8.0.4.1 (#3270) via dependabot[bot]
(@dependabot[bot])
* Merge release PR after deploy (#3269) via Antonio Pallares
(@ajpallares)
* Require PR approval before release tagging (#3268) via Antonio
Pallares (@ajpallares)
* Bump json from 2.18.1 to 2.19.2 (#3261) via dependabot[bot]
(@dependabot[bot])
* feat(ads): update admob sample app (#3264) via Peter Porfy
(@peterporfy)
* feat(ads): add vanilla-ad-tracker-sample (#3263) via Peter Porfy
(@peterporfy)
* [Purchase Tester]: Persist appUserId on login screen across app
launches (#3266) via Will Taylor (@fire-at-will)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Mostly release automation and versioning changes, but it modifies
CI/orb references and deploy/merge automation, which could affect the
release pipeline if misconfigured.
> 
> **Overview**
> Cuts the `9.28.0` release by removing `-SNAPSHOT` across version
sources (`.version`, `gradle.properties`, `Config.frameworkVersion`) and
updating sample/test-app dependency pins to `9.28.0`.
> 
> Updates release documentation: publishes Dokka docs to the `9.28.0` S3
path, updates `docs/index.html` redirect to `9.28.0`, and rolls
`CHANGELOG.latest.md` into a new `9.28.0` section in `CHANGELOG.md`.
> 
> Tweaks release tooling/CI: pins `fastlane-plugin-revenuecat_internal`
to a specific git ref (and bumps a few Ruby deps), switches the CircleCI
`revenuecat/sdks-common-config` orb to a dev commit, and adds a
temporary `test_merge_queue` workflow to exercise
`revenuecat/merge-release-pr` with `use_merge_queue: true`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
5050888. 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.

2 participants