Skip to content

Commit b25aebd

Browse files
facumenzellaclaude
andcommitted
Unify token cache into single JSON key with auto-renewing status
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>
1 parent 8eed21d commit b25aebd

File tree

2 files changed

+231
-104
lines changed

2 files changed

+231
-104
lines changed

purchases/src/main/kotlin/com/revenuecat/purchases/common/caching/DeviceCache.kt

Lines changed: 90 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,31 @@ internal open class DeviceCache(
5656
val legacyAppUserIDCacheKey: String by lazy { apiKeyPrefix }
5757
val appUserIDCacheKey: String by lazy { "$apiKeyPrefix.new" }
5858
internal val attributionCacheKey = "$SHARED_PREFERENCES_PREFIX.attribution"
59-
val tokensCacheKey: String by lazy { "$apiKeyPrefix.tokens" }
60-
val tokensAutoRenewingCacheKey: String by lazy { "$apiKeyPrefix.tokensAutoRenewing" }
59+
60+
/**
61+
* Legacy key that stored sent token hashes as a `Set<String>` via `putStringSet`.
62+
* Replaced by [tokensCacheKey] which stores a JSON map of `{ hashedToken: isAutoRenewing }`.
63+
* Kept for migration: on first read, entries are migrated to the new key with `null`
64+
* auto-renewing values, then this key is deleted.
65+
*/
66+
val legacyTokensCacheKey: String by lazy { "$apiKeyPrefix.tokens" }
67+
68+
/**
69+
* Unified token cache storing both "which tokens have been posted" (the key set) and
70+
* their `isAutoRenewing` status (the values) as a JSON object.
71+
*
72+
* Format: `{ "hashedToken1": true, "hashedToken2": false, "hashedToken3": null }`
73+
* - Keys = hashed tokens that have been successfully posted (replaces the old StringSet)
74+
* - Values = cached `isAutoRenewing` status (`null` = unknown, e.g. migrated from legacy
75+
* cache or posted via a path that doesn't have the StoreTransaction)
76+
*
77+
* The `isAutoRenewing` values are used to detect subscription changes made outside the app
78+
* (e.g., cancellations in Play Store management). When `queryPurchases` returns a different
79+
* `isAutoRenewing` value than what's cached, the token is reposted so the backend can
80+
* re-check the subscription status with Google — avoiding a full `syncPurchases` which
81+
* reposts everything with `RESTORE` initiation source.
82+
*/
83+
val tokensCacheKey: String by lazy { "$apiKeyPrefix.tokensV2" }
6184
val storefrontCacheKey: String by lazy { "storefrontCacheKey" }
6285

6386
private val productEntitlementMappingCacheKey: String by lazy {
@@ -383,30 +406,69 @@ internal open class DeviceCache(
383406

384407
// region purchase tokens
385408

409+
/**
410+
* Returns the unified token map (hashedToken → isAutoRenewing), migrating from legacy
411+
* StringSet format if needed. The key set represents all previously sent tokens; the values
412+
* represent cached isAutoRenewing status (null = unknown).
413+
*/
386414
@Synchronized
387-
fun getPreviouslySentHashedTokens(): Set<String> {
415+
private fun getTokenMap(): Map<String, Boolean?> {
416+
val json = preferences.getString(tokensCacheKey, null)
417+
if (json != null) {
418+
return try {
419+
val jsonObject = JSONObject(json)
420+
jsonObject.keys().asSequence().associateWith { key ->
421+
if (jsonObject.isNull(key)) null else jsonObject.getBoolean(key)
422+
}
423+
} catch (@Suppress("SwallowedException") e: JSONException) {
424+
emptyMap()
425+
}
426+
}
427+
// Migrate from legacy StringSet if it exists
388428
return try {
389-
(preferences.getStringSet(tokensCacheKey, emptySet())?.toSet() ?: emptySet()).also {
390-
log(LogIntent.DEBUG) { ReceiptStrings.TOKENS_ALREADY_POSTED.format(it) }
429+
val legacyTokens = preferences.getStringSet(legacyTokensCacheKey, null)?.toSet()
430+
if (legacyTokens != null) {
431+
val migrated = legacyTokens.associateWith<String, Boolean?> { null }
432+
saveTokenMap(migrated)
433+
preferences.edit().remove(legacyTokensCacheKey).apply()
434+
log(LogIntent.DEBUG) { ReceiptStrings.TOKENS_ALREADY_POSTED.format(migrated.keys) }
435+
migrated
436+
} else {
437+
emptyMap()
391438
}
392-
} catch (e: ClassCastException) {
393-
emptySet()
439+
} catch (@Suppress("SwallowedException") e: ClassCastException) {
440+
emptyMap()
394441
}
395442
}
396443

397444
@Synchronized
398-
fun addSuccessfullyPostedToken(token: String) {
399-
log(LogIntent.DEBUG) { ReceiptStrings.SAVING_TOKENS_WITH_HASH.format(token, token.sha1()) }
400-
getPreviouslySentHashedTokens().let {
401-
log(LogIntent.DEBUG) { ReceiptStrings.TOKENS_IN_CACHE.format(it) }
402-
setSavedTokenHashes(it.toMutableSet().apply { add(token.sha1()) })
445+
private fun saveTokenMap(tokenMap: Map<String, Boolean?>) {
446+
val jsonObject = JSONObject().apply {
447+
for ((hash, autoRenewing) in tokenMap) {
448+
put(hash, autoRenewing ?: JSONObject.NULL)
449+
}
450+
}
451+
log(LogIntent.DEBUG) { ReceiptStrings.SAVING_TOKENS.format(tokenMap.keys) }
452+
putString(tokensCacheKey, jsonObject.toString())
453+
}
454+
455+
@Synchronized
456+
fun getPreviouslySentHashedTokens(): Set<String> {
457+
return getTokenMap().keys.also {
458+
log(LogIntent.DEBUG) { ReceiptStrings.TOKENS_ALREADY_POSTED.format(it) }
403459
}
404460
}
405461

406462
@Synchronized
407-
private fun setSavedTokenHashes(newSet: Set<String>) {
408-
log(LogIntent.DEBUG) { ReceiptStrings.SAVING_TOKENS.format(newSet) }
409-
preferences.edit().putStringSet(tokensCacheKey, newSet).apply()
463+
fun addSuccessfullyPostedToken(token: String) {
464+
val hashedToken = token.sha1()
465+
log(LogIntent.DEBUG) { ReceiptStrings.SAVING_TOKENS_WITH_HASH.format(token, hashedToken) }
466+
val current = getTokenMap().toMutableMap()
467+
log(LogIntent.DEBUG) { ReceiptStrings.TOKENS_IN_CACHE.format(current.keys) }
468+
if (hashedToken !in current) {
469+
current[hashedToken] = null
470+
saveTokenMap(current)
471+
}
410472
}
411473

412474
/**
@@ -418,9 +480,9 @@ internal open class DeviceCache(
418480
hashedTokens: Set<String>,
419481
) {
420482
log(LogIntent.DEBUG) { ReceiptStrings.CLEANING_PREV_SENT_HASHED_TOKEN }
421-
setSavedTokenHashes(
422-
hashedTokens.intersect(getPreviouslySentHashedTokens()),
423-
)
483+
val current = getTokenMap()
484+
val cleaned = current.filterKeys { it in hashedTokens }
485+
saveTokenMap(cleaned)
424486
}
425487

426488
/**
@@ -447,33 +509,26 @@ internal open class DeviceCache(
447509
fun getPurchasesWithAutoRenewingChange(
448510
hashedTokens: Map<String, StoreTransaction>,
449511
): List<StoreTransaction> {
450-
val cachedAutoRenewing = getCachedAutoRenewingStatus()
451-
val sentTokens = getPreviouslySentHashedTokens()
512+
val tokenMap = getTokenMap()
452513
return hashedTokens.filter { (hash, transaction) ->
453-
hash in sentTokens &&
454-
hash in cachedAutoRenewing &&
455-
transaction.isAutoRenewing != cachedAutoRenewing[hash]
514+
val cachedAutoRenewing = tokenMap[hash]
515+
cachedAutoRenewing != null && transaction.isAutoRenewing != cachedAutoRenewing
456516
}.values.toList()
457517
}
458518

459-
@Synchronized
460-
private fun getCachedAutoRenewingStatus(): Map<String, Boolean> {
461-
val jsonObject = getJSONObjectOrNull(tokensAutoRenewingCacheKey) ?: return emptyMap()
462-
return jsonObject.keys().asSequence().associateWith { jsonObject.getBoolean(it) }
463-
}
464-
465519
/**
466-
* Saves the auto-renewing status for the given hashed tokens, replacing any previously stored
467-
* values. Also removes entries for tokens no longer in [hashedTokens].
520+
* Saves the auto-renewing status for all given hashed tokens. Tokens already in the cache
521+
* get their status updated; tokens not in the cache are ignored (they haven't been posted yet).
468522
*/
469523
@Synchronized
470524
fun saveAutoRenewingStatus(hashedTokens: Map<String, StoreTransaction>) {
471-
val jsonObject = JSONObject().apply {
472-
for ((hash, transaction) in hashedTokens) {
473-
transaction.isAutoRenewing?.let { put(hash, it) }
525+
val current = getTokenMap().toMutableMap()
526+
for ((hash, transaction) in hashedTokens) {
527+
if (hash in current) {
528+
current[hash] = transaction.isAutoRenewing
474529
}
475530
}
476-
putString(tokensAutoRenewingCacheKey, jsonObject.toString())
531+
saveTokenMap(current)
477532
}
478533

479534
// endregion

0 commit comments

Comments
 (0)