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