Cache isAutoRenewing to detect subscription changes without syncPurchases#3198
Conversation
tonidero
left a comment
There was a problem hiding this comment.
Mostly a question on the implementation, but yeah, this is what I was thinking! 🫶
tonidero
left a comment
There was a problem hiding this comment.
Would love your thoughts on this @RevenueCat/catforms
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Auto-renewing status saved before post may lose failed retries
- Auto-renewing status is now persisted only for transactions not being posted immediately and per-transaction after successful post, so failed posts keep old cached status and remain retryable.
Or push these changes by commenting:
@cursor push d8ca9457a4
Preview (d8ca9457a4)
diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PostPendingTransactionsHelper.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PostPendingTransactionsHelper.kt
--- a/purchases/src/main/kotlin/com/revenuecat/purchases/PostPendingTransactionsHelper.kt
+++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PostPendingTransactionsHelper.kt
@@ -6,6 +6,7 @@
import com.revenuecat.purchases.common.LogIntent
import com.revenuecat.purchases.common.caching.DeviceCache
import com.revenuecat.purchases.common.log
+import com.revenuecat.purchases.common.sha1
import com.revenuecat.purchases.identity.IdentityManager
import com.revenuecat.purchases.models.PurchaseState
import com.revenuecat.purchases.models.StoreTransaction
@@ -57,10 +58,16 @@
val autoRenewingChanged = deviceCache.getPurchasesWithAutoRenewingChange(
purchasesByHashedToken,
)
- deviceCache.saveAutoRenewingStatus(purchasesByHashedToken)
val transactionsToSync = (newPurchases + autoRenewingChanged).distinctBy {
it.purchaseToken
}
+ val transactionsToSyncTokens = transactionsToSync.map { it.purchaseToken }.toSet()
+ val purchasesNotBeingSynced = purchasesByHashedToken.filterValues { transaction ->
+ transaction.purchaseToken !in transactionsToSyncTokens
+ }
+ if (purchasesNotBeingSynced.isNotEmpty()) {
+ deviceCache.saveAutoRenewingStatus(purchasesNotBeingSynced)
+ }
val pendingTransactionsTokens = purchasesByHashedToken.values
.filter { it.purchaseState == PurchaseState.PENDING }
.map { it.purchaseToken }
@@ -119,6 +126,13 @@
},
)
},
+ onTransactionSuccess = { transaction ->
+ deviceCache.saveAutoRenewingStatus(
+ mapOf(
+ transaction.purchaseToken.sha1() to transaction,
+ ),
+ )
+ },
)
},
onError = { error ->
@@ -137,6 +151,7 @@
onNoTransactionsToSync: (() -> Unit),
onError: ((PurchasesError) -> Unit),
onSuccess: ((CustomerInfo) -> Unit),
+ onTransactionSuccess: ((StoreTransaction) -> Unit) = {},
) {
if (transactionsToSync.isEmpty()) {
log(LogIntent.DEBUG) { PurchaseStrings.NO_PENDING_PURCHASES_TO_SYNC }
@@ -149,7 +164,8 @@
appUserID,
PostReceiptInitiationSource.UNSYNCED_ACTIVE_PURCHASES,
sdkOriginated = false,
- transactionPostSuccess = { _, customerInfo ->
+ transactionPostSuccess = { transaction, customerInfo ->
+ onTransactionSuccess(transaction)
results.add(Result.Success(customerInfo))
callCompletionFromResults(transactionsToSync, results, onError, onSuccess)
},
diff --git a/purchases/src/test/java/com/revenuecat/purchases/PostPendingTransactionsHelperTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PostPendingTransactionsHelperTest.kt
--- a/purchases/src/test/java/com/revenuecat/purchases/PostPendingTransactionsHelperTest.kt
+++ b/purchases/src/test/java/com/revenuecat/purchases/PostPendingTransactionsHelperTest.kt
@@ -363,12 +363,9 @@
transactionPostError = any()
)
} answers {
- transactions?.let {
- it.forEach { transaction ->
- lambda<SuccessfulPurchaseCallback>().captured.invoke(transaction, customerInfo!!)
- }
- } ?: run {
- lambda<SuccessfulPurchaseCallback>().captured.invoke(mockk(), customerInfo!!)
+ val capturedTransactions = firstArg<List<StoreTransaction>>()
+ capturedTransactions.forEach { transaction ->
+ lambda<SuccessfulPurchaseCallback>().captured.invoke(transaction, customerInfo!!)
}
}
}
@@ -398,7 +395,7 @@
deviceCache.getPurchasesWithAutoRenewingChange(purchasesByHashedToken)
} returns autoRenewingChanged
every {
- deviceCache.saveAutoRenewingStatus(purchasesByHashedToken)
+ deviceCache.saveAutoRenewingStatus(any())
} just Runs
every {
@@ -871,5 +868,43 @@
}
}
+ @Test
+ fun `when auto-renewing status change sync fails, status is not saved`() {
+ val purchase = stubGooglePurchase(
+ purchaseToken = "token",
+ productIds = listOf("product"),
+ purchaseState = Purchase.PurchaseState.PURCHASED,
+ )
+ val transaction = purchase.toStoreTransaction(ProductType.SUBS)
+ val purchasesByHash = mapOf(purchase.purchaseToken.sha1() to transaction)
+
+ mockSuccessfulQueryPurchases(
+ purchasesByHashedToken = purchasesByHash,
+ notInCache = emptyList(),
+ autoRenewingChanged = listOf(transaction),
+ )
+
+ val error = PurchasesError(PurchasesErrorCode.StoreProblemError, "Broken")
+ every {
+ postTransactionWithProductDetailsHelper.postTransactions(
+ transactions = listOf(transaction),
+ allowSharingPlayStoreAccount = allowSharingPlayStoreAccount,
+ appUserID = appUserId,
+ initiationSource = initiationSource,
+ sdkOriginated = false,
+ transactionPostSuccess = any(),
+ transactionPostError = captureLambda(),
+ )
+ } answers {
+ lambda<ErrorPurchaseCallback>().captured.invoke(transaction, error)
+ }
+
+ syncAndAssertResult(SyncPendingPurchaseResult.Error(error))
+
+ verify(exactly = 0) {
+ deviceCache.saveAutoRenewingStatus(any())
+ }
+ }
+
// endregion
}
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #3198 +/- ##
==========================================
+ Coverage 79.34% 79.41% +0.06%
==========================================
Files 356 356
Lines 14276 14342 +66
Branches 1945 1958 +13
==========================================
+ Hits 11327 11389 +62
- Misses 2144 2149 +5
+ Partials 805 804 -1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
tonidero
left a comment
There was a problem hiding this comment.
A couple of comments. The most important one I think would be to try to keep the data storage and retrieval as a single operation, to avoid possible issues.
| } | ||
|
|
||
| @Synchronized | ||
| fun addSuccessfullyPostedToken(token: String) { |
There was a problem hiding this comment.
I think ideally we would save the autorenewing status in one go, instead of first storing it empty and adding the value later...
If we separate it, it's more likely that we miss something IMO... I understand this might require some refactors but I think ideally we just keep most of the outside logic the same it was before, just that the new "token" is the entry of the map that contains information for the purchase. If anything changes, in either the key or value, it would be considered a different, new purchase.
Happy to chat about this if you think otherwise though!
There was a problem hiding this comment.
Done in 47533e2fc. addSuccessfullyPostedToken now accepts an isAutoRenewing parameter, and BillingWrapper.consumeAndSave passes purchase.isAutoRenewing so the value is stored when the token is first added. Callers without a StoreTransaction (e.g. PostReceiptHelper.postTokenWithoutProductInfo) default to null.
There was a problem hiding this comment.
Actually, I think we should always store it with a value... If we don't, in some cases, like with postTokenWithoutConsuming, it means that token will be posted twice, one the first time and another on next app foreground, when it detects a non-null value. I think this should always have a value? Happy to chat if we find any scenario where this is not true though.
We should also do this for the Amazon store as well, otherwise we could be running into the issue we're trying to avoid in #3152 but for the amazon store only.
There was a problem hiding this comment.
Done in dd668a757. isAutoRenewing is now threaded through all token-saving paths:
postTokenWithoutConsumingacceptsisAutoRenewingparam, passed fromSyncPurchasesHelper(which has theStoreTransaction)AmazonBilling.consumeAndSavepassespurchase.isAutoRenewingtoAmazonCacheBillingWrapper.consumeAndSavealready passes it (from previous commit)
The only caller that still defaults to null is PurchasesOrchestrator's Amazon receipt path, which only has a raw receipt ID string without a StoreTransaction.
📸 Snapshot Test571 unchanged
🛸 Powered by Emerge Tools |
…ncPurchases When users manage subscriptions outside the app (e.g., cancelling via Play Store), the SDK now detects changes to isAutoRenewing by comparing the current value from queryPurchases with a cached value. Changed tokens are automatically reposted, avoiding the need for a full syncPurchases (which posts everything with RESTORE initiation source and can transfer purchases between users). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use getJSONObjectOrNull instead of duplicating JSON parse + try/catch - Use putString helper instead of raw preferences.edit() - Replace triple map pass + unchecked cast with single-pass buildMap via JSONObject.apply Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces the legacy StringSet (`$apiKey.tokens`) with a single JSON map (`$apiKey.tokensV2`) that stores both "which tokens have been posted" (keys) and their isAutoRenewing status (values). Migration: on first read, if the legacy StringSet exists, entries are converted to the new format with null auto-renewing values (unknown), then the legacy key is deleted. Null values mean the auto-renewing status hasn't been observed yet — they get populated on the next syncPendingPurchaseQueue. This eliminates the two-sources-of-truth issue where the token set and auto-renewing map could drift apart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fixes a bug where saveAutoRenewingStatus was called eagerly before posting, so if the post failed, the cache already reflected the new value and the change would never be retried. Now: unchanged tokens' auto-renewing status is saved eagerly (safe since they're not being reposted). Changed tokens' status is saved per-transaction only on successful post via updateAutoRenewingStatus. If the post fails, the old cached value is preserved and the change will be detected again on the next sync cycle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address review feedback:
- Token cache values are now JSON objects ({ "isAutoRenewing": true })
instead of flat booleans, allowing future extensibility without migration.
- addSuccessfullyPostedToken now accepts isAutoRenewing parameter so the
value is stored when the token is first added (via BillingWrapper.consumeAndSave).
- Backwards compatibility: getTokenMap reads both old flat boolean format
and new nested format.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address Toni's feedback to always store isAutoRenewing when saving a token. Thread the value through: - postTokenWithoutConsuming: new isAutoRenewing parameter, passed from SyncPurchasesHelper (which has the StoreTransaction) - AmazonBilling.consumeAndSave: pass purchase.isAutoRenewing to AmazonCache.addSuccessfullyPostedToken - AmazonCache: forward isAutoRenewing to DeviceCache Callers without a StoreTransaction (e.g. PurchasesOrchestrator Amazon receipt path) still default to null. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dd668a7 to
1e52b3c
Compare
Prevents null transaction isAutoRenewing from being incorrectly flagged as a change when compared against a non-null cached value. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tonidero
left a comment
There was a problem hiding this comment.
Getting there, just some more comments :)
| val value = jsonObject.get(key) | ||
| if (value is JSONObject) { | ||
| TokenCacheEntry( | ||
| isAutoRenewing = if (value.isNull(KEY_IS_AUTO_RENEWING)) { |
There was a problem hiding this comment.
Instead of writing this parsing from/to TokenCacheEntry manually, could be worth making the TokenCacheEntry @Serializable, so we get that kinda for free? I guess we would then need to store the value as a string instead of a map... But that might be ok?
There was a problem hiding this comment.
Done in d6a72d9. TokenCacheEntry is now @serializable. getTokenMap/saveTokenMap use Json.decodeFromString/encodeToString with a MapSerializer. Legacy formats (org.json objects, flat booleans) are still readable via a fallback parser.
| } | ||
|
|
||
| @Synchronized | ||
| fun addSuccessfullyPostedToken(token: String, isAutoRenewing: Boolean? = null) { |
There was a problem hiding this comment.
I'm just wondering if we should make isAutoRenewing non-optional to try to avoid us missing setting the value... I think almost all the time, we should actually have a value, except maybe when posting an amazon purchase when PurchasesAreCompletedBy.MY_APP? In which case, we can pass null then? Lmk if I miss something though!
There was a problem hiding this comment.
isAutoRenewing is now always explicitly passed in all paths (Google consume/acknowledge, Amazon consume, SyncPurchasesHelper, PostReceiptHelper). The only path that passes null is PurchasesOrchestrator's Amazon receipt posting which only has a raw receipt ID without a StoreTransaction. Keeping the parameter as Boolean? with a default of null to handle that edge case, but all other callers are now explicit.
| * after a successful post, so we don't eagerly save before knowing the post succeeded. | ||
| */ | ||
| @Synchronized | ||
| fun updateAutoRenewingStatus(token: String, isAutoRenewing: Boolean?) { |
There was a problem hiding this comment.
Hmm so I'm wondering if we need to differentiate between updateAutoRenewingStatus and addSuccessfullyPostedToken... Seems they are pretty similar and could be unified?
There was a problem hiding this comment.
Done in d6a72d9. Unified into addSuccessfullyPostedToken which is now an upsert: adds new tokens, and updates isAutoRenewing for existing ones (only when the new value is non-null and different). Removed updateAutoRenewingStatus.
- Make TokenCacheEntry @serializable, use kotlinx serialization for the token map instead of manual JSONObject parsing/writing - Merge updateAutoRenewingStatus into addSuccessfullyPostedToken as an upsert: adds new tokens, updates isAutoRenewing for existing ones - Legacy JSON formats (flat boolean values, org.json objects) still readable via fallback parser for backwards compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… with null When a transaction has null isAutoRenewing, skip updating the cached entry to preserve the previously known value for change detection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…-renewing-status # Conflicts: # purchases/src/main/kotlin/com/revenuecat/purchases/common/caching/DeviceCache.kt
|
@tonidero can I get a second look? 🙏 |
- Change from internal to @InternalRevenueCatAPI public to allow cross-module access from :feature:galaxy - Pass isAutoRenewing=true in galaxy calls (Galaxy only supports subs) - Replace method reference with lambda to match new signature - Update galaxy tests accordingly
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.
| ) { | ||
| if (!finishTransactions || purchase.type == ProductType.UNKNOWN) { | ||
| deviceCache.addSuccessfullyPostedToken(purchase.purchaseToken) | ||
| deviceCache.addSuccessfullyPostedToken(purchase.purchaseToken, isAutoRenewing = true) |
There was a problem hiding this comment.
Galaxy hardcodes isAutoRenewing instead of using actual value
Medium Severity
GalaxyBillingWrapper.consumeAndSave hardcodes isAutoRenewing = true in both token-caching paths, instead of using purchase.isAutoRenewing like the Google and Amazon wrappers do. This caches an incorrect value when purchase.type == ProductType.UNKNOWN (where isAutoRenewing is false per StoreTransactionConversions), which could cause the auto-renewing change detection to incorrectly flag these tokens for re-sync on the next query cycle.
Additional Locations (1)
There was a problem hiding this comment.
This is fine for now since the galaxy integration only supports subscriptions. We should probably add a comment here explaining that, either here or in a follow-up PR
There was a problem hiding this comment.
Actually I was rethinking this... Even if it only supports subscriptions, do we know whether the subscription is set to autorenew or not for galaxy subscriptions? This is what this property is meant for and would be great to actually use this for galaxy subscriptions, so we get updated CustomerCenter when a user cancels @fire-at-will
tonidero
left a comment
There was a problem hiding this comment.
A few optimization comments, but nothing really blocking. Thanks for doing this!
| @Synchronized | ||
| internal fun getPreviouslySentHashedTokens(): Set<String> { | ||
| private fun getTokenMap(): Map<String, TokenCacheEntry> { | ||
| val json = preferences.getString(tokensCacheKey, null) |
There was a problem hiding this comment.
I do see this serialization/deserialization happens quite a bit... I don't expect it to be too long, but I wonder if we should keep an in-memory copy of this and use that at least for fetches, so we avoid deserializing multiple times? We should be careful of it getting out of sync, but I don't think it should be too hard IMO.
There was a problem hiding this comment.
Addressed in 563fc97. Added an in-memory cache (tokenMapCache) — populated on first read from SharedPreferences, updated on writes via saveTokenMap. Subsequent reads are served from memory without re-deserializing. Added 4 tests covering cache hit on repeated reads and cache updates from addSuccessfullyPostedToken, cleanPreviouslySentTokens, and saveAutoRenewingStatus.
| current[hash] = existing.copy(isAutoRenewing = transaction.isAutoRenewing) | ||
| } | ||
| } | ||
| saveTokenMap(current) |
There was a problem hiding this comment.
This saves even if there are no changes correct? Maybe we can skip it if not needed?
There was a problem hiding this comment.
Addressed in 563fc97. saveAutoRenewingStatus now tracks whether any entry actually changed and skips the saveTokenMap call if nothing was modified.
| * Parses the legacy JSON format where values were either `{ "isAutoRenewing": true }` objects | ||
| * using org.json, or flat `Boolean?` values (pre-serializable format). | ||
| */ | ||
| private fun parseLegacyTokenMapJson(json: String): Map<String, TokenCacheEntry> { |
There was a problem hiding this comment.
Hmm is this needed? Like, none of these changes actually shipped right?
There was a problem hiding this comment.
Addressed in 563fc97. Removed parseLegacyTokenMapJson and the flat boolean format handling — those intermediate formats never shipped, so we only need the StringSet migration + the current @Serializable format.
…ng, skip no-op saves - Add in-memory cache for token map to avoid repeated JSON deserialization - Remove parseLegacyTokenMapJson (intermediate formats never shipped) - Skip saveAutoRenewingStatus write when nothing changed - Add comment explaining hardcoded isAutoRenewing=true in Galaxy wrapper Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…PendingTransactionsHelper PR #3198 added an unconditional addSuccessfullyPostedToken call in transactionPostSuccess to persist isAutoRenewing status for auto-renewing change detection. However, this also fired for new purchases, which: 1. Broke integration tests that use a relaxed mock for BillingAbstract (consumeAndSave is a no-op), prematurely marking tokens as posted during SDK init so later syncs couldn't find pending purchases. 2. Changed production retry behavior: tokens that got a 500 (handled via offline entitlements) were marked as posted and never retried. Now addSuccessfullyPostedToken only fires for tokens reposted due to an auto-renewing status change. New purchases rely on billing.consumeAndSave to cache the token, as before #3198. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| val changedTokenHashes = autoRenewingChanged.map { it.purchaseToken.sha1() } | ||
| .toSet() | ||
| val unchangedTokens = purchasesByHashedToken.minus(changedTokenHashes) | ||
| deviceCache.saveAutoRenewingStatus(unchangedTokens) |
There was a problem hiding this comment.
One thing is whether this is needed at all... As in, it's basically trying to save tokens that haven't changed. And the method doesn't (and shouldn't) add new tokens to the cache yet... So seems like code that won't do anything?
There was a problem hiding this comment.
You're right — removed in 6080deb on #3239. Since #3198 hasn't been released, no users have tokens migrated from the legacy StringSet cache with isAutoRenewing = null (the only case where this would do something). For tokens already cached with isAutoRenewing set, unchanged means same value, so it was always a no-op.
There was a problem hiding this comment.
Just for posterity, we ended up bringing this back because it's needed for users with purchases in the legacy StringSet cache, so we set a value so we can get updates to that value later. Sorry for the confusion!
Since #3198 hasn't been released, no users have tokens migrated from the legacy StringSet cache with isAutoRenewing = null. The call to saveAutoRenewingStatus(unchangedTokens) was only useful for that migration case — for tokens already cached with isAutoRenewing set, it's a no-op by definition (unchanged means same value). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The legacy StringSet cache predates #3198 — users upgrading from any older SDK version will have tokens with isAutoRenewing = null. This call populates that value on the first sync so auto-renewing change detection works for pre-existing subscriptions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ctionsHelper (#3239) ## Summary [#3198](#3198) added an unconditional `addSuccessfullyPostedToken` call in `PostPendingTransactionsHelper.transactionPostSuccess` to persist the `isAutoRenewing` status for auto-renewing change detection. However, this call was redundant — `billing.consumeAndSave` already calls `addSuccessfullyPostedToken(token, isAutoRenewing)` on the success path before `transactionPostSuccess` fires. The extra call caused two issues: ### 1. Integration test failures The tests `entersOfflineEntitlementsModeIfCachedCustomerInfoAndPostingPendingPurchasesReturns500` and `postsPurchasePerformedOnFallbackURLWhenRecoveringToMainServer` use a relaxed mock for `BillingAbstract` where `consumeAndSave` is a no-op. The unconditional `addSuccessfullyPostedToken` prematurely marked tokens as posted during SDK init, so subsequent syncs found no pending purchases and couldn't enter offline entitlements mode. ### 2. Production retry behavior change Tokens that received a 500 error (handled via offline entitlements) were marked as posted and never retried on the next sync. This means the backend could permanently miss a purchase. ### Fix Removed the `addSuccessfullyPostedToken` call from `transactionPostSuccess` entirely. Token caching (including `isAutoRenewing`) is already handled by `billing.consumeAndSave` on the success path. On the error path (500 + offline entitlements), `consumeAndSave` is intentionally not called, so the token stays uncached for retry on the next sync. All auto-renewing change detection functionality from #3198 is preserved. ## Test plan - [x] Existing `PostPendingTransactionsHelperTest` auto-renewing tests pass - [x] New test: `synced transactions do not call addSuccessfullyPostedToken directly` - [x] `DeviceCacheTest` passes - [ ] `entersOfflineEntitlementsModeIfCachedCustomerInfoAndPostingPendingPurchasesReturns500` passes - [ ] `postsPurchasePerformedOnFallbackURLWhenRecoveringToMainServer` passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches purchase sync caching behavior by removing an eager `addSuccessfullyPostedToken` call; incorrect behavior could affect retry/sync reliability, but change is small and covered by updated tests. > > **Overview** > Removes the `deviceCache.addSuccessfullyPostedToken` side effect from `PostPendingTransactionsHelper`’s `transactionPostSuccess`, relying on `billing.consumeAndSave` to persist posted tokens and `isAutoRenewing` metadata. > > Clarifies the intent of `saveAutoRenewingStatus` for legacy-migrated tokens and updates tests to assert `addSuccessfullyPostedToken` is *not* called directly for either auto-renewing-change resyncs or newly-synced purchases. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b38bab1. 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 (1M context) <noreply@anthropic.com>
**This is an automatic release.** ## RevenueCat SDK ### 🐞 Bugfixes * Fix & Standardize Galaxy Date Parsing Edge Cases (#3216) via Will Taylor (@fire-at-will) * Fix addSuccessfullyPostedToken for new purchases in PostPendingTransactionsHelper (#3239) via Facundo Menzella (@facumenzella) * [Galaxy]: Fix race condition when fetching Galaxy products (#3213) via Will Taylor (@fire-at-will) * Fixes double padding in PaywallActivity on Android 15+ when `edgeToEdge` parameter is false (#3227) via Cesar de la Vega (@vegaro) ## RevenueCatUI SDK ### 🐞 Bugfixes * Fix bold text not rendering in Markdown lists (#3228) via Cesar de la Vega (@vegaro) * Fix: Clear in-memory offerings cache on locale override to prevent stale paywall data (#3225) via Antonio Pallares (@ajpallares) ### Paywallv2 #### ✨ New Features * Feature: Update default paywall (#3133) via Jacob Rakidzich (@JZDesign) #### 🐞 Bugfixes * Fix V2 paywall safe area in landscape mode (#3221) via Cesar de la Vega (@vegaro) ### 🔄 Other Changes * Run integration tests on all branches (#3242) via Toni Rico (@tonidero) * Migrate Firebase Test Lab jobs to CircleCI emulators (#3238) via Toni Rico (@tonidero) * Run metalava on galaxy module in test-galaxy job (#3235) via Will Taylor (@fire-at-will) * Add offering_id to custom paywall impression event (#3230) via Rick (@rickvdl) * Cache isAutoRenewing to detect subscription changes without syncPurchases (#3198) via Facundo Menzella (@facumenzella) * Bump fastlane-plugin-revenuecat_internal from `e146447` to `3e8c384` (#3233) via dependabot[bot] (@dependabot[bot]) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk release housekeeping: version string bumps and documentation/deployment path updates with no functional runtime logic changes beyond the exposed version constant. > > **Overview** > Publishes the `9.26.0` release by removing `-SNAPSHOT` across build/version metadata (root `VERSION_NAME`, `.version`, `Config.frameworkVersion`, and sample/test app dependency pins). > > Updates release documentation artifacts by adding the `9.26.0` notes to `CHANGELOG.md`/`CHANGELOG.latest.md`, switching docs deployment in CircleCI to sync `docs/9.26.0` to S3, and updating `docs/index.html` to redirect to the new version. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0a30a45. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->



Summary
isAutoRenewingstatus per hashed token inDeviceCache(new SharedPreferences key)PostPendingTransactionsHelpernow detects whenisAutoRenewingchanges (e.g., user cancels/resubscribes via Play Store) and reposts only those tokenssyncPurchasesto detect external subscription changes, which is problematic because it posts all purchases withRESTOREinitiation source and can transfer purchases between usersHow it works
queryPurchases(), the helper compares each token's currentisAutoRenewingwith the cached valueContext: #3152 (comment)
Test plan
DeviceCache.getPurchasesWithAutoRenewingChange(5 tests: no cache, true→false, false→true, unchanged, not in sent cache)DeviceCache.saveAutoRenewingStatus(2 tests: saves JSON, skips null)PostPendingTransactionsHelperauto-renewing detection (4 tests: changed synced, combined with new purchases, no changes, status saved)🤖 Generated with Claude Code
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
DeviceCachethat stores hashed posted tokens plus per-token metadata (currentlyisAutoRenewing) in a JSON map, with in-memory caching and migration from the legacyStringSetkey.Updates pending purchase syncing to repost only tokens whose
isAutoRenewingchanged (in addition to newly-seen tokens), saving unchanged tokens’ auto-renewing status eagerly and updating changed tokens only after a successful post.Threads
isAutoRenewingthrough receipt posting and billing wrappers (Google,Amazon,Galaxy) by extendingaddSuccessfullyPostedToken/postTokenWithoutConsuming, and expands unit tests to cover migration, change detection, and the new caching call sites.Written by Cursor Bugbot for commit 563fc97. This will update automatically on new commits. Configure here.