Skip to content

Unified StoreReplacementMode API#3234

Merged
fire-at-will merged 57 commits into
mainfrom
shared-replacement-mode
Apr 28, 2026
Merged

Unified StoreReplacementMode API#3234
fire-at-will merged 57 commits into
mainfrom
shared-replacement-mode

Conversation

@fire-at-will

@fire-at-will fire-at-will commented Mar 13, 2026

Copy link
Copy Markdown
Contributor

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.Builder function to set the replacement mode for that store.

Description

This PR introduces a new unified StoreReplacementMode abstract class that can be used across both the Play Store and the Galaxy Store. It replaces the existing GoogleReplacementMode and GalaxyReplacementMode enums. It has the same fields as the existing GoogleReplacementMode:

public abstract class StoreReplacementMode internal constructor(
    override val name: String,
) : ReplacementMode {
public companion object {
        @JvmField public val WITHOUT_PRORATION: StoreReplacementMode = WithoutProration
        @JvmField public val WITH_TIME_PRORATION: StoreReplacementMode = WithTimeProration
        @JvmField public val CHARGE_FULL_PRICE: StoreReplacementMode = ChargeFullPrice
        @JvmField public val CHARGE_PRORATED_PRICE: StoreReplacementMode = ChargeProratedPrice
        @JvmField public val DEFERRED: StoreReplacementMode = Deferred
}

The following table documents how each StoreReplacementMode maps to each store:

StoreReplacementMode Play Store Replacement Mode Galaxy Replacement Mode
WITHOUT_PRORATION WITHOUT_PRORATION INSTANT_NO_PRORATION
WITH_TIME_PRORATION WITH_TIME_PRORATION INSTANT_PRORATED_DATE
CHARGE_FULL_PRICE CHARGE_FULL_PRICE N/A - error returned if provided to purchase()
CHARGE_PRORATED_PRICE CHARGE_PRORATED_PRICE INSTANT_PRORATED_CHARGE
DEFERRED DEFERRED DEFERRED

Additionally, this PR deprecates the existing GoogleReplacementMode enum and associated public APIs and hard-removes the experimental GalaxyReplacementMode enum and its APIs.

Under the hood, it also updates the product change flows for both the Galaxy and Play Stores to use the StoreReplacementMode class instead of the store-specific enums.

API Changes

Additions

  • StoreReplacementMode abstract class
  • PurchaseParams.replacementMode: StoreReplacementMode (defaults to WITHOUT_PRORATION)
  • PurchaseParams.Builder.replacementMode(replacementMode: StoreReplacementMode)

Deprecations

  • GoogleReplacementMode - use StoreReplacementMode instead
  • PurchaseParams.googleReplacementMode - use PurchaseParams.replacementMode instead
  • PurchaseParams.Builder.googleReplacementMode - use PurchaseParams.Builder.replacementMode instead
  • PurchaseParams.Builder.googleReplacementMode(googleReplacementMode: GoogleReplacementMode) - use PurchaseParams.Builder.replacementMode(replacementMode: StoreReplacementMode) instead

Hard Removals

These are all currently marked as @ExperimentalPreviewRevenueCatPurchasesAPI:

  • GalaxyReplacementMode - use StoreReplacementMode instead
  • PurchaseParams.galaxyReplacementMode - use PurchaseParams.replacementMode instead
  • PurchaseParams.Builder.galaxyReplacementMode - use PurchaseParams.Builder.replacementMode instead
  • PurchaseParams.Builder.galaxyReplacementMode(galaxyReplacementMode: GalaxyReplacementMode) - use PurchaseParams.Builder.replacementMode(replacementMode: StoreReplacementMode) instead

Paywalls 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

  • Lots of unit tests
  • Manually tested:
    • Product changes on the Galaxy Store using the new StoreReplacementAPI work
    • Confirmed that making a product change on the Galaxy Store with StoreReplacementAPI.CHARGE_FULL_PRICE returns an unsupported error
    • Product changes on the Play Store using the deprecated GoogleReplacementAPI still work
    • Product changes on the Play Store using the new StoreReplacementAPI work

Note

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 StoreReplacementMode and wires it through PurchaseParams/PurchaseParams.Builder (replacementMode), while deprecating GoogleReplacementMode APIs and removing GalaxyReplacementMode.

Updates Play and Galaxy product-change flows to consume StoreReplacementMode end-to-end (including backend proration_mode mapping 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.

@fire-at-will fire-at-will added the pr:feat A new feature label Mar 13, 2026
@codecov

codecov Bot commented Mar 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.86992% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.45%. Comparing base (9baf20a) to head (1930d36).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...urchases/models/StoreReplacementModeConversions.kt 94.11% 2 Missing and 1 partial ⚠️
...kotlin/com/revenuecat/purchases/ReplacementMode.kt 83.33% 0 Missing and 2 partials ⚠️
...at/purchases/google/BillingFlowParamsExtensions.kt 60.00% 1 Missing and 1 partial ⚠️
...common/serializers/ReplacementModeDeserializers.kt 88.88% 1 Missing and 1 partial ⚠️
.../com/revenuecat/purchases/PurchasesOrchestrator.kt 66.66% 1 Missing ⚠️
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.
📢 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.

@emerge-tools

emerge-tools Bot commented Mar 16, 2026

Copy link
Copy Markdown

📸 Snapshot Test

588 unchanged

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

🛸 Powered by Emerge Tools

*
* This is the default behavior for the Galaxy and Play stores.
*/
@JvmField public val WITHOUT_PRORATION: StoreReplacementMode = WithoutProration

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.

We're using these so that the API in Java can be StoreReplacementMode.WITHOUT_PRORATION instead of StoreReplacementMode.WITHOUT_PRORATION.Instance

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.

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) {

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.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f318143. Configure here.

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'll check and see if the galaxy store also exhibits this behavior or not. Not sure why it would, but you never know

@fire-at-will fire-at-will requested a review from tonidero April 24, 2026 18:07

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

I think this looks good! Just a small suggestion, but nothing blocking

public val oldProductId: String?

@Deprecated("Use replacementMode instead")
public val googleReplacementMode: GoogleReplacementMode

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.

Should we just make this a computed property based on replacementMode to avoid the breaking change, but avoid having the same state stored twice?

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.

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

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.

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?

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.

Updated to do that in 1c00c22 - doesn't look like we needed to make the constructor public

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

There are 3 total unresolved issues (including 2 from previous reviews).

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 1930d36. Configure here.


override fun serialize(encoder: Encoder, value: StoreReplacementMode) {
throw NotImplementedError("Serialization is not implemented because it is not needed.")
}

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.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1930d36. Configure here.

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.

EnumDeserializerWithDefault.serialize also threw NotImplementedError, so this isn't new.

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

🚢

@fire-at-will fire-at-will added this pull request to the merge queue Apr 28, 2026
Merged via the queue into main with commit 635dc93 Apr 28, 2026
41 checks passed
@fire-at-will fire-at-will deleted the shared-replacement-mode branch April 28, 2026 17:21
matteinn pushed a commit to matteinn/purchases-android that referenced this pull request May 5, 2026
**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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants