Unified StoreReplacementMode API#3234
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #3234 +/- ##
==========================================
+ Coverage 79.39% 79.45% +0.05%
==========================================
Files 361 362 +1
Lines 14473 14539 +66
Branches 1968 1976 +8
==========================================
+ Hits 11491 11552 +61
- Misses 2188 2190 +2
- Partials 794 797 +3 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
📸 Snapshot Test588 unchanged
🛸 Powered by Emerge Tools |
| * | ||
| * This is the default behavior for the Galaxy and Play stores. | ||
| */ | ||
| @JvmField public val WITHOUT_PRORATION: StoreReplacementMode = WithoutProration |
There was a problem hiding this comment.
We're using these so that the API in Java can be StoreReplacementMode.WITHOUT_PRORATION instead of StoreReplacementMode.WITHOUT_PRORATION.Instance
There was a problem hiding this comment.
I'm wondering, could we just remove the ones above (outside the companion object) and just create the corresponding StoreReplacementModes here directly? Not sure if we need that level of indirection...
| // (e.g., "productId:basePlanId" becomes "productId") to ensure the callback key matches the productId | ||
| // in the transaction returned by Google Play, which only contains the product ID without the base plan. | ||
| val productId = if (googleReplacementMode == GoogleReplacementMode.DEFERRED) { | ||
| val productId = if (replacementMode == StoreReplacementMode.DEFERRED && store == Store.PLAY_STORE) { |
There was a problem hiding this comment.
DEFERRED callback mapping missing for Galaxy Store
Medium Severity
The DEFERRED replacement mode callback-to-old-product-ID mapping is now gated on store == Store.PLAY_STORE, but there's no evidence the Galaxy Store's DEFERRED mode doesn't exhibit similar behavior. Previously the check was only googleReplacementMode == GoogleReplacementMode.DEFERRED which wouldn't apply to Galaxy, but now that both stores share StoreReplacementMode.DEFERRED, explicitly excluding Galaxy here could cause the purchase callback to never fire if the Galaxy Store also returns a transaction referencing the old product ID in DEFERRED mode.
Reviewed by Cursor Bugbot for commit f318143. Configure here.
There was a problem hiding this comment.
I'll check and see if the galaxy store also exhibits this behavior or not. Not sure why it would, but you never know
tonidero
left a comment
There was a problem hiding this comment.
I think this looks good! Just a small suggestion, but nothing blocking
| public val oldProductId: String? | ||
|
|
||
| @Deprecated("Use replacementMode instead") | ||
| public val googleReplacementMode: GoogleReplacementMode |
There was a problem hiding this comment.
Should we just make this a computed property based on replacementMode to avoid the breaking change, but avoid having the same state stored twice?
There was a problem hiding this comment.
Good idea! Implemented this in 7d34311. I was able to make PurchaseParams.googleReplacementMode computed, and remove the internal PurchaseParams.Builder.googleReplacementMode property entirely.
|
|
||
| override fun toString(): String = name | ||
|
|
||
| @Parcelize |
There was a problem hiding this comment.
Could we remove these from here and make StoreReplacementMode parcelize instead? We might need to make the constructor public, but we can make the constructor @InternalRevenueCatAPI I think?
There was a problem hiding this comment.
Updated to do that in 1c00c22 - doesn't look like we needed to make the constructor public
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
❌ 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 1930d36. Configure here.
|
|
||
| override fun serialize(encoder: Encoder, value: StoreReplacementMode) { | ||
| throw NotImplementedError("Serialization is not implemented because it is not needed.") | ||
| } |
There was a problem hiding this comment.
Serializer throws NotImplementedError on serialization attempt
Low Severity
StoreReplacementModeDeserializer.serialize() throws NotImplementedError at runtime. The previous EnumDeserializerWithDefault for GoogleReplacementMode supported serialization. Since ProductChangeConfig is @Serializable and its fields use this serializer, any code path that attempts to serialize a ProductChangeConfig (e.g., for caching, logging, or test assertions using kotlinx.serialization) would crash at runtime with no compile-time warning.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 1930d36. Configure here.
There was a problem hiding this comment.
EnumDeserializerWithDefault.serialize also threw NotImplementedError, so this isn't new.
**This is an automatic release.** ## RevenueCat SDK ### ✨ New Features * Unified StoreReplacementMode API (RevenueCat#3234) via Will Taylor (@fire-at-will) * Add placement and targeting context to paywall events (RevenueCat#3253) via Dan Pannasch (@dpannasch) ### 🐞 Bugfixes * Fix null Placements when offering_ids_by_placement is absent (RevenueCat#3254) via Dan Pannasch (@dpannasch) ## RevenueCatUI SDK ### Paywallv2 #### ✨ New Features * Wire multipage workflow navigation into PaywallViewModel (RevenueCat#3381) via Cesar de la Vega (@vegaro) ### 🔄 Other Changes * Add `triggerType` to `WorkflowTrigger` (RevenueCat#3393) via Cesar de la Vega (@vegaro) * Extract private function `NavigateTo.toPaywallAction` (RevenueCat#3392) via Cesar de la Vega (@vegaro) * Bump revenucatui-tests gradle cache key (RevenueCat#3391) via Toni Rico (@tonidero) * Create `WorkflowTriggerType` and `WorkflowTriggerActionType` (RevenueCat#3386) via Cesar de la Vega (@vegaro) * Update baseline profiles (RevenueCat#3390) via RevenueCat Git Bot (@RCGitBot) * Plumb `componentId` through buttons on workflow interactions (RevenueCat#3380) via Cesar de la Vega (@vegaro) * Add `ButtonComponent.Action.Workflow` (RevenueCat#3385) via Cesar de la Vega (@vegaro) * Add `componentId` to `ButtonCoomponentStyle` (RevenueCat#3384) via Cesar de la Vega (@vegaro) * Migrate all suspendCoroutine usages to suspendCancellableCoroutine (RevenueCat#3365) via Jaewoong Eum (@skydoves) * Add `WorkflowNavigator` for multipage workflow step navigation (RevenueCat#3379) via Cesar de la Vega (@vegaro) * build(deps): bump fastlane-plugin-revenuecat_internal from `b822f01` to `d24ab26` (RevenueCat#3383) via dependabot[bot] (@dependabot[bot]) * Add `id` field to `ButtonComponent` (RevenueCat#3377) via Cesar de la Vega (@vegaro) * Add CI workflows for generating Baseline Profiles (RevenueCat#3372) via Jaewoong Eum (@skydoves) * add min sdk level for paywalls and customer center (RevenueCat#2465) via Muhammad-Sharif Moustafa (@mshmoustafa) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk release bookkeeping: updates version strings, changelogs, and documentation deployment targets without changing runtime logic beyond the reported version identifier. > > **Overview** > Cuts the `10.3.0` release by updating all version references from `10.3.0-SNAPSHOT` to `10.3.0` across build config (`gradle.properties`, `.version`, `Config.frameworkVersion`) and sample/test app dependency pins. > > Updates release documentation publishing to sync Dokka output to the `10.3.0` S3 path and changes `docs/index.html` to redirect to `10.3.0`. Also promotes release notes by moving `CHANGELOG.latest.md` entries into a new `CHANGELOG.md` section for `10.3.0`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 056ce62. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->


Motivation
With the introduction of the Galaxy Store, we needed to find a way to gracefully model our replacement mode APIs for product changes, now that we have two stores in the Android SDK that support product changes with replacement modes.
Our current replacement mode APIs are store-specific. This allows you to tailor the replacement modes to each store, but puts the onus on the developer to maintain different logic for each store to call the appropriate
PurchaseParams.Builderfunction to set the replacement mode for that store.Description
This PR introduces a new unified
StoreReplacementModeabstract class that can be used across both the Play Store and the Galaxy Store. It replaces the existingGoogleReplacementModeandGalaxyReplacementModeenums. It has the same fields as the existingGoogleReplacementMode:The following table documents how each
StoreReplacementModemaps to each store:Additionally, this PR deprecates the existing
GoogleReplacementModeenum and associated public APIs and hard-removes the experimentalGalaxyReplacementModeenum and its APIs.Under the hood, it also updates the product change flows for both the Galaxy and Play Stores to use the
StoreReplacementModeclass instead of the store-specific enums.API Changes
Additions
StoreReplacementModeabstract classPurchaseParams.replacementMode: StoreReplacementMode(defaults to WITHOUT_PRORATION)PurchaseParams.Builder.replacementMode(replacementMode: StoreReplacementMode)Deprecations
GoogleReplacementMode- useStoreReplacementModeinsteadPurchaseParams.googleReplacementMode- usePurchaseParams.replacementModeinsteadPurchaseParams.Builder.googleReplacementMode- usePurchaseParams.Builder.replacementModeinsteadPurchaseParams.Builder.googleReplacementMode(googleReplacementMode: GoogleReplacementMode)- usePurchaseParams.Builder.replacementMode(replacementMode: StoreReplacementMode)insteadHard Removals
These are all currently marked as
@ExperimentalPreviewRevenueCatPurchasesAPI:GalaxyReplacementMode- useStoreReplacementModeinsteadPurchaseParams.galaxyReplacementMode- usePurchaseParams.replacementModeinsteadPurchaseParams.Builder.galaxyReplacementMode- usePurchaseParams.Builder.replacementModeinsteadPurchaseParams.Builder.galaxyReplacementMode(galaxyReplacementMode: GalaxyReplacementMode)- usePurchaseParams.Builder.replacementMode(replacementMode: StoreReplacementMode)insteadPaywalls Changes
This PR includes minor changes to the paywalls product to account for this change, primarily in how product changes and custom purchase logic are handled.
Testing
StoreReplacementAPIworkStoreReplacementAPI.CHARGE_FULL_PRICEreturns an unsupported errorGoogleReplacementAPIstill workStoreReplacementAPIworkNote
Medium Risk
Medium risk because it changes public purchase/product-change APIs and the proration/replacement-mode handling across Play Store and Galaxy flows, which can affect upgrade/downgrade behavior and backend payloads.
Overview
Introduces a unified
StoreReplacementModeand wires it throughPurchaseParams/PurchaseParams.Builder(replacementMode), while deprecatingGoogleReplacementModeAPIs and removingGalaxyReplacementMode.Updates Play and Galaxy product-change flows to consume
StoreReplacementModeend-to-end (including backendproration_modemapping and Google Billing upgrade params), adds store-specific conversion/mapping helpers, and tightens Galaxy behavior by rejecting unsupported modes (e.g.CHARGE_FULL_PRICE) with explicit errors and fallbacks when an unknown mode is provided.Refreshes examples and API tester code to use the new API, and updates/expands unit tests and ABI snapshots to validate mappings, parcelization, and deferred product-change behavior on both stores.
Reviewed by Cursor Bugbot for commit 1930d36. Bugbot is set up for automated code reviews on this repo. Configure here.