Fix Purchase.synchronizeReceipts re-submitting same receipt and firing callback multiple times#4990
Merged
Merged
Conversation
…g callback N times Three closely-related bugs surfaced from a forum report of submitReceipt being invoked repeatedly: 1. removePendingPurchase matched only on transactionId and silently no-op'd when the receipt's transactionId was null. The pending queue then still contained the receipt, the recursion at the end of synchronizeReceipts pulled it again, and the same receipt was re-submitted forever. removePendingPurchase now takes the Receipt itself and falls back to matching on (sku, storeCode, purchaseDate, orderData) when transactionId is null on either side. 2. The recursive synchronizeReceipts(0, callback) re-registered the caller's SuccessCallback on every iteration, so a queue of N pending receipts caused the user's callback to fire N times. The recursive call now passes null since the original callback is already in synchronizeReceiptsCallbacks. 3. syncInProgress was reset to false after removePendingPurchase, so a throw from removePendingPurchase (or any user-supplied submitReceipt implementation) permanently wedged the sync state. syncInProgress is now reset at the top of onSucess. Added three regression tests in PurchaseTest: - testSynchronizeReceiptsSyncDrainsMultiplePendingReceipts: each pending receipt is submitted exactly once. - testSynchronizeReceiptsCallbackFiresOnceWhenDrainingMultiplePendingReceipts - testSynchronizeReceiptsDoesNotInfinitelyResubmitReceiptWithNullTransactionId (uses a CountingReceiptStore that throws after a cap so a regression fails the test instead of hanging it). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
Collaborator
Author
|
Compared 110 screenshots: 110 matched. Native Android coverage
✅ Native Android screenshot tests passed. Native Android coverage
Benchmark ResultsDetailed Performance Metrics
|
Collaborator
Author
|
Compared 110 screenshots: 110 matched. Benchmark Results
Build and Run Timing
Detailed Performance Metrics
|
Collaborator
Author
|
Compared 110 screenshots: 110 matched. Benchmark Results
Build and Run Timing
Detailed Performance Metrics
|
…r id Until now the framework's contract was effectively "submitReceipt fires once per pending-queue entry," which leaks platform-level behavior to the user: iOS StoreKit can redeliver an unfinished transaction across app sessions, and sandbox subscription renewals fire repeatedly — each delivery hits postReceipt and produces another submitReceipt call with the same transactionId. The user-facing contract was inconsistent across platforms. Add a persistent List<String> of processed transactionIds in CN1 Storage (key ProcessedPurchases.dat). Two checks close the gap: 1. addPendingPurchase drops a receipt whose transactionId is already in the processed set, or already sitting in the pending queue. So duplicate postReceipt calls — from iOS cross-session redelivery, in-session double-fire, or anything else — never reach the queue. 2. The success branch of synchronizeReceipts records the transactionId in the processed set before removing the receipt from pending. Doing it in that order means a parallel re-enqueue racing the remove is also dropped. Receipts with a null transactionId can't be tracked in the set; they fall back to the existing receiptsMatch-based in-pending dedup. Two new tests: - testPostReceiptSkipsReceiptThatWasAlreadySuccessfullySubmitted (cross-session redelivery — verified the test fails 1 vs 2 without the fix) - testPostReceiptSkipsDuplicateTransactionIdAlreadyPending (same-session double-fire before sync runs) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Forum report: a user implementing
ReceiptStore(storage-style —submitReceiptposts to their server and callscallback.onSucess(true)inline) seessubmitReceiptinvoked many times after a single in-app purchase. Investigation surfaced several bugs and a cross-platform abstraction gap.Commit 1 — Fix three bugs in
synchronizeReceiptsBug 1: receipts with a null
transactionIdare resubmitted forever.removePendingPurchase(String transactionId)only matched receipts with a non-null storedtransactionId. If a receipt with null tx reached the queue, remove was a no-op, the recursion picked it back up, andsubmitReceiptfired forever.removePendingPurchasenow takes theReceiptitself and falls back to matching on(sku, storeCode, purchaseDate, orderData)whentransactionIdis null on either side.Bug 2: caller's
SuccessCallbackfires N times for N pending receipts. The recursivesynchronizeReceipts(0, callback)re-registered the user's callback every iteration. Now passesnullsince the callback is already registered on the top-level call.Bug 3: thrown exceptions permanently wedge
syncInProgress.syncInProgress = falsewas set afterremovePendingPurchase. Any throw left it stucktruefor the rest of the app's lifetime. Now reset at the top ofonSucessbefore any work that can throw.Commit 2 — Dedupe
submitReceiptontransactionIdacross the install lifetimeThe framework's implicit contract was "submitReceipt fires once per pending-queue entry," which leaks platform behavior: iOS StoreKit redelivers unfinished transactions across sessions, sandbox subscription renewals fire on a compressed schedule, etc. — each delivery hits
postReceiptand produces anothersubmitReceiptcall with the sametransactionId.Now backed by a persistent
List<String>of processed transactionIds (Storage keyProcessedPurchases.dat):addPendingPurchasedrops a receipt whosetransactionIdis already in the processed set, or already sitting in the pending queue. DuplicatepostReceiptcalls never reach the queue.transactionIdbeforeremovePendingPurchase, so a parallel re-enqueue racing the remove is also dropped.Receipts with a null
transactionIdcan't be tracked in the set; they fall back to the existingreceiptsMatch-based in-pending dedup added in commit 1.The user-facing contract is now:
submitReceiptis invoked at most once pertransactionIdfor the lifetime of the install, on every platform.Test plan
PurchaseTest:testSynchronizeReceiptsSyncDrainsMultiplePendingReceipts— each pending receipt is submitted exactly once.testSynchronizeReceiptsCallbackFiresOnceWhenDrainingMultiplePendingReceipts— user callback fires once, not N times.testSynchronizeReceiptsDoesNotInfinitelyResubmitReceiptWithNullTransactionId— uses aCountingReceiptStorethat throws after a cap so a regression fails the test instead of hanging it.testPostReceiptSkipsReceiptThatWasAlreadySuccessfullySubmitted— simulates iOS cross-session redelivery.testPostReceiptSkipsDuplicateTransactionIdAlreadyPending— same-session double-fire before sync runs.PurchaseTestpass (mvn test -Dtest=PurchaseTest).submitReceipt invoked more than 5 times; pending receipt is being resubmitted in a loop; cross-session redelivery test failsexpected: <1> but was: <2>without the dedup).🤖 Generated with Claude Code