Skip to content

Fix Purchase.synchronizeReceipts re-submitting same receipt and firing callback multiple times#4990

Merged
shai-almog merged 2 commits into
masterfrom
fix/purchase-iap-multiple-submit-receipt
May 20, 2026
Merged

Fix Purchase.synchronizeReceipts re-submitting same receipt and firing callback multiple times#4990
shai-almog merged 2 commits into
masterfrom
fix/purchase-iap-multiple-submit-receipt

Conversation

@shai-almog
Copy link
Copy Markdown
Collaborator

@shai-almog shai-almog commented May 20, 2026

Summary

Forum report: a user implementing ReceiptStore (storage-style — submitReceipt posts to their server and calls callback.onSucess(true) inline) sees submitReceipt invoked many times after a single in-app purchase. Investigation surfaced several bugs and a cross-platform abstraction gap.

Commit 1 — Fix three bugs in synchronizeReceipts

Bug 1: receipts with a null transactionId are resubmitted forever. removePendingPurchase(String transactionId) only matched receipts with a non-null stored transactionId. If a receipt with null tx reached the queue, remove was a no-op, the recursion picked it back up, and submitReceipt fired forever. removePendingPurchase now takes the Receipt itself and falls back to matching on (sku, storeCode, purchaseDate, orderData) when transactionId is null on either side.

Bug 2: caller's SuccessCallback fires N times for N pending receipts. The recursive synchronizeReceipts(0, callback) re-registered the user's callback every iteration. Now passes null since the callback is already registered on the top-level call.

Bug 3: thrown exceptions permanently wedge syncInProgress. syncInProgress = false was set after removePendingPurchase. Any throw left it stuck true for the rest of the app's lifetime. Now reset at the top of onSucess before any work that can throw.

Commit 2 — Dedupe submitReceipt on transactionId across the install lifetime

The 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 postReceipt and produces another submitReceipt call with the same transactionId.

Now backed by a persistent List<String> of processed transactionIds (Storage key ProcessedPurchases.dat):

  1. addPendingPurchase drops a receipt whose transactionId is already in the processed set, or already sitting in the pending queue. Duplicate postReceipt calls never reach the queue.
  2. The success branch records the transactionId before removePendingPurchase, so 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 added in commit 1.

The user-facing contract is now: submitReceipt is invoked at most once per transactionId for the lifetime of the install, on every platform.

Test plan

  • Five new regression tests in PurchaseTest:
    • testSynchronizeReceiptsSyncDrainsMultiplePendingReceipts — each pending receipt is submitted exactly once.
    • testSynchronizeReceiptsCallbackFiresOnceWhenDrainingMultiplePendingReceipts — user callback fires once, not N times.
    • testSynchronizeReceiptsDoesNotInfinitelyResubmitReceiptWithNullTransactionId — uses a CountingReceiptStore that 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.
  • All 16 tests in PurchaseTest pass (mvn test -Dtest=PurchaseTest).
  • Verified each new test fails when the corresponding fix is reverted (e.g. submitReceipt invoked more than 5 times; pending receipt is being resubmitted in a loop; cross-session redelivery test fails expected: <1> but was: <2> without the dedup).

🤖 Generated with Claude Code

…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>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 20, 2026

✅ Continuous Quality Report

Test & Coverage

Static Analysis

  • SpotBugs [Report archive]
    • ByteCodeTranslator: 0 findings (no issues)
    • android: 0 findings (no issues)
    • codenameone-maven-plugin: 0 findings (no issues)
    • core-unittests: 0 findings (no issues)
    • ios: 0 findings (no issues)
  • PMD: 0 findings (no issues) [Report archive]
  • Checkstyle: 0 findings (no issues) [Report archive]

Generated automatically by the PR CI workflow.

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 20, 2026

Compared 110 screenshots: 110 matched.

Native Android coverage

  • 📊 Line coverage: 11.82% (6581/55655 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 9.53% (33026/346569), branch 4.12% (1358/32925), complexity 5.19% (1637/31558), method 9.02% (1331/14749), class 15.07% (301/1998)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

✅ Native Android screenshot tests passed.

Native Android coverage

  • 📊 Line coverage: 11.82% (6581/55655 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 9.53% (33026/346569), branch 4.12% (1358/32925), complexity 5.19% (1637/31558), method 9.02% (1331/14749), class 15.07% (301/1998)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

Benchmark Results

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 976.000 ms
Base64 CN1 encode 108.000 ms
Base64 encode ratio (CN1/native) 0.111x (88.9% faster)
Base64 native decode 944.000 ms
Base64 CN1 decode 226.000 ms
Base64 decode ratio (CN1/native) 0.239x (76.1% faster)
Image encode benchmark status skipped (SIMD unsupported)

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 20, 2026

Compared 110 screenshots: 110 matched.
✅ Native iOS Metal screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 266 seconds

Build and Run Timing

Metric Duration
Simulator Boot 77000 ms
Simulator Boot (Run) 1000 ms
App Install 12000 ms
App Launch 33000 ms
Test Execution 255000 ms

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 633.000 ms
Base64 CN1 encode 1228.000 ms
Base64 encode ratio (CN1/native) 1.940x (94.0% slower)
Base64 native decode 270.000 ms
Base64 CN1 decode 949.000 ms
Base64 decode ratio (CN1/native) 3.515x (251.5% slower)
Base64 SIMD encode 449.000 ms
Base64 encode ratio (SIMD/native) 0.709x (29.1% faster)
Base64 encode ratio (SIMD/CN1) 0.366x (63.4% faster)
Base64 SIMD decode 392.000 ms
Base64 decode ratio (SIMD/native) 1.452x (45.2% slower)
Base64 decode ratio (SIMD/CN1) 0.413x (58.7% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 56.000 ms
Image createMask (SIMD on) 9.000 ms
Image createMask ratio (SIMD on/off) 0.161x (83.9% faster)
Image applyMask (SIMD off) 124.000 ms
Image applyMask (SIMD on) 72.000 ms
Image applyMask ratio (SIMD on/off) 0.581x (41.9% faster)
Image modifyAlpha (SIMD off) 137.000 ms
Image modifyAlpha (SIMD on) 52.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.380x (62.0% faster)
Image modifyAlpha removeColor (SIMD off) 139.000 ms
Image modifyAlpha removeColor (SIMD on) 60.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.432x (56.8% faster)
Image PNG encode (SIMD off) 980.000 ms
Image PNG encode (SIMD on) 801.000 ms
Image PNG encode ratio (SIMD on/off) 0.817x (18.3% faster)
Image JPEG encode 515.000 ms

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 20, 2026

Compared 110 screenshots: 110 matched.
✅ Native iOS screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 156 seconds

Build and Run Timing

Metric Duration
Simulator Boot 63000 ms
Simulator Boot (Run) 1000 ms
App Install 12000 ms
App Launch 14000 ms
Test Execution 328000 ms

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 1217.000 ms
Base64 CN1 encode 1808.000 ms
Base64 encode ratio (CN1/native) 1.486x (48.6% slower)
Base64 native decode 389.000 ms
Base64 CN1 decode 1210.000 ms
Base64 decode ratio (CN1/native) 3.111x (211.1% slower)
Base64 SIMD encode 634.000 ms
Base64 encode ratio (SIMD/native) 0.521x (47.9% faster)
Base64 encode ratio (SIMD/CN1) 0.351x (64.9% faster)
Base64 SIMD decode 615.000 ms
Base64 decode ratio (SIMD/native) 1.581x (58.1% slower)
Base64 decode ratio (SIMD/CN1) 0.508x (49.2% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 90.000 ms
Image createMask (SIMD on) 15.000 ms
Image createMask ratio (SIMD on/off) 0.167x (83.3% faster)
Image applyMask (SIMD off) 160.000 ms
Image applyMask (SIMD on) 98.000 ms
Image applyMask ratio (SIMD on/off) 0.613x (38.7% faster)
Image modifyAlpha (SIMD off) 158.000 ms
Image modifyAlpha (SIMD on) 73.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.462x (53.8% faster)
Image modifyAlpha removeColor (SIMD off) 1860.000 ms
Image modifyAlpha removeColor (SIMD on) 94.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.051x (94.9% faster)
Image PNG encode (SIMD off) 1582.000 ms
Image PNG encode (SIMD on) 1099.000 ms
Image PNG encode ratio (SIMD on/off) 0.695x (30.5% faster)
Image JPEG encode 548.000 ms

…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>
@shai-almog shai-almog merged commit 750cde7 into master May 20, 2026
18 checks passed
shai-almog added a commit that referenced this pull request May 28, 2026
…read (#5064)

* Fix #5010: serialize Purchase state on the EDT to unblock iOS main thread

PR #4990 introduced extra Storage I/O inside `Purchase.addPendingPurchase`
(it now reads ProcessedPurchases.dat in addition to PendingPurchases.dat).
That I/O happens inside `synchronized (PENDING_PURCHASE_LOCK)`, on
whichever thread native code calls `postReceipt` from -- on iOS that's the
main UI thread (StoreKit invokes `paymentQueue:updatedTransactions:` on it,
including sandbox auto-renewals that fire on a compressed schedule on
iOS 26.x).  The added per-callback I/O was enough to wedge keyboard input
once a TextField was active, because the same iOS main thread is
responsible for both StoreKit delivery and UIKit events.

Replace the three lock objects (`PENDING_PURCHASE_LOCK`,
`synchronizationLock`, `receiptsLock`) with EDT-based serialization:

- All state mutations (`addPendingPurchase`, `removePendingPurchase`,
  `recordProcessedTransactionId`, `setReceipts`, `setReceiptsRefreshTime`,
  `synchronizeReceipts`, `loadReceipts`, `postReceipt`) auto-dispatch to
  the EDT via `Display.callSerially` when called off-EDT.  Once on the
  EDT, single-threaded execution makes `synchronized` blocks unnecessary.

- `ReceiptStore.submitReceipt` / `fetchReceipts` callbacks may fire on any
  thread; the new `onSubmitReceiptComplete` / `onLoadReceiptsComplete`
  helpers (and the inline `loadReceipts` fetch callback) re-dispatch to
  the EDT before touching state, so reentrancy from any thread is safe.

- Public read accessors (`getReceipts`, `getPendingPurchases`) preserve
  their existing "callable from any thread" contract by using
  `callSeriallyAndWait` when invoked off-EDT.

- `receiptStore` is marked `volatile` for safe publication, since
  `setReceiptStore` / `getReceiptStore` / the `subscribe` family have no
  thread contract.

Net effect:
- iOS native thread returns from `postReceipt` immediately; Storage I/O
  no longer blocks the UI thread.
- The recursive `synchronizeReceipts(0, null)` becomes a tail
  `callSerially` instead of a same-thread recursive call -- naturally
  bounded, no held-lock concerns.
- All three lock objects, plus the `synchronized` blocks they guarded,
  are deleted.  The only remaining `synchronized` blocks are on a
  local `boolean[]` monitor inside `SynchronizeReceiptsSyncRunnable` /
  `SynchronizeReceiptsSyncSuccessCallback`, used for the existing
  `invokeAndBlock` wait/notify pattern in `synchronizeReceiptsSync`.

All 16 tests in `PurchaseTest` pass unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Eagerly initialize synchronizeReceiptsCallbacks to silence SpotBugs

The lazy-init pattern was acceptable while the field was protected by
synchronizationLock, but after the EDT-serialization refactor SpotBugs
flags it as LI_LAZY_INIT_STATIC because the write to the static field
is unsynchronized.  Access is EDT-only by construction so there is no
real race, but the simplest way to silence the analyzer is to drop the
lazy init entirely: declare the list final, initialize it at class
load, and remove the now-redundant null guards in synchronizeReceipts
and fireSynchronizeReceiptsCallbacks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix PMD: drop volatile on receiptStore, add @OverRide to anonymous Runnables

Two PMD rules fired on the EDT-serialization refactor:

- AvoidUsingVolatile on `receiptStore`.  The field was not volatile
  before this refactor; I had added the modifier defensively for safe
  publication across the off-EDT setter/getter.  The pre-existing
  behavior (plain field) was unchanged for years, so drop the modifier
  to stay within the project's coding standards.

- MissingOverride on the nine new anonymous `Runnable` instances added
  by the auto-dispatch pattern.  Add the @OverRide annotation to each.

No behavior change.  PurchaseTest 16/16 still pass; local SpotBugs and
PMD both clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant